From bee8abdba49e2275d203e3b0b4a3afac330ec4ea Mon Sep 17 00:00:00 2001 From: Andrew Bonney Date: Mon, 18 Mar 2019 23:32:42 +0000 Subject: [PATCH 0001/1433] Adjust query intervals to match RFC 6762 (#159) * Limit query backoff time to one hour as-per rfc6762 section 5.2 * tests: monkey patch backoff limit to focus testing on TTL expiry * tests: speed up integration test * tests: add test of query backoff interval and limit * Set initial query interval to 1 second as-per rfc6762 sec 5.2 * Add comments around timing constants * tests: fix linting errors * tests: fix float assignment to integer var Sets the repeated query backoff limit to one hour as opposed to 20 seconds, reducing unnecessary network traffic Adds a test for the behaviour of the backoff procedure Sets the first repeated query to happen after one second as opposed to 500ms --- test_zeroconf.py | 87 +++++++++++++++++++++++++++++++++++++++++++++--- zeroconf.py | 13 ++++---- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index 0c4d771ab..f337e63f8 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -799,6 +799,74 @@ def remove_service(self, zeroconf, type, name): zeroconf_browser.close() +def test_backoff(): + got_query = Event() + + type_ = "_http._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to monkey patch the zeroconf send to check query transmission + old_send = zeroconf_browser.send + + time_offset = 0.0 + start_time = time.time() * 1000 + initial_query_interval = r._BROWSER_TIME / 1000 + + def current_time_millis(): + """Current system time in milliseconds""" + return start_time + time_offset * 1000 + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + got_query.set() + old_send(out, addr=addr, port=port) + + # monkey patch the zeroconf send + setattr(zeroconf_browser, "send", send) + + # monkey patch the zeroconf current_time_millis + r.current_time_millis = current_time_millis + + # monkey patch the backoff limit to prevent test running forever + r._BROWSER_BACKOFF_LIMIT = 10 # seconds + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + try: + # Test that queries are sent at increasing intervals + sleep_count = 0 + next_query_interval = 0.0 + expected_query_time = 0.0 + while True: + zeroconf_browser.notify_all() + sleep_count += 1 + got_query.wait(0.1) + if time_offset == expected_query_time: + assert got_query.is_set() + got_query.clear() + if next_query_interval == r._BROWSER_BACKOFF_LIMIT: + # Only need to test up to the point where we've seen a query + # after the backoff limit has been hit + break + elif next_query_interval == 0: + next_query_interval = initial_query_interval + expected_query_time = initial_query_interval + else: + next_query_interval = min(2*next_query_interval, r._BROWSER_BACKOFF_LIMIT) + expected_query_time += next_query_interval + else: + assert not got_query.is_set() + time_offset += initial_query_interval + + finally: + browser.cancel() + zeroconf_browser.close() + + def test_integration(): service_added = Event() service_removed = Event() @@ -828,14 +896,14 @@ def current_time_millis(): expected_ttl = r._DNS_TTL - nbr_queries = 0 + nbr_answers = 0 def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): """Sends an outgoing packet.""" pout = r.DNSIncoming(out.packet()) - nonlocal nbr_queries + nonlocal nbr_answers for answer in pout.answers: - nbr_queries += 1 + nbr_answers += 1 if not answer.ttl > expected_ttl / 2: unexpected_ttl.set() @@ -848,6 +916,9 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): # monkey patch the zeroconf current_time_millis r.current_time_millis = current_time_millis + # monkey patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL + r._BROWSER_BACKOFF_LIMIT = int(expected_ttl / 4) + service_added = Event() service_removed = Event() @@ -865,13 +936,19 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): service_added.wait(1) assert service_added.is_set() + # Test that we receive queries containing answers only if the remaining TTL + # is greater than half the original TTL sleep_count = 0 - while nbr_queries < 50: + test_iterations = 50 + while nbr_answers < test_iterations: + # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 zeroconf_browser.notify_all() sleep_count += 1 - got_query.wait(1) + got_query.wait(0.1) got_query.clear() + # Prevent the test running indefinitely in an error condition + assert sleep_count < test_iterations * 4 assert not unexpected_ttl.is_set() # Don't remove service, allow close() to cleanup diff --git a/zeroconf.py b/zeroconf.py index 978c25464..c10680942 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -62,11 +62,12 @@ # Some timing constants -_UNREGISTER_TIME = 125 -_CHECK_TIME = 175 -_REGISTER_TIME = 225 -_LISTENER_TIME = 200 -_BROWSER_TIME = 500 +_UNREGISTER_TIME = 125 # ms +_CHECK_TIME = 175 # ms +_REGISTER_TIME = 225 # ms +_LISTENER_TIME = 200 # ms +_BROWSER_TIME = 1000 # ms +_BROWSER_BACKOFF_LIMIT = 3600 # s # Some DNS constants @@ -1383,7 +1384,7 @@ def run(self): self.zc.send(out, addr=self.addr, port=self.port) self.next_time = now + self.delay - self.delay = min(20 * 1000, self.delay * 2) + self.delay = min(_BROWSER_BACKOFF_LIMIT * 1000, self.delay * 2) if len(self._handlers_to_call) > 0 and not self.zc.done: handler = self._handlers_to_call.pop(0) From 57310e185a4f924dd257edd64f866da685a786c6 Mon Sep 17 00:00:00 2001 From: Andrew Bonney Date: Fri, 29 Mar 2019 10:59:39 +0000 Subject: [PATCH 0002/1433] Fix service removal packets not being sent on shutdown --- test_zeroconf.py | 2 ++ zeroconf.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index f337e63f8..89da406fa 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -955,5 +955,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): finally: zeroconf_registrar.close() + service_removed.wait(1) + assert service_removed.is_set() browser.cancel() zeroconf_browser.close() diff --git a/zeroconf.py b/zeroconf.py index c10680942..4d5dc82ab 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -2121,10 +2121,10 @@ def close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" if not self._GLOBAL_DONE: - self._GLOBAL_DONE = True # remove service listeners self.remove_all_service_listeners() self.unregister_all_services() + self._GLOBAL_DONE = True # shutdown recv socket and thread if not self.unicast: From f25989d8cdae8f77e19eba70f236dd8103b33e8f Mon Sep 17 00:00:00 2001 From: Andrew Bonney Date: Mon, 18 Mar 2019 20:47:39 +0000 Subject: [PATCH 0003/1433] ttl: modify default used to respond to _services queries --- test_zeroconf.py | 26 +++++++++++++------------- zeroconf.py | 7 ++++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index 89da406fa..b30b260a8 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -59,7 +59,7 @@ def test_dns_hinfo_repr_eq(self): def test_dns_pointer_repr(self): pointer = r.DNSPointer( - 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_TTL, '123') + 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_HOST_TTL, '123') repr(pointer) def test_dns_address_repr(self): @@ -74,11 +74,11 @@ def test_dns_question_repr(self): def test_dns_service_repr(self): service = r.DNSService( - 'irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL, 0, 0, 80, b'a') + 'irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, b'a') repr(service) def test_dns_record_abc(self): - record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL) + record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) self.assertRaises(r.AbstractMethodException, record.__eq__, record) self.assertRaises(r.AbstractMethodException, record.write, None) @@ -134,7 +134,7 @@ def test_parse_own_packet_question(self): def test_parse_own_packet_response(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) generated.add_answer_at_time(r.DNSService( - "æøå.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL, 0, 0, 80, "foo.local."), 0) + "æøå.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local."), 0) parsed = r.DNSIncoming(generated.packet()) self.assertEqual(len(generated.answers), 1) self.assertEqual(len(generated.answers), len(parsed.answers)) @@ -153,11 +153,11 @@ def test_suppress_answer(self): question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) query_generated.add_question(question) answer1 = r.DNSService( - "testname1.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL, 0, 0, 80, "foo.local.") + "testname1.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local.") staleanswer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL/2, 0, 0, 80, "foo.local.") + "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL/2, 0, 0, 80, "foo.local.") answer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_TTL, 0, 0, 80, "foo.local.") + "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local.") query_generated.add_answer_at_time(answer1, 0) query_generated.add_answer_at_time(staleanswer2, 0) query = r.DNSIncoming(query_generated.packet()) @@ -441,10 +441,10 @@ def generate_host(zc, host_name, type_): out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) out.add_answer_at_time( r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, - r._DNS_TTL, name), 0) + r._DNS_HOST_TTL, name), 0) out.add_answer_at_time( r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, - r._DNS_TTL, 0, 0, 80, + r._DNS_HOST_TTL, 0, 0, 80, name), 0) zc.send(out) @@ -609,7 +609,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): setattr(zc, "send", send) # register service with default TTL - expected_ttl = r._DNS_TTL + expected_ttl = r._DNS_HOST_TTL zc.register_service(info) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -631,8 +631,8 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): nbr_answers = nbr_additionals = nbr_authorities = 0 # register service with custom TTL - expected_ttl = r._DNS_TTL * 2 - assert expected_ttl != r._DNS_TTL + expected_ttl = r._DNS_HOST_TTL * 2 + assert expected_ttl != r._DNS_HOST_TTL zc.register_service(info, ttl=expected_ttl) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -894,7 +894,7 @@ def current_time_millis(): """Current system time in milliseconds""" return time.time() * 1000 + time_offset * 1000 - expected_ttl = r._DNS_TTL + expected_ttl = r._DNS_HOST_TTL nbr_answers = 0 diff --git a/zeroconf.py b/zeroconf.py index 4d5dc82ab..894fa1018 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -74,7 +74,8 @@ _MDNS_ADDR = '224.0.0.251' _MDNS_PORT = 5353 _DNS_PORT = 53 -_DNS_TTL = 120 # two minutes default TTL as recommended by RFC6762 +_DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 +_DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per RFC6762 _MAX_MSG_TYPICAL = 1460 # unused _MAX_MSG_ABSOLUTE = 8966 @@ -1835,7 +1836,7 @@ def remove_all_service_listeners(self) -> None: self.remove_service_listener(listener) def register_service( - self, info: ServiceInfo, ttl: int = _DNS_TTL, allow_name_change: bool = False, + self, info: ServiceInfo, ttl: int = _DNS_HOST_TTL, allow_name_change: bool = False, ) -> None: """Registers service information to the network with a default TTL of 60 seconds. Zeroconf will then respond to requests for @@ -2048,7 +2049,7 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer(msg, DNSPointer( "_services._dns-sd._udp.local.", _TYPE_PTR, - _CLASS_IN, _DNS_TTL, stype)) + _CLASS_IN, _DNS_OTHER_TTL, stype)) for service in self.services.values(): if question.name == service.type: if out is None: From a7aedb58649f557a5e372fc776f98457ce84eb39 Mon Sep 17 00:00:00 2001 From: Andrew Bonney Date: Tue, 19 Mar 2019 21:13:50 +0000 Subject: [PATCH 0004/1433] Use recommended TTLs with overrides via ServiceInfo --- test_zeroconf.py | 21 +++++++++++++++------ zeroconf.py | 39 ++++++++++++++++++++------------------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index b30b260a8..822f2cbc5 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -59,7 +59,7 @@ def test_dns_hinfo_repr_eq(self): def test_dns_pointer_repr(self): pointer = r.DNSPointer( - 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_HOST_TTL, '123') + 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') repr(pointer) def test_dns_address_repr(self): @@ -441,7 +441,7 @@ def generate_host(zc, host_name, type_): out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) out.add_answer_at_time( r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, - r._DNS_HOST_TTL, name), 0) + r._DNS_OTHER_TTL, name), 0) out.add_answer_at_time( r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, @@ -591,25 +591,34 @@ def test_ttl(self): nbr_answers = nbr_additionals = nbr_authorities = 0 + def get_ttl(record_type): + if expected_ttl is not None: + return expected_ttl + elif record_type in [r._TYPE_A, r._TYPE_SRV]: + return r._DNS_HOST_TTL + else: + return r._DNS_OTHER_TTL + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): """Sends an outgoing packet.""" nonlocal nbr_answers, nbr_additionals, nbr_authorities + for answer, time_ in out.answers: nbr_answers += 1 - assert answer.ttl == expected_ttl + assert answer.ttl == get_ttl(answer.type) for answer in out.additionals: nbr_additionals += 1 - assert answer.ttl == expected_ttl + assert answer.ttl == get_ttl(answer.type) for answer in out.authorities: nbr_authorities += 1 - assert answer.ttl == expected_ttl + assert answer.ttl == get_ttl(answer.type) old_send(out, addr=addr, port=port) # monkey patch the zeroconf send setattr(zc, "send", send) # register service with default TTL - expected_ttl = r._DNS_HOST_TTL + expected_ttl = None zc.register_service(info) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 diff --git a/zeroconf.py b/zeroconf.py index 894fa1018..164bc09e3 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -1427,9 +1427,8 @@ def __init__(self, type_: str, name: str, address: Optional[bytes] = None, port: self.server = name self._properties = {} # type: ServicePropertiesType self._set_properties(properties) - # FIXME: this is here only so that mypy doesn't complain when we set and then use the attribute when - # registering services. See if setting this to None by default is the right way to go. - self.ttl = None # type: Optional[int] + self.host_ttl = _DNS_HOST_TTL + self.other_ttl = _DNS_OTHER_TTL @property def properties(self) -> ServicePropertiesType: @@ -1836,13 +1835,15 @@ def remove_all_service_listeners(self) -> None: self.remove_service_listener(listener) def register_service( - self, info: ServiceInfo, ttl: int = _DNS_HOST_TTL, allow_name_change: bool = False, + self, info: ServiceInfo, ttl: Optional[int] = None, allow_name_change: bool = False, ) -> None: - """Registers service information to the network with a default TTL - of 60 seconds. Zeroconf will then respond to requests for - information for that service. The name of the service may be - changed if needed to make it unique on the network.""" - info.ttl = ttl + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. The name of the service may be changed if needed to make + it unique on the network.""" + if ttl is not None: + info.host_ttl = ttl + info.other_ttl = ttl self.check_service(info, allow_name_change) self.services[info.name.lower()] = info if info.type in self.servicetypes: @@ -1859,18 +1860,18 @@ def register_service( continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer_at_time( - DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, ttl, info.name), 0) + DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name), 0) out.add_answer_at_time( DNSService(info.name, _TYPE_SRV, _CLASS_IN, - ttl, info.priority, info.weight, info.port, + info.host_ttl, info.priority, info.weight, info.port, info.server), 0) out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN, ttl, info.text), 0) + DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) if info.address: out.add_answer_at_time( DNSAddress(info.server, _TYPE_A, _CLASS_IN, - ttl, info.address), 0) + info.host_ttl, info.address), 0) self.send(out) i += 1 next_time += _REGISTER_TIME @@ -1978,7 +1979,7 @@ def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: self.debug = out out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) out.add_authorative_answer(DNSPointer( - info.type, _TYPE_PTR, _CLASS_IN, info.ttl, info.name)) + info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) self.send(out) i += 1 next_time += _CHECK_TIME @@ -2056,7 +2057,7 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer(msg, DNSPointer( service.type, _TYPE_PTR, - _CLASS_IN, service.ttl, service.name)) + _CLASS_IN, service.other_ttl, service.name)) else: try: if out is None: @@ -2069,7 +2070,7 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: out.add_answer(msg, DNSAddress( question.name, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, - service.ttl, service.address)) + service.host_ttl, service.address)) name_to_find = question.name.lower() if name_to_find not in self.services: @@ -2079,16 +2080,16 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: if question.type in (_TYPE_SRV, _TYPE_ANY): out.add_answer(msg, DNSService( question.name, _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, - service.ttl, service.priority, service.weight, + service.host_ttl, service.priority, service.weight, service.port, service.server)) if question.type in (_TYPE_TXT, _TYPE_ANY): out.add_answer(msg, DNSText( question.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, - service.ttl, service.text)) + service.other_ttl, service.text)) if question.type == _TYPE_SRV: out.add_additional_answer(DNSAddress( service.server, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, - service.ttl, service.address)) + service.host_ttl, service.address)) except Exception: # TODO stop catching all Exceptions self.log_exception_warning() From ecc021b7a3cec863eed5a3f71a1f28e3026c25b0 Mon Sep 17 00:00:00 2001 From: Andrew Bonney Date: Tue, 19 Mar 2019 21:39:06 +0000 Subject: [PATCH 0005/1433] Add arguments to set TTLs via ServiceInfo --- zeroconf.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/zeroconf.py b/zeroconf.py index 164bc09e3..0743b7e97 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -1400,7 +1400,8 @@ class ServiceInfo(RecordUpdateListener): """Service information""" def __init__(self, type_: str, name: str, address: Optional[bytes] = None, port: Optional[int] = None, - weight: int = 0, priority: int = 0, properties=b'', server: Optional[str] = None) -> None: + weight: int = 0, priority: int = 0, properties=b'', server: Optional[str] = None, + host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL) -> None: """Create a service description. type_: fully qualified service type name @@ -1411,7 +1412,9 @@ def __init__(self, type_: str, name: str, address: Optional[bytes] = None, port: priority: priority of the service properties: dictionary of properties (or a string holding the bytes for the text field) - server: fully qualified name for service host (defaults to name)""" + server: fully qualified name for service host (defaults to name) + host_ttl: ttl used for A/SRV records + other_ttl: ttl used for PTR/TXT records""" if not type_.endswith(service_type_name(name, allow_underscores=True)): raise BadTypeInNameException @@ -1427,8 +1430,8 @@ def __init__(self, type_: str, name: str, address: Optional[bytes] = None, port: self.server = name self._properties = {} # type: ServicePropertiesType self._set_properties(properties) - self.host_ttl = _DNS_HOST_TTL - self.other_ttl = _DNS_OTHER_TTL + self.host_ttl = host_ttl + self.other_ttl = other_ttl @property def properties(self) -> ServicePropertiesType: @@ -1842,6 +1845,8 @@ def register_service( service. The name of the service may be changed if needed to make it unique on the network.""" if ttl is not None: + # ttl argument is used to maintain backward compatibility + # Setting TTLs via ServiceInfo is preferred info.host_ttl = ttl info.other_ttl = ttl self.check_service(info, allow_name_change) From db1dcf682e453766b53773d70c0091b81a87a192 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sat, 27 Apr 2019 21:18:46 +0200 Subject: [PATCH 0006/1433] Prepare release 0.22.0 --- README.rst | 14 ++++++++++++++ zeroconf.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 36b521962..f610e1714 100644 --- a/README.rst +++ b/README.rst @@ -120,6 +120,20 @@ See examples directory for more. Changelog ========= +0.22.0 +------ + +* A lot of maintenance work (tooling, typing coverage and improvements, spelling) done, thanks to Ville Skyttä +* Provided saner defaults in ServiceInfo's constructor, thanks to Jorge Miranda +* Fixed service removal packets not being sent on shutdown, thanks to Andrew Bonney +* Added a way to define TTL-s through ServiceInfo contructor parameters, thanks to Andrew Bonney + +Technically backwards incompatible: + +* Adjusted query intervals to match RFC 6762, thanks to Andrew Bonney +* Made default TTL-s match RFC 6762, thanks to Andrew Bonney + + 0.21.3 ------ diff --git a/zeroconf.py b/zeroconf.py index 0743b7e97..098a1e5e6 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -38,7 +38,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.21.3' +__version__ = '0.22.0' __license__ = 'LGPL' From 4a02d0489da80e8b9e8d012bb7451cd172c753ca Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 14 May 2019 11:12:27 +0200 Subject: [PATCH 0007/1433] Drop Python 3.4 support (it's dead now) See https://devguide.python.org/#status-of-python-branches --- .travis.yml | 1 - setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 90625484f..ce3f3f953 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "3.4" - "3.5" - "3.6" - "pypy3.5-5.10.1" diff --git a/setup.py b/setup.py index 1ed96da1d..ecfff3e6a 100755 --- a/setup.py +++ b/setup.py @@ -35,7 +35,6 @@ 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', From e1c2b00c772a1538a6682c45884bbe89c8efba60 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 14 May 2019 11:16:39 +0200 Subject: [PATCH 0008/1433] Remove Python 3.4 from the Python compatibility section I forgot to do this in 4a02d0489da80e8b9e8d012bb7451cd172c753ca. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f610e1714..692872678 100644 --- a/README.rst +++ b/README.rst @@ -43,7 +43,7 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.4+ +* CPython 3.5+ * PyPy3 5.8+ Versioning From d4e06bc54098bfa7a863bcc11bb9e2035738c8f5 Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Wed, 15 May 2019 12:12:54 +0100 Subject: [PATCH 0009/1433] Add support for MyListener call getting updates to service TXT records (2nd attempt) (#166) Add support for MyListener call getting updates to service TXT records At the moment, the implementation supports notification to the ServiceListener class for additions and removals of service, but for service updates to the TXT record, the client must poll the ServiceInfo class. This draft PR provides a mechanism to have a callback on the ServiceListener class be invoked when the TXT record changes. --- test_zeroconf.py | 34 ++++++++++++++++++++++++++++++++-- zeroconf.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index 822f2cbc5..525fdc55a 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -737,12 +737,13 @@ def test_integration_with_listener_class(self): service_added = Event() service_removed = Event() + service_updated = Event() subtype_name = "My special Subtype" type_ = "_http._tcp.local." subtype = subtype_name + "._sub." + type_ name = "xxxyyyæøå" - registration_name = "%s.%s" % (name, type_) + registration_name = "%s.%s" % (name, subtype) class MyListener(r.ServiceListener): def add_service(self, zeroconf, type, name): @@ -752,6 +753,16 @@ def add_service(self, zeroconf, type, name): def remove_service(self, zeroconf, type, name): service_removed.set() + class MySubListener(r.ServiceListener): + def add_service(self, zeroconf, type, name): + pass + + def remove_service(self, zeroconf, type, name): + pass + + def update_service(self, zeroconf, type, name): + service_updated.set() + listener = MyListener() zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) zeroconf_browser.add_service_listener(subtype, listener) @@ -779,7 +790,7 @@ def remove_service(self, zeroconf, type, name): assert service_added.is_set() # short pause to allow multicast timers to expire - time.sleep(2) + time.sleep(3) # clear the answer cache to force query for record in zeroconf_browser.cache.entries(): @@ -799,9 +810,28 @@ def remove_service(self, zeroconf, type, name): assert info is not None assert info.properties[b'prop_none'] is False + # Begin material test addition + sublistener = MySubListener() + zeroconf_browser.add_service_listener(registration_name, sublistener) + properties['prop_blank'] = b'an updated string' + desc.update(properties) + info_service = ServiceInfo( + subtype, registration_name, + socket.inet_aton("10.0.1.2"), 80, 0, 0, + desc, "ash-2.local.") + zeroconf_registrar.update_service(info_service) + service_updated.wait(1) + assert service_updated.is_set() + + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_blank'] == properties['prop_blank'] + # End material test addition + zeroconf_registrar.unregister_service(info_service) service_removed.wait(1) assert service_removed.is_set() + finally: zeroconf_registrar.close() zeroconf_browser.remove_service_listener(listener) diff --git a/zeroconf.py b/zeroconf.py index 098a1e5e6..26400dd08 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -169,7 +169,7 @@ class InterfaceChoice(enum.Enum): class ServiceStateChange(enum.Enum): Added = 1 Removed = 2 - + Updated = 3 # utility functions @@ -1267,6 +1267,9 @@ def add_service(self, zc, type_, name) -> None: def remove_service(self, zc, type_, name) -> None: raise NotImplementedError() + def update_service(self, zc, type_, name) -> None: + raise NotImplementedError() + class ServiceBrowser(RecordUpdateListener, threading.Thread): @@ -1312,6 +1315,9 @@ def on_change(zeroconf, service_type, name, state_change): listener.add_service(*args) elif state_change is ServiceStateChange.Removed: listener.remove_service(*args) + elif state_change is ServiceStateChange.Updated: + if hasattr(listener, 'update_service'): + listener.update_service(*args) else: raise NotImplementedError(state_change) handlers.append(on_change) @@ -1361,6 +1367,12 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: if expires < self.next_time: self.next_time = expires + elif record.type == _TYPE_TXT and record.name == self.type: + assert isinstance(record, DNSText) + expired = record.is_expired(now) + if not expired: + enqueue_callback(ServiceStateChange.Updated, record.name) + def cancel(self): self.done = True self.zc.remove_listener(self) @@ -1855,6 +1867,24 @@ def register_service( self.servicetypes[info.type] += 1 else: self.servicetypes[info.type] = 1 + + self._broadcast_service(info) + + def update_service( + self, info: ServiceInfo + ) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service.""" + + assert self.services[info.name.lower()] is not None + + self.services[info.name.lower()] = info + + self._broadcast_service(info) + + def _broadcast_service(self, info: ServiceInfo) -> None: + now = current_time_millis() next_time = now i = 0 @@ -2031,9 +2061,12 @@ def handle_response(self, msg: DNSIncoming) -> None: entry.reset_ttl(record) else: self.cache.add(record) + if record.type == _TYPE_TXT: + self.update_record(now, record) for record in msg.answers: - self.update_record(now, record) + if record.type != _TYPE_TXT: + self.update_record(now, record) def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: """Deal with incoming query packets. Provides a response if From beb596c345b0764bdfe1a828cfa744bcc560cf32 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 15 May 2019 22:42:59 +0200 Subject: [PATCH 0010/1433] Reformat the code using Black We could use some style consistency in the project and Black looks like the best tool for the job. Two flake8 errors are being silenced from now on: * E203 whitespace before : * W503 line break before binary operator Both are to satisfy Black-formatted code (and W503 is somemwhat against the latest PEP8 recommendations regarding line breaks and binary operators in new code). --- examples/browser.py | 2 +- examples/registration.py | 14 +- examples/self_test.py | 15 +- pyproject.toml | 4 + setup.cfg | 1 + setup.py | 16 +- test_zeroconf.py | 178 +++++-------- zeroconf.py | 536 +++++++++++++++++++++------------------ 8 files changed, 391 insertions(+), 375 deletions(-) create mode 100644 pyproject.toml diff --git a/examples/browser.py b/examples/browser.py index 17a778ac9..c851be676 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -12,7 +12,7 @@ def on_service_state_change( - zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange, + zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) diff --git a/examples/registration.py b/examples/registration.py index ad707c220..7829acc92 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -17,10 +17,16 @@ desc = {'path': '/~paulsm/'} - info = ServiceInfo("_http._tcp.local.", - "Paul's Test Web Site._http._tcp.local.", - socket.inet_aton("127.0.0.1"), 80, 0, 0, - desc, "ash-2.local.") + info = ServiceInfo( + "_http._tcp.local.", + "Paul's Test Web Site._http._tcp.local.", + socket.inet_aton("127.0.0.1"), + 80, + 0, + 0, + desc, + "ash-2.local.", + ) zeroconf = Zeroconf() print("Registration of a service, press Ctrl-C to exit...") diff --git a/examples/self_test.py b/examples/self_test.py index c5b56ecb2..6667d13ee 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -18,15 +18,20 @@ r = Zeroconf() print("1. Testing registration of a service...") desc = {'version': '0.10', 'a': 'test value', 'b': 'another value'} - info = ServiceInfo("_http._tcp.local.", - "My Service Name._http._tcp.local.", - socket.inet_aton("127.0.0.1"), 1234, 0, 0, desc) + info = ServiceInfo( + "_http._tcp.local.", + "My Service Name._http._tcp.local.", + socket.inet_aton("127.0.0.1"), + 1234, + 0, + 0, + desc, + ) print(" Registering service...") r.register_service(info) print(" Registration done.") print("2. Testing query of service information...") - print(" Getting ZOE service: %s" % ( - r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local."))) + print(" Getting ZOE service: %s" % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local."))) print(" Query done.") print("3. Testing query of own service...") queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..a5d30b54e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.black] +line-length = 110 +target_version = ['py35', 'py36', 'py37'] +skip_string_normalization = true diff --git a/setup.cfg b/setup.cfg index d39bf999c..dd4764ed9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,7 @@ show-source = 1 application-import-names=zeroconf max-line-length=110 +ignore=E203,W503 [mypy] ignore_missing_imports = true diff --git a/setup.py b/setup.py index ecfff3e6a..b306619dc 100755 --- a/setup.py +++ b/setup.py @@ -11,14 +11,14 @@ version = ( [l for l in open(join(PROJECT_ROOT, 'zeroconf.py')) if '__version__' in l][0] .split('=')[-1] - .strip().strip('\'"') + .strip() + .strip('\'"') ) setup( name='zeroconf', version=version, - description='Pure Python Multicast DNS Service Discovery Library ' - '(Bonjour/Avahi compatible)', + description='Pure Python Multicast DNS Service Discovery Library ' '(Bonjour/Avahi compatible)', long_description=readme, author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', @@ -41,12 +41,6 @@ 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], - keywords=[ - 'Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', - 'mDNS', - ], - install_requires=[ - 'ifaddr', - 'typing;python_version<"3.5"' - ], + keywords=['Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', 'mDNS'], + install_requires=['ifaddr', 'typing;python_version<"3.5"'], ) diff --git a/test_zeroconf.py b/test_zeroconf.py index 525fdc55a..9737d38f4 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -42,7 +42,6 @@ def teardown_module(): class TestDunder(unittest.TestCase): - def test_dns_text_repr(self): # There was an issue on Python 3 that prevented DNSText's repr # from working when the text was longer than 10 bytes @@ -58,8 +57,7 @@ def test_dns_hinfo_repr_eq(self): repr(hinfo) def test_dns_pointer_repr(self): - pointer = r.DNSPointer( - 'irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') + pointer = r.DNSPointer('irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') repr(pointer) def test_dns_address_repr(self): @@ -67,14 +65,12 @@ def test_dns_address_repr(self): repr(address) def test_dns_question_repr(self): - question = r.DNSQuestion( - 'irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) + question = r.DNSQuestion('irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) repr(question) assert not question != question def test_dns_service_repr(self): - service = r.DNSService( - 'irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, b'a') + service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, b'a') repr(service) def test_dns_record_abc(self): @@ -87,9 +83,8 @@ def test_service_info_dunder(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) info = ServiceInfo( - type_, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - None, "ash-2.local.") + type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, None, "ash-2.local." + ) assert not info != info repr(info) @@ -99,9 +94,12 @@ def test_service_info_text_properties_not_given(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) info = ServiceInfo( - type_=type_, name=registration_name, + type_=type_, + name=registration_name, address=socket.inet_aton("10.0.1.2"), - port=80, server="ash-2.local.") + port=80, + server="ash-2.local.", + ) assert isinstance(info.text, bytes) repr(info) @@ -112,7 +110,6 @@ def test_dns_outgoing_repr(self): class PacketGeneration(unittest.TestCase): - def test_parse_own_packet_simple(self): generated = r.DNSOutgoing(0) r.DNSIncoming(generated.packet()) @@ -127,14 +124,14 @@ def test_parse_own_packet_flags(self): def test_parse_own_packet_question(self): generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - generated.add_question(r.DNSQuestion("testname.local.", r._TYPE_SRV, - r._CLASS_IN)) + generated.add_question(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) r.DNSIncoming(generated.packet()) def test_parse_own_packet_response(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - generated.add_answer_at_time(r.DNSService( - "æøå.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local."), 0) + generated.add_answer_at_time( + r.DNSService("æøå.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local."), 0 + ) parsed = r.DNSIncoming(generated.packet()) self.assertEqual(len(generated.answers), 1) self.assertEqual(len(generated.answers), len(parsed.answers)) @@ -153,11 +150,14 @@ def test_suppress_answer(self): question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) query_generated.add_question(question) answer1 = r.DNSService( - "testname1.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local.") + "testname1.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local." + ) staleanswer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL/2, 0, 0, 80, "foo.local.") + "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL / 2, 0, 0, 80, "foo.local." + ) answer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local.") + "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local." + ) query_generated.add_answer_at_time(answer1, 0) query_generated.add_answer_at_time(staleanswer2, 0) query = r.DNSIncoming(query_generated.packet()) @@ -193,21 +193,18 @@ def test_suppress_answer(self): def test_dns_hinfo(self): generated = r.DNSOutgoing(0) - generated.add_additional_answer( - DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) + generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) parsed = r.DNSIncoming(generated.packet()) answer = cast(r.DNSHinfo, parsed.answers[0]) self.assertEqual(answer.cpu, u'cpu') self.assertEqual(answer.os, u'os') generated = r.DNSOutgoing(0) - generated.add_additional_answer( - DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) + generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) self.assertRaises(r.NamePartTooLongException, generated.packet) class PacketForm(unittest.TestCase): - def test_transaction_id(self): """ID must be zero in a DNS-SD packet""" generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) @@ -230,8 +227,7 @@ def test_response_header_bits(self): def test_numbers(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) bytes = generated.packet() - (num_questions, num_answers, num_authorities, - num_additionals) = struct.unpack('!4H', bytes[4:12]) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) self.assertEqual(num_questions, 0) self.assertEqual(num_answers, 0) self.assertEqual(num_authorities, 0) @@ -243,8 +239,7 @@ def test_numbers_questions(self): for i in range(10): generated.add_question(question) bytes = generated.packet() - (num_questions, num_answers, num_authorities, - num_additionals) = struct.unpack('!4H', bytes[4:12]) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) self.assertEqual(num_questions, 10) self.assertEqual(num_answers, 0) self.assertEqual(num_authorities, 0) @@ -252,11 +247,11 @@ def test_numbers_questions(self): class Names(unittest.TestCase): - def test_long_name(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("this.is.a.very.long.name.with.lots.of.parts.in.it.local.", - r._TYPE_SRV, r._CLASS_IN) + question = r.DNSQuestion( + "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", r._TYPE_SRV, r._CLASS_IN + ) generated.add_question(question) r.DNSIncoming(generated.packet()) @@ -323,8 +318,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # wait until the browse request packet has maxed out in size sleep_count = 0 - while sleep_count < 100 and \ - longest_packet_len < r._MAX_MSG_ABSOLUTE - 100: + while sleep_count < 100 and longest_packet_len < r._MAX_MSG_ABSOLUTE - 100: sleep_count += 1 time.sleep(0.1) @@ -332,8 +326,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): time.sleep(0.5) import zeroconf - zeroconf.log.debug('sleep_count %d, sized %d', - sleep_count, longest_packet_len) + + zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) # now the browser has sent at least one request, verify the size assert longest_packet_len <= r._MAX_MSG_ABSOLUTE @@ -341,6 +335,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # mock zeroconf's logger warning() and debug() from unittest.mock import patch + patch_warn = patch('zeroconf.log.warning') patch_debug = patch('zeroconf.log.debug') mocked_log_warn = patch_warn.start() @@ -372,10 +367,9 @@ def on_service_state_change(zeroconf, service_type, state_change, name): s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) time.sleep(2.0) - zeroconf.log.debug('warn %d debug %d was %s', - mocked_log_warn.call_count, - mocked_log_debug.call_count, - call_counts) + zeroconf.log.debug( + 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts + ) assert mocked_log_debug.call_count > call_counts[0] # close our zeroconf which will close the sockets @@ -389,17 +383,15 @@ def on_service_state_change(zeroconf, service_type, state_change, name): call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count # send on a closed socket (force a socket error) zc.send(out) - zeroconf.log.debug('warn %d debug %d was %s', - mocked_log_warn.call_count, - mocked_log_debug.call_count, - call_counts) + zeroconf.log.debug( + 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts + ) assert mocked_log_warn.call_count > call_counts[0] assert mocked_log_debug.call_count > call_counts[0] zc.send(out) - zeroconf.log.debug('warn %d debug %d was %s', - mocked_log_warn.call_count, - mocked_log_debug.call_count, - call_counts) + zeroconf.log.debug( + 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts + ) assert mocked_log_debug.call_count > call_counts[0] + 2 mocked_log_warn.stop() @@ -408,17 +400,14 @@ def on_service_state_change(zeroconf, service_type, state_change, name): def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} info_service = ServiceInfo( - type_, '%s.%s' % (name, type_), socket.inet_aton("10.0.1.2"), - 80, 0, 0, desc, "ash-2.local.") + type_, '%s.%s' % (name, type_), socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) # verify name conflict - self.assertRaises( - r.NonUniqueNameException, - zc.register_service, info_service) + self.assertRaises(r.NonUniqueNameException, zc.register_service, info_service) zc.register_service(info_service, allow_name_change=True) - assert info_service.name.split('.')[0] == '%s-%d' % ( - name, number_hosts + 1) + assert info_service.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) def generate_many_hosts(self, zc, type_, name, number_hosts): records_per_server = 2 @@ -429,9 +418,7 @@ def generate_many_hosts(self, zc, type_, name, number_hosts): self.generate_host(zc, next_name, type_) if i % block_size == 0: sleep_count = 0 - while sleep_count < 40 and \ - i * records_per_server > len( - zc.cache.entries_with_name(type_)): + while sleep_count < 40 and i * records_per_server > len(zc.cache.entries_with_name(type_)): sleep_count += 1 time.sleep(0.05) @@ -439,18 +426,14 @@ def generate_many_hosts(self, zc, type_, name, number_hosts): def generate_host(zc, host_name, type_): name = '.'.join((host_name, type_)) out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + out.add_answer_at_time(r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, name), 0) out.add_answer_at_time( - r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, - r._DNS_OTHER_TTL, name), 0) - out.add_answer_at_time( - r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, - r._DNS_HOST_TTL, 0, 0, 80, - name), 0) + r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, name), 0 + ) zc.send(out) class Framework(unittest.TestCase): - def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) rv.close() @@ -472,9 +455,7 @@ def tearDownClass(cls): cls.browser = None def test_bad_service_info_name(self): - self.assertRaises( - r.BadTypeInNameException, - self.browser.get_service_info, "type", "type_not") + self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") def test_bad_service_names(self): bad_names_to_try = ( @@ -494,15 +475,13 @@ def test_bad_service_names(self): '\x00._x._udp.local.', ) for name in bad_names_to_try: - self.assertRaises( - r.BadTypeInNameException, - self.browser.get_service_info, name, 'x.' + name) + self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) def test_good_instance_names(self): good_names_to_try = ( '.._x._tcp.local.', 'x.sub._http._tcp.local.', - '6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.' + '6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.', ) for name in good_names_to_try: r.service_type_name(name) @@ -514,8 +493,7 @@ def test_bad_types(self): 'a' * 62 + u'â._sub._http._tcp.local.', ) for name in bad_names_to_try: - self.assertRaises( - r.BadTypeInNameException, r.service_type_name, name) + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) def test_bad_sub_types(self): bad_names_to_try = ( @@ -525,8 +503,7 @@ def test_bad_sub_types(self): '\x1f._sub._http._tcp.local.', ) for name in bad_names_to_try: - self.assertRaises( - r.BadTypeInNameException, r.service_type_name, name) + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) def test_good_service_names(self): good_names_to_try = ( @@ -544,7 +521,6 @@ def test_good_service_names(self): class TestDnsIncoming(unittest.TestCase): - def test_incoming_exception_handling(self): generated = r.DNSOutgoing(0) packet = generated.packet() @@ -569,7 +545,6 @@ def test_incoming_ipv6(self): class TestRegistrar(unittest.TestCase): - def test_ttl(self): # instantiate a zeroconf instance @@ -582,9 +557,8 @@ def test_ttl(self): desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) # we are going to monkey patch the zeroconf send to check packet sizes old_send = zc.send @@ -664,7 +638,6 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): class TestDNSCache(unittest.TestCase): - def test_order(self): record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') @@ -677,7 +650,6 @@ def test_order(self): class ServiceTypesQuery(unittest.TestCase): - def test_integration_with_listener(self): type_ = "_test-srvc-type._tcp.local." @@ -687,17 +659,14 @@ def test_integration_with_listener(self): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) zeroconf_registrar.register_service(info) try: - service_types = ZeroconfServiceTypes.find( - interfaces=['127.0.0.1'], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types - service_types = ZeroconfServiceTypes.find( - zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types finally: @@ -714,17 +683,14 @@ def test_integration_with_subtype_and_listener(self): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( - discovery_type, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + discovery_type, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) zeroconf_registrar.register_service(info) try: - service_types = ZeroconfServiceTypes.find( - interfaces=['127.0.0.1'], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert discovery_type in service_types - service_types = ZeroconfServiceTypes.find( - zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert discovery_type in service_types finally: @@ -732,7 +698,6 @@ def test_integration_with_subtype_and_listener(self): class ListenerTest(unittest.TestCase): - def test_integration_with_listener_class(self): service_added = Event() @@ -780,9 +745,8 @@ def update_service(self, zeroconf, type, name): desc = {'path': '/~paulsm/'} # type: r.ServicePropertiesType desc.update(properties) info_service = ServiceInfo( - subtype, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + subtype, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) zeroconf_registrar.register_service(info_service) try: @@ -816,9 +780,8 @@ def update_service(self, zeroconf, type, name): properties['prop_blank'] = b'an updated string' desc.update(properties) info_service = ServiceInfo( - subtype, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + subtype, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) zeroconf_registrar.update_service(info_service) service_updated.wait(1) assert service_updated.is_set() @@ -895,7 +858,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): next_query_interval = initial_query_interval expected_query_time = initial_query_interval else: - next_query_interval = min(2*next_query_interval, r._BROWSER_BACKOFF_LIMIT) + next_query_interval = min(2 * next_query_interval, r._BROWSER_BACKOFF_LIMIT) expected_query_time += next_query_interval else: assert not got_query.is_set() @@ -965,10 +928,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, - socket.inet_aton("10.0.1.2"), 80, 0, 0, - desc, "ash-2.local.") + info = ServiceInfo(type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local.") zeroconf_registrar.register_service(info) try: diff --git a/zeroconf.py b/zeroconf.py index 26400dd08..b8c664730 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -44,15 +44,21 @@ __all__ = [ "__version__", - "Zeroconf", "ServiceInfo", "ServiceBrowser", - "Error", "InterfaceChoice", "ServiceStateChange", + "Zeroconf", + "ServiceInfo", + "ServiceBrowser", + "Error", + "InterfaceChoice", + "ServiceStateChange", ] if sys.version_info <= (3, 3): - raise ImportError(''' + raise ImportError( + ''' Python version > 3.3 required for python-zeroconf. If you need support for Python 2 or Python 3.3 please use version 19.1 - ''') + ''' + ) log = logging.getLogger(__name__) log.addHandler(logging.NullHandler()) @@ -124,32 +130,36 @@ # Mapping constants to names -_CLASSES = {_CLASS_IN: "in", - _CLASS_CS: "cs", - _CLASS_CH: "ch", - _CLASS_HS: "hs", - _CLASS_NONE: "none", - _CLASS_ANY: "any"} - -_TYPES = {_TYPE_A: "a", - _TYPE_NS: "ns", - _TYPE_MD: "md", - _TYPE_MF: "mf", - _TYPE_CNAME: "cname", - _TYPE_SOA: "soa", - _TYPE_MB: "mb", - _TYPE_MG: "mg", - _TYPE_MR: "mr", - _TYPE_NULL: "null", - _TYPE_WKS: "wks", - _TYPE_PTR: "ptr", - _TYPE_HINFO: "hinfo", - _TYPE_MINFO: "minfo", - _TYPE_MX: "mx", - _TYPE_TXT: "txt", - _TYPE_AAAA: "quada", - _TYPE_SRV: "srv", - _TYPE_ANY: "any"} +_CLASSES = { + _CLASS_IN: "in", + _CLASS_CS: "cs", + _CLASS_CH: "ch", + _CLASS_HS: "hs", + _CLASS_NONE: "none", + _CLASS_ANY: "any", +} + +_TYPES = { + _TYPE_A: "a", + _TYPE_NS: "ns", + _TYPE_MD: "md", + _TYPE_MF: "mf", + _TYPE_CNAME: "cname", + _TYPE_SOA: "soa", + _TYPE_MB: "mb", + _TYPE_MG: "mg", + _TYPE_MR: "mr", + _TYPE_NULL: "null", + _TYPE_WKS: "wks", + _TYPE_PTR: "ptr", + _TYPE_HINFO: "hinfo", + _TYPE_MINFO: "minfo", + _TYPE_MX: "mx", + _TYPE_TXT: "txt", + _TYPE_AAAA: "quada", + _TYPE_SRV: "srv", + _TYPE_ANY: "any", +} _HAS_A_TO_Z = re.compile(r'[A-Za-z]') _HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') @@ -171,6 +181,7 @@ class ServiceStateChange(enum.Enum): Removed = 2 Updated = 3 + # utility functions @@ -220,58 +231,48 @@ def service_type_name(type_, *, allow_underscores: bool = False): :return: fully qualified service name (eg: _http._tcp.local.) """ if not (type_.endswith('._tcp.local.') or type_.endswith('._udp.local.')): - raise BadTypeInNameException( - "Type '%s' must end with '._tcp.local.' or '._udp.local.'" % - type_) + raise BadTypeInNameException("Type '%s' must end with '._tcp.local.' or '._udp.local.'" % type_) - remaining = type_[:-len('._tcp.local.')].split('.') + remaining = type_[: -len('._tcp.local.')].split('.') name = remaining.pop() if not name: raise BadTypeInNameException("No Service name found") if len(remaining) == 1 and len(remaining[0]) == 0: - raise BadTypeInNameException( - "Type '%s' must not start with '.'" % type_) + raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) if name[0] != '_': - raise BadTypeInNameException( - "Service name (%s) must start with '_'" % name) + raise BadTypeInNameException("Service name (%s) must start with '_'" % name) # remove leading underscore name = name[1:] if len(name) > 15: - raise BadTypeInNameException( - "Service name (%s) must be <= 15 bytes" % name) + raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % name) if '--' in name: - raise BadTypeInNameException( - "Service name (%s) must not contain '--'" % name) + raise BadTypeInNameException("Service name (%s) must not contain '--'" % name) if '-' in (name[0], name[-1]): - raise BadTypeInNameException( - "Service name (%s) may not start or end with '-'" % name) + raise BadTypeInNameException("Service name (%s) may not start or end with '-'" % name) if not _HAS_A_TO_Z.search(name): - raise BadTypeInNameException( - "Service name (%s) must contain at least one letter (eg: 'A-Z')" % - name) + raise BadTypeInNameException("Service name (%s) must contain at least one letter (eg: 'A-Z')" % name) allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE if allow_underscores - else _HAS_ONLY_A_TO_Z_NUM_HYPHEN + _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE if allow_underscores else _HAS_ONLY_A_TO_Z_NUM_HYPHEN ) if not allowed_characters_re.search(name): raise BadTypeInNameException( "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" % (name, ", underscore ('_')" if allow_underscores else "")) + "A-Z, a-z, 0-9, hyphen ('-')%s" % (name, ", underscore ('_')" if allow_underscores else "") + ) if remaining and remaining[-1] == '_sub': remaining.pop() if len(remaining) == 0 or len(remaining[0]) == 0: - raise BadTypeInNameException( - "_sub requires a subtype name") + raise BadTypeInNameException("_sub requires a subtype name") if len(remaining) > 1: remaining = ['.'.join(remaining)] @@ -283,10 +284,10 @@ def service_type_name(type_, *, allow_underscores: bool = False): if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): raise BadTypeInNameException( - "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % - remaining[0]) + "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0] + ) - return '_' + name + type_[-len('._tcp.local.'):] + return '_' + name + type_[-len('._tcp.local.') :] # Exceptions @@ -361,10 +362,12 @@ def __init__(self, name: str, type_: int, class_): def __eq__(self, other): """Equality test on name, type, and class""" - return (isinstance(other, DNSEntry) and - self.name == other.name and - self.type == other.type and - self.class_ == other.class_) + return ( + isinstance(other, DNSEntry) + and self.name == other.name + and self.type == other.type + and self.class_ == other.class_ + ) def __ne__(self, other): """Non-equality test""" @@ -382,8 +385,7 @@ def get_type(t): def to_string(self, hdr, other) -> str: """String representation with additional information""" - result = "%s[%s,%s" % (hdr, self.get_type(self.type), - self.get_class_(self.class_)) + result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) if self.unique: result += "-unique," else: @@ -405,9 +407,11 @@ def __init__(self, name: str, type_: int, class_: int) -> None: def answered_by(self, rec: 'DNSRecord') -> bool: """Returns true if the question is answered by the record""" - return (self.class_ == rec.class_ and - (self.type == rec.type or self.type == _TYPE_ANY) and - self.name == rec.name) + return ( + self.class_ == rec.class_ + and (self.type == rec.type or self.type == _TYPE_ANY) + and self.name == rec.name + ) def __repr__(self) -> str: """String representation""" @@ -473,8 +477,7 @@ def write(self, out): def to_string(self, other): """String representation with additional information""" - arg = "%s/%s,%s" % ( - self.ttl, self.get_remaining_ttl(current_time_millis()), other) + arg = "%s/%s,%s" % (self.ttl, self.get_remaining_ttl(current_time_millis()), other) return DNSEntry.to_string(self, "record", arg) @@ -492,8 +495,9 @@ def write(self, out): def __eq__(self, other): """Tests equality on address""" - return (isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and - self.address == other.address) + return ( + isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address + ) def __ne__(self, other): """Non-equality test""" @@ -529,8 +533,12 @@ def write(self, out): def __eq__(self, other): """Tests equality on cpu and os""" - return (isinstance(other, DNSHinfo) and DNSEntry.__eq__(self, other) and - self.cpu == other.cpu and self.os == other.os) + return ( + isinstance(other, DNSHinfo) + and DNSEntry.__eq__(self, other) + and self.cpu == other.cpu + and self.os == other.os + ) def __ne__(self, other): """Non-equality test""" @@ -555,8 +563,7 @@ def write(self, out): def __eq__(self, other): """Tests equality on alias""" - return (isinstance(other, DNSPointer) and DNSEntry.__eq__(self, other) and - self.alias == other.alias) + return isinstance(other, DNSPointer) and DNSEntry.__eq__(self, other) and self.alias == other.alias def __ne__(self, other): """Non-equality test""" @@ -582,8 +589,7 @@ def write(self, out): def __eq__(self, other): """Tests equality on text""" - return (isinstance(other, DNSText) and DNSEntry.__eq__(self, other) and - self.text == other.text) + return isinstance(other, DNSText) and DNSEntry.__eq__(self, other) and self.text == other.text def __ne__(self, other): """Non-equality test""" @@ -601,8 +607,7 @@ class DNSService(DNSRecord): """A DNS service record""" - def __init__(self, name, type_, class_, ttl, - priority, weight, port, server): + def __init__(self, name, type_, class_, ttl, priority, weight, port, server): DNSRecord.__init__(self, name, type_, class_, ttl) self.priority = priority self.weight = weight @@ -618,12 +623,14 @@ def write(self, out): def __eq__(self, other): """Tests equality on priority, weight, port and server""" - return (isinstance(other, DNSService) and - DNSEntry.__eq__(self, other) and - self.priority == other.priority and - self.weight == other.weight and - self.port == other.port and - self.server == other.server) + return ( + isinstance(other, DNSService) + and DNSEntry.__eq__(self, other) + and self.priority == other.priority + and self.weight == other.weight + and self.port == other.port + and self.server == other.server + ) def __ne__(self, other): """Non-equality test""" @@ -659,20 +666,24 @@ def __init__(self, data): self.valid = True except (IndexError, struct.error, IncomingDecodeError): - self.log_exception_warning(( - 'Choked at offset %d while unpacking %r', self.offset, data)) + self.log_exception_warning(('Choked at offset %d while unpacking %r', self.offset, data)) def unpack(self, format_): length = struct.calcsize(format_) - info = struct.unpack( - format_, self.data[self.offset:self.offset + length]) + info = struct.unpack(format_, self.data[self.offset : self.offset + length]) self.offset += length return info def read_header(self): """Reads header portion of packet""" - (self.id, self.flags, self.num_questions, self.num_answers, - self.num_authorities, self.num_additionals) = self.unpack(b'!6H') + ( + self.id, + self.flags, + self.num_questions, + self.num_answers, + self.num_authorities, + self.num_additionals, + ) = self.unpack(b'!6H') def read_questions(self): """Reads questions section of packet""" @@ -695,7 +706,7 @@ def read_character_string(self): def read_string(self, length): """Reads a string of a given length from the packet""" - info = self.data[self.offset:self.offset + length] + info = self.data[self.offset : self.offset + length] self.offset += length return info @@ -713,26 +724,28 @@ def read_others(self): rec = None # type: Optional[DNSRecord] if type_ == _TYPE_A: - rec = DNSAddress( - domain, type_, class_, ttl, self.read_string(4)) + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) elif type_ == _TYPE_CNAME or type_ == _TYPE_PTR: - rec = DNSPointer( - domain, type_, class_, ttl, self.read_name()) + rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) elif type_ == _TYPE_TXT: - rec = DNSText( - domain, type_, class_, ttl, self.read_string(length)) + rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) elif type_ == _TYPE_SRV: rec = DNSService( - domain, type_, class_, ttl, - self.read_unsigned_short(), self.read_unsigned_short(), - self.read_unsigned_short(), self.read_name()) + domain, + type_, + class_, + ttl, + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_name(), + ) elif type_ == _TYPE_HINFO: rec = DNSHinfo( - domain, type_, class_, ttl, - self.read_character_string(), self.read_character_string()) + domain, type_, class_, ttl, self.read_character_string(), self.read_character_string() + ) elif type_ == _TYPE_AAAA: - rec = DNSAddress( - domain, type_, class_, ttl, self.read_string(16)) + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) else: # Try to ignore types we don't know about # Skip the payload for the resource record so the next @@ -752,7 +765,7 @@ def is_response(self): def read_utf(self, offset, length): """Reads a UTF-8 string of a given length from the packet""" - return str(self.data[offset:offset + length], 'utf-8', 'replace') + return str(self.data[offset : offset + length], 'utf-8', 'replace') def read_name(self): """Reads a domain name from the packet""" @@ -775,8 +788,7 @@ def read_name(self): next_ = off + 1 off = ((length & 0x3F) << 8) | self.data[off] if off >= first: - raise IncomingDecodeError( - "Bad domain name (circular) at %s" % (off,)) + raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) first = off else: raise IncomingDecodeError("Bad domain name at %s" % (off,)) @@ -809,14 +821,16 @@ def __init__(self, flags, multicast=True): self.additionals = [] # type: List[DNSAddress] def __repr__(self): - return '' % ', '.join([ - 'multicast=%s' % self.multicast, - 'flags=%s' % self.flags, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - 'authorities=%s' % self.authorities, - 'additionals=%s' % self.additionals, - ]) + return '' % ', '.join( + [ + 'multicast=%s' % self.multicast, + 'flags=%s' % self.flags, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + 'authorities=%s' % self.authorities, + 'additionals=%s' % self.additionals, + ] + ) class State(enum.Enum): init = 0 @@ -1091,9 +1105,7 @@ def entries_with_name(self, name): def current_entry_with_name_and_alias(self, name, alias): now = current_time_millis() for record in self.entries_with_name(name): - if (record.type == _TYPE_PTR and - not record.is_expired(now) and - record.alias == alias): + if record.type == _TYPE_PTR and not record.is_expired(now) and record.alias == alias: return record def entries(self): @@ -1242,7 +1254,6 @@ def registration_interface(self) -> 'SignalRegistrationInterface': class SignalRegistrationInterface: - def __init__(self, handlers): self._handlers = handlers @@ -1279,20 +1290,27 @@ class ServiceBrowser(RecordUpdateListener, threading.Thread): remove_service() methods called when this browser discovers changes in the services availability.""" - def __init__(self, zc: 'Zeroconf', type_: str, handlers=None, listener=None, - addr: str = _MDNS_ADDR, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME) -> None: + def __init__( + self, + zc: 'Zeroconf', + type_: str, + handlers=None, + listener=None, + addr: str = _MDNS_ADDR, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: """Creates a browser for a specific type""" assert handlers or listener, 'You need to specify at least one handler' if not type_.endswith(service_type_name(type_, allow_underscores=True)): raise BadTypeInNameException - threading.Thread.__init__( - self, name='zeroconf-ServiceBrowser_' + type_) + threading.Thread.__init__(self, name='zeroconf-ServiceBrowser_' + type_) self.daemon = True self.zc = zc self.type = type_ self.addr = addr self.port = port - self.multicast = (self.addr == _MDNS_ADDR) + self.multicast = self.addr == _MDNS_ADDR self.services = {} # type: Dict[str, DNSRecord] self.next_time = current_time_millis() self.delay = delay @@ -1309,6 +1327,7 @@ def __init__(self, zc: 'Zeroconf', type_: str, handlers=None, listener=None, handlers = handlers or [] if listener: + def on_change(zeroconf, service_type, name, state_change): args = (zeroconf, service_type, name) if state_change is ServiceStateChange.Added: @@ -1320,6 +1339,7 @@ def on_change(zeroconf, service_type, name, state_change): listener.update_service(*args) else: raise NotImplementedError(state_change) + handlers.append(on_change) for h in handlers: @@ -1339,11 +1359,9 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: self._handlers_to_call.append( lambda zeroconf: self._service_state_changed.fire( - zeroconf=zeroconf, - service_type=self.type, - name=name, - state_change=state_change, - )) + zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change + ) + ) if record.type == _TYPE_PTR and record.name == self.type: assert isinstance(record, DNSPointer) @@ -1411,9 +1429,19 @@ class ServiceInfo(RecordUpdateListener): """Service information""" - def __init__(self, type_: str, name: str, address: Optional[bytes] = None, port: Optional[int] = None, - weight: int = 0, priority: int = 0, properties=b'', server: Optional[str] = None, - host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL) -> None: + def __init__( + self, + type_: str, + name: str, + address: Optional[bytes] = None, + port: Optional[int] = None, + weight: int = 0, + priority: int = 0, + properties=b'', + server: Optional[str] = None, + host_ttl: int = _DNS_HOST_TTL, + other_ttl: int = _DNS_OTHER_TTL, + ) -> None: """Create a service description. type_: fully qualified service type name @@ -1489,7 +1517,7 @@ def _set_text(self, text): while index < end: length = text[index] index += 1 - strs.append(text[index:index + length]) + strs.append(text[index : index + length]) index += length for s in strs: @@ -1515,7 +1543,7 @@ def _set_text(self, text): def get_name(self): """Name accessor""" if self.type is not None and self.name.endswith("." + self.type): - return self.name[:len(self.name) - len(self.type) - 1] + return self.name[: len(self.name) - len(self.type) - 1] return self.name def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: @@ -1534,9 +1562,7 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: self.weight = record.weight self.priority = record.priority # self.address = None - self.update_record( - zc, now, zc.cache.get_by_details( - self.server, _TYPE_A, _CLASS_IN)) + self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) elif record.type == _TYPE_TXT: assert isinstance(record, DNSText) if record.name == self.name: @@ -1551,10 +1577,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: next_ = now + delay last = now + timeout - record_types_for_check_cache = [ - (_TYPE_SRV, _CLASS_IN), - (_TYPE_TXT, _CLASS_IN), - ] + record_types_for_check_cache = [(_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN)] if self.server is not None: record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) for record_type in record_types_for_check_cache: @@ -1572,24 +1595,15 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: return False if next_ <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question( - DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) - out.add_answer_at_time( - zc.cache.get_by_details( - self.name, _TYPE_SRV, _CLASS_IN), now) + out.add_question(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) + out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN), now) - out.add_question( - DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) - out.add_answer_at_time( - zc.cache.get_by_details( - self.name, _TYPE_TXT, _CLASS_IN), now) + out.add_question(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) + out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN), now) if self.server is not None: - out.add_question( - DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) - out.add_answer_at_time( - zc.cache.get_by_details( - self.server, _TYPE_A, _CLASS_IN), now) + out.add_question(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) + out.add_answer_at_time(zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN), now) zc.send(out) next_ = now + delay delay *= 2 @@ -1615,11 +1629,8 @@ def __repr__(self) -> str: type(self).__name__, ', '.join( '%s=%r' % (name, getattr(self, name)) - for name in ( - 'type', 'name', 'address', 'port', 'weight', 'priority', - 'server', 'properties', - ) - ) + for name in ('type', 'name', 'address', 'port', 'weight', 'priority', 'server', 'properties') + ), ) @@ -1627,6 +1638,7 @@ class ZeroconfServiceTypes(ServiceListener): """ Return all of the advertised services on any local networks """ + def __init__(self): self.found_services = set() # type: Set[str] @@ -1648,8 +1660,7 @@ def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): """ local_zc = zc or Zeroconf(interfaces=interfaces) listener = cls() - browser = ServiceBrowser( - local_zc, '_services._dns-sd._udp.local.', listener=listener) + browser = ServiceBrowser(local_zc, '_services._dns-sd._udp.local.', listener=listener) # wait for responses time.sleep(timeout) @@ -1664,12 +1675,14 @@ def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): def get_all_addresses() -> List[str]: - return list(set( - addr.ip - for iface in ifaddr.get_adapters() - for addr in iface.ips - if addr.is_IPv4 and addr.network_prefix != 32 # Host only netmask 255.255.255.255 - )) + return list( + set( + addr.ip + for iface in ifaddr.get_adapters() + for addr in iface.ips + if addr.is_IPv4 and addr.network_prefix != 32 # Host only netmask 255.255.255.255 + ) + ) def normalize_interface_choice(choice: Union[List[str], InterfaceChoice]) -> List[str]: @@ -1730,9 +1743,7 @@ class Zeroconf(QuietLogger): """ def __init__( - self, - interfaces: Union[List[str], InterfaceChoice] = InterfaceChoice.All, - unicast: bool = False + self, interfaces: Union[List[str], InterfaceChoice] = InterfaceChoice.All, unicast: bool = False ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -1754,34 +1765,31 @@ def __init__( log.debug('Adding %r to multicast group', i) try: _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(i) - self._listen_socket.setsockopt( - socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) + self._listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) except socket.error as e: _errno = get_errno(e) if _errno == errno.EADDRINUSE: log.info( 'Address in use when adding %s to multicast group, ' - 'it is expected to happen on some systems', i, + 'it is expected to happen on some systems', + i, ) elif _errno == errno.EADDRNOTAVAIL: log.info( 'Address not available when adding %s to multicast ' - 'group, it is expected to happen on some systems', i, + 'group, it is expected to happen on some systems', + i, ) continue elif _errno == errno.EINVAL: - log.info( - 'Interface of %s does not support multicast, ' - 'it is expected in WSL', i - ) + log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', i) continue else: raise respond_socket = new_socket() - respond_socket.setsockopt( - socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(i)) + respond_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(i)) else: respond_socket = new_socket(port=0) @@ -1850,7 +1858,7 @@ def remove_all_service_listeners(self) -> None: self.remove_service_listener(listener) def register_service( - self, info: ServiceInfo, ttl: Optional[int] = None, allow_name_change: bool = False, + self, info: ServiceInfo, ttl: Optional[int] = None, allow_name_change: bool = False ) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that @@ -1870,9 +1878,7 @@ def register_service( self._broadcast_service(info) - def update_service( - self, info: ServiceInfo - ) -> None: + def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service.""" @@ -1894,19 +1900,26 @@ def _broadcast_service(self, info: ServiceInfo) -> None: now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name), 0) out.add_answer_at_time( - DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name), 0) - out.add_answer_at_time( - DNSService(info.name, _TYPE_SRV, _CLASS_IN, - info.host_ttl, info.priority, info.weight, info.port, - info.server), 0) + DNSService( + info.name, + _TYPE_SRV, + _CLASS_IN, + info.host_ttl, + info.priority, + info.weight, + info.port, + info.server, + ), + 0, + ) - out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) + out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) if info.address: out.add_answer_at_time( - DNSAddress(info.server, _TYPE_A, _CLASS_IN, - info.host_ttl, info.address), 0) + DNSAddress(info.server, _TYPE_A, _CLASS_IN, info.host_ttl, info.address), 0 + ) self.send(out) i += 1 next_time += _REGISTER_TIME @@ -1930,18 +1943,17 @@ def unregister_service(self, info: ServiceInfo) -> None: now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) out.add_answer_at_time( - DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.add_answer_at_time( - DNSService(info.name, _TYPE_SRV, _CLASS_IN, 0, - info.priority, info.weight, info.port, info.name), 0) - out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + DNSService( + info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.name + ), + 0, + ) + out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) if info.address: - out.add_answer_at_time( - DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, - info.address), 0) + out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -1959,17 +1971,25 @@ def unregister_all_services(self) -> None: continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) for info in self.services.values(): - out.add_answer_at_time(DNSPointer( - info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.add_answer_at_time(DNSService( - info.name, _TYPE_SRV, _CLASS_IN, 0, - info.priority, info.weight, info.port, info.server), 0) - out.add_answer_at_time(DNSText( - info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) + out.add_answer_at_time( + DNSService( + info.name, + _TYPE_SRV, + _CLASS_IN, + 0, + info.priority, + info.weight, + info.port, + info.server, + ), + 0, + ) + out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) if info.address: - out.add_answer_at_time(DNSAddress( - info.server, _TYPE_A, _CLASS_IN, 0, - info.address), 0) + out.add_answer_at_time( + DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0 + ) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -1984,7 +2004,7 @@ def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: if not info.type.endswith(service_name): raise BadTypeInNameException - instance_name = info.name[:-len(service_name) - 1] + instance_name = info.name[: -len(service_name) - 1] next_instance_number = 2 now = current_time_millis() @@ -1992,14 +2012,12 @@ def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: i = 0 while i < 3: # check for a name conflict - while self.cache.current_entry_with_name_and_alias( - info.type, info.name): + while self.cache.current_entry_with_name_and_alias(info.type, info.name): if not allow_name_change: raise NonUniqueNameException # change the name and look for a conflict - info.name = '%s-%s.%s' % ( - instance_name, next_instance_number, info.type) + info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) next_instance_number += 1 service_type_name(info.name) next_time = now @@ -2013,8 +2031,7 @@ def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) self.debug = out out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) - out.add_authorative_answer(DNSPointer( - info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) + out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) self.send(out) i += 1 next_time += _CHECK_TIME @@ -2086,16 +2103,20 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: for stype in self.servicetypes.keys(): if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer(msg, DNSPointer( - "_services._dns-sd._udp.local.", _TYPE_PTR, - _CLASS_IN, _DNS_OTHER_TTL, stype)) + out.add_answer( + msg, + DNSPointer( + "_services._dns-sd._udp.local.", _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype + ), + ) for service in self.services.values(): if question.name == service.type: if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer(msg, DNSPointer( - service.type, _TYPE_PTR, - _CLASS_IN, service.other_ttl, service.name)) + out.add_answer( + msg, + DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name), + ) else: try: if out is None: @@ -2105,10 +2126,16 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: if question.type in (_TYPE_A, _TYPE_ANY): for service in self.services.values(): if service.server == question.name.lower(): - out.add_answer(msg, DNSAddress( - question.name, _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, service.address)) + out.add_answer( + msg, + DNSAddress( + question.name, + _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.address, + ), + ) name_to_find = question.name.lower() if name_to_find not in self.services: @@ -2116,18 +2143,40 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: service = self.services[name_to_find] if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, DNSService( - question.name, _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, service.priority, service.weight, - service.port, service.server)) + out.add_answer( + msg, + DNSService( + question.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.priority, + service.weight, + service.port, + service.server, + ), + ) if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, DNSText( - question.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, service.text)) + out.add_answer( + msg, + DNSText( + question.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + service.other_ttl, + service.text, + ), + ) if question.type == _TYPE_SRV: - out.add_additional_answer(DNSAddress( - service.server, _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, service.address)) + out.add_additional_answer( + DNSAddress( + service.server, + _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.address, + ) + ) except Exception: # TODO stop catching all Exceptions self.log_exception_warning() @@ -2139,8 +2188,7 @@ def send(self, out: DNSOutgoing, addr: str = _MDNS_ADDR, port: int = _MDNS_PORT) """Sends an outgoing packet.""" packet = out.packet() if len(packet) > _MAX_MSG_ABSOLUTE: - self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", - out, len(packet), packet) + self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return log.debug('Sending %r (%d bytes) as %r...', out, len(packet), packet) for s in self._respond_sockets: @@ -2148,14 +2196,12 @@ def send(self, out: DNSOutgoing, addr: str = _MDNS_ADDR, port: int = _MDNS_PORT) return try: bytes_sent = s.sendto(packet, 0, (addr, port)) - except Exception: # TODO stop catching all Exceptions + except Exception: # TODO stop catching all Exceptions # on send errors, log the exception and keep going self.log_exception_warning() else: if bytes_sent != len(packet): - self.log_warning_once( - '!!! sent %d out of %d bytes to %r' % ( - bytes_sent, len(packet), s)) + self.log_warning_once('!!! sent %d out of %d bytes to %r' % (bytes_sent, len(packet), s)) def close(self) -> None: """Ends the background threads, and prevent this instance from From 69ad22cf852a12622f78aa2f4e7cf20c2d395db2 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 15 May 2019 22:55:45 +0200 Subject: [PATCH 0011/1433] Refactor the CI script a bit to make adding black check easier --- .travis.yml | 5 +---- Makefile | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index ce3f3f953..2bda93067 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,9 +12,6 @@ install: # mypy can't be installed on pypy - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi script: - - make test_coverage - - flake8 --version - - make flake8 - - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then make mypy ; fi + - make ci after_success: - coveralls diff --git a/Makefile b/Makefile index e1aa7fffb..18a1c73d1 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ .PHONY: all virtualenv MAX_LINE_LENGTH=110 +PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") + +LINT_TARGETS:=flake8 +ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) + LINT_TARGETS:=$(LINT_TARGETS) mypy +endif + virtualenv: ./env/requirements.built @@ -10,6 +17,12 @@ env: ./env/bin/pip install -r requirements-dev.txt cp requirements-dev.txt ./env/requirements.built +.PHONY: ci +ci: test_coverage lint + +.PHONY: lint +lint: $(LINT_TARGETS) + flake8: flake8 --max-line-length=$(MAX_LINE_LENGTH) examples *.py From 12477c954e7f051d10152f9ab970e28fd4222b30 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 15 May 2019 23:11:53 +0200 Subject: [PATCH 0012/1433] Run black --check as part of CI to enforce code style --- .travis.yml | 1 + Makefile | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2bda93067..9b8e8d5e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ install: - pip install -r requirements-dev.txt # mypy can't be installed on pypy - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi + - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* ]] ; then pip install black ; fi script: - make ci after_success: diff --git a/Makefile b/Makefile index 18a1c73d1..82b438285 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,16 @@ .PHONY: all virtualenv MAX_LINE_LENGTH=110 PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") +PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.version_info[:2])") LINT_TARGETS:=flake8 + ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) LINT_TARGETS:=$(LINT_TARGETS) mypy endif +ifneq ($(findstring 3.5,$(PYTHON_VERSION)),3.5) + LINT_TARGETS:=$(LINT_TARGETS) black_check +endif virtualenv: ./env/requirements.built @@ -26,6 +31,10 @@ lint: $(LINT_TARGETS) flake8: flake8 --max-line-length=$(MAX_LINE_LENGTH) examples *.py +.PHONY: black_check +black_check: + black --check . + mypy: mypy examples/*.py test_zeroconf.py zeroconf.py From 6b85a333de21fa36187f081c3c115c8af40d7055 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 20 May 2019 16:16:08 +0200 Subject: [PATCH 0013/1433] Makefile: be specific which files to check with black (#169) Otherwise black tries to check the "env" directory, which fails. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 82b438285..1dcce0f06 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ flake8: .PHONY: black_check black_check: - black --check . + black --check *.py examples mypy: mypy examples/*.py test_zeroconf.py zeroconf.py From c7876108150cd251786db4ab52dadd1b2283d262 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Wed, 22 May 2019 10:26:04 +0200 Subject: [PATCH 0014/1433] Add support for multiple addresses when publishing a service (#170) This is a rebased and fixed version of PR #27, which also adds compatibility shim for ServiceInfo.address and does a proper deprecation for it. * Present all addresses that are available. * Add support for publishing multiple addresses. * Add test for backwards compatibility. * Provide proper deprecation of the "address" argument and field * Raise deprecation warnings when address is used * Add a compatibility property to avoid breaking existing code (based on suggestion by Bas Stottelaar in PR #27) * Make addresses keyword-only, so that address can be eventually removed and replaced with it without breaking consumers * Raise TypeError instead of an assertion on conflicting address and addresses * Disable black on ServiceInfo.__init__ until black is fixed Due to https://github.com/python/black/issues/759 black produces code that is invalid Python 3.5 syntax even with --target-version py35. This patch disables reformatting for this call (it doesn't seem to be possible per line) until it's fixed. --- examples/browser.py | 3 +- examples/registration.py | 10 ++-- examples/self_test.py | 8 +-- test_zeroconf.py | 37 ++++++++++++ zeroconf.py | 119 +++++++++++++++++++++++++++------------ 5 files changed, 129 insertions(+), 48 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index c851be676..8b4fd5ee2 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -19,7 +19,8 @@ def on_service_state_change( if state_change is ServiceStateChange.Added: info = zeroconf.get_service_info(service_type, name) if info: - print(" Address: %s:%d" % (socket.inet_ntoa(cast(bytes, info.address)), cast(int, info.port))) + addresses = ["%s:%d" % (socket.inet_ntoa(addr), cast(int, info.port)) for addr in info.addresses] + print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(" Server: %s" % (info.server,)) if info.properties: diff --git a/examples/registration.py b/examples/registration.py index 7829acc92..bda55b831 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -20,12 +20,10 @@ info = ServiceInfo( "_http._tcp.local.", "Paul's Test Web Site._http._tcp.local.", - socket.inet_aton("127.0.0.1"), - 80, - 0, - 0, - desc, - "ash-2.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=80, + properties=desc, + server="ash-2.local.", ) zeroconf = Zeroconf() diff --git a/examples/self_test.py b/examples/self_test.py index 6667d13ee..62e325732 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -21,11 +21,9 @@ info = ServiceInfo( "_http._tcp.local.", "My Service Name._http._tcp.local.", - socket.inet_aton("127.0.0.1"), - 1234, - 0, - 0, - desc, + addresses=[socket.inet_aton("127.0.0.1")], + port=1234, + properties=desc, ) print(" Registering service...") r.register_service(info) diff --git a/test_zeroconf.py b/test_zeroconf.py index 9737d38f4..59f03df6c 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -958,3 +958,40 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): assert service_removed.is_set() browser.cancel() zeroconf_browser.close() + + +def test_multiple_addresses(): + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + desc = {'path': '/~paulsm/'} + address = socket.inet_aton("10.0.1.2") + + # Old way + info = ServiceInfo(type_, registration_name, address, 80, 0, 0, desc, "ash-2.local.") + + assert info.address == address + assert info.addresses == [address] + + # Updating works + address2 = socket.inet_aton("10.0.1.3") + info.address = address2 + + assert info.address == address2 + assert info.addresses == [address2] + + info.address = None + + assert info.address is None + assert info.addresses == [] + + # Compatibility way + info = ServiceInfo(type_, registration_name, [address, address], 80, 0, 0, desc, "ash-2.local.") + + assert info.addresses == [address, address] + + # New kwarg way + info = ServiceInfo( + type_, registration_name, None, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address] + ) + + assert info.addresses == [address, address] diff --git a/zeroconf.py b/zeroconf.py index b8c664730..c0f16797e 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -30,6 +30,7 @@ import sys import threading import time +import warnings from functools import reduce from typing import AnyStr, Dict, List, Optional, Union, cast from typing import Callable, Set, Tuple # noqa # used in type hints @@ -1429,11 +1430,14 @@ class ServiceInfo(RecordUpdateListener): """Service information""" + # FIXME(dtantsur): black 19.3b0 produces code that is not valid syntax on + # Python 3.5: https://github.com/python/black/issues/759 + # fmt: off def __init__( self, type_: str, name: str, - address: Optional[bytes] = None, + address: Optional[Union[bytes, List[bytes]]] = None, port: Optional[int] = None, weight: int = 0, priority: int = 0, @@ -1441,12 +1445,14 @@ def __init__( server: Optional[str] = None, host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL, + *, + addresses: Optional[List[bytes]] = None ) -> None: """Create a service description. type_: fully qualified service type name name: fully qualified service name - address: IP address as unsigned short, network byte order + address: IP address as unsigned short, network byte order (deprecated, use addresses) port: port that the service runs on weight: weight of the service priority: priority of the service @@ -1454,13 +1460,29 @@ def __init__( bytes for the text field) server: fully qualified name for service host (defaults to name) host_ttl: ttl used for A/SRV records - other_ttl: ttl used for PTR/TXT records""" + other_ttl: ttl used for PTR/TXT records + addresses: List of IP addresses as unsigned short, network byte + order + """ + + # Accept both none, or one, but not both. + if address is not None and addresses is not None: + raise TypeError("address and addresses cannot be provided together") if not type_.endswith(service_type_name(name, allow_underscores=True)): raise BadTypeInNameException self.type = type_ self.name = name - self.address = address + if addresses is not None: + self.addresses = addresses + elif address is not None: + warnings.warn("address is deprecated, use addresses instead", DeprecationWarning) + if isinstance(address, list): + self.addresses = address + else: + self.addresses = [address] + else: + self.addresses = [] self.port = port self.weight = weight self.priority = priority @@ -1472,6 +1494,23 @@ def __init__( self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl + # fmt: on + + @property + def address(self): + warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) + try: + return self.addresses[0] + except IndexError: + return None + + @address.setter + def address(self, value): + warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) + if value is None: + self.addresses = [] + else: + self.addresses = [value] @property def properties(self) -> ServicePropertiesType: @@ -1553,7 +1592,8 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: assert isinstance(record, DNSAddress) # if record.name == self.name: if record.name == self.server: - self.address = record.address + if record.address not in self.addresses: + self.addresses.append(record.address) elif record.type == _TYPE_SRV: assert isinstance(record, DNSService) if record.name == self.name: @@ -1585,12 +1625,12 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if cached: self.update_record(zc, now, cached) - if None not in (self.server, self.address, self.text): + if self.server is not None and self.text is not None and self.addresses: return True try: zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) - while None in (self.server, self.address, self.text): + while self.server is None or self.text is None or not self.addresses: if last <= now: return False if next_ <= now: @@ -1629,7 +1669,16 @@ def __repr__(self) -> str: type(self).__name__, ', '.join( '%s=%r' % (name, getattr(self, name)) - for name in ('type', 'name', 'address', 'port', 'weight', 'priority', 'server', 'properties') + for name in ( + 'type', + 'name', + 'addresses', + 'port', + 'weight', + 'priority', + 'server', + 'properties', + ) ), ) @@ -1916,10 +1965,8 @@ def _broadcast_service(self, info: ServiceInfo) -> None: ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) - if info.address: - out.add_answer_at_time( - DNSAddress(info.server, _TYPE_A, _CLASS_IN, info.host_ttl, info.address), 0 - ) + for address in info.addresses: + out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, info.host_ttl, address), 0) self.send(out) i += 1 next_time += _REGISTER_TIME @@ -1952,8 +1999,8 @@ def unregister_service(self, info: ServiceInfo) -> None: ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - if info.address: - out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0) + for address in info.addresses: + out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -1986,10 +2033,8 @@ def unregister_all_services(self) -> None: 0, ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - if info.address: - out.add_answer_at_time( - DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, info.address), 0 - ) + for address in info.addresses: + out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -2126,16 +2171,17 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: if question.type in (_TYPE_A, _TYPE_ANY): for service in self.services.values(): if service.server == question.name.lower(): - out.add_answer( - msg, - DNSAddress( - question.name, - _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.address, - ), - ) + for address in service.addresses: + out.add_answer( + msg, + DNSAddress( + question.name, + _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + address, + ), + ) name_to_find = question.name.lower() if name_to_find not in self.services: @@ -2168,15 +2214,16 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: ), ) if question.type == _TYPE_SRV: - out.add_additional_answer( - DNSAddress( - service.server, - _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.address, + for address in service.addresses: + out.add_additional_answer( + DNSAddress( + service.server, + _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + address, + ) ) - ) except Exception: # TODO stop catching all Exceptions self.log_exception_warning() From 7bd04363c7ff0f583a17cc2fac42f9a9c1724769 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 4 Jun 2019 17:51:55 +0200 Subject: [PATCH 0015/1433] Release version 0.23.0 --- README.rst | 11 +++++++++++ zeroconf.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 692872678..456483b42 100644 --- a/README.rst +++ b/README.rst @@ -120,6 +120,17 @@ See examples directory for more. Changelog ========= +0.23.0 +------ + +* Added support for MyListener call getting updates to service TXT records, thanks to Matt Saxon +* Added support for multiple addresses when publishing a service, getting/setting single address + has become deprecated. Change thanks to Dmitry Tantsur + +Backwards incompatible: + +* Dropped Python 3.4 support + 0.22.0 ------ diff --git a/zeroconf.py b/zeroconf.py index c0f16797e..19b772519 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -39,7 +39,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.22.0' +__version__ = '0.23.0' __license__ = 'LGPL' From 3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Fri, 31 May 2019 13:53:57 +0200 Subject: [PATCH 0016/1433] First stab at supporting listening on IPv6 interfaces This change adds basic support for listening on IPv6 interfaces. Some limitations exist for non-POSIX platforms, pending fixes in Python and in the ifaddr library. Also dual V4-V6 sockets may not work on all BSD platforms. As a result, V4-only is used by default. Unfortunately, Travis does not seem to support IPv6, so the tests are disabled on it, which also leads to coverage decrease. --- .travis.yml | 3 +- Makefile | 5 +- README.rst | 12 ++ examples/browser.py | 23 ++- examples/registration.py | 23 ++- test_zeroconf.py | 41 +++++ zeroconf.py | 320 +++++++++++++++++++++++++++++++-------- 7 files changed, 349 insertions(+), 78 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9b8e8d5e2..4065a354a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ install: - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* ]] ; then pip install black ; fi script: - - make ci + # no IPv6 support in Travis :( + - make TEST_ARGS='-a "!IPv6"' ci after_success: - coveralls diff --git a/Makefile b/Makefile index 1dcce0f06..d8be73fa3 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ MAX_LINE_LENGTH=110 PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.version_info[:2])") +TEST_ARGS= LINT_TARGETS:=flake8 @@ -39,10 +40,10 @@ mypy: mypy examples/*.py test_zeroconf.py zeroconf.py test: - nosetests -v + nosetests -v $(TEST_ARGS) test_coverage: - nosetests -v --with-coverage --cover-package=zeroconf + nosetests -v --with-coverage --cover-package=zeroconf $(TEST_ARGS) autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i examples *.py diff --git a/README.rst b/README.rst index 456483b42..8b4624df9 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,18 @@ Status There are some people using this package. I don't actively use it and as such any help I can offer with regard to any issues is very limited. +IPv6 support +------------ + +IPv6 support is relatively new and currently limited, specifically: +* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX + systems. +* On Windows specific interfaces can only be requested as interface indexes, + not as IP addresses. +* Dual-stack IPv6 sockets are used, which may not be supported everywhere (some + BSD variants do not have them). +* Listening on localhost (`::1`) does not work. Help with understanding why is + appreciated. How to get python-zeroconf? =========================== diff --git a/examples/browser.py b/examples/browser.py index 8b4fd5ee2..712390b89 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -2,13 +2,13 @@ """ Example of browsing for a service (in this case, HTTP) """ +import argparse import logging import socket -import sys from time import sleep from typing import cast -from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf +from zeroconf import IpVersion, ServiceBrowser, ServiceStateChange, Zeroconf def on_service_state_change( @@ -36,11 +36,24 @@ def on_service_state_change( if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) - if len(sys.argv) > 1: - assert sys.argv[1:] == ['--debug'] + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IpVersion.All + elif args.v6_only: + ip_version = IpVersion.V6Only + else: + ip_version = IpVersion.V4Only - zeroconf = Zeroconf() + zeroconf = Zeroconf(ip_version=ip_version) print("\nBrowsing services, press Ctrl-C to exit...\n") browser = ServiceBrowser(zeroconf, "_http._tcp.local.", handlers=[on_service_state_change]) diff --git a/examples/registration.py b/examples/registration.py index bda55b831..a5e334a50 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -2,18 +2,31 @@ """ Example of announcing a service (in this case, a fake HTTP server) """ +import argparse import logging import socket -import sys from time import sleep -from zeroconf import ServiceInfo, Zeroconf +from zeroconf import IpVersion, ServiceInfo, Zeroconf if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) - if len(sys.argv) > 1: - assert sys.argv[1:] == ['--debug'] + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IpVersion.All + elif args.v6_only: + ip_version = IpVersion.V6Only + else: + ip_version = IpVersion.V4Only desc = {'path': '/~paulsm/'} @@ -26,7 +39,7 @@ server="ash-2.local.", ) - zeroconf = Zeroconf() + zeroconf = Zeroconf(ip_version=ip_version) print("Registration of a service, press Ctrl-C to exit...") zeroconf.register_service(info) try: diff --git a/test_zeroconf.py b/test_zeroconf.py index 59f03df6c..56746d0b0 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -14,6 +14,7 @@ from typing import Dict, Optional # noqa # used in type hints from typing import cast +from nose.plugins.attrib import attr import zeroconf as r from zeroconf import ( @@ -440,6 +441,22 @@ def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) rv.close() + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @attr('IPv6') + def test_launch_and_close_v4_v6(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IpVersion.All) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IpVersion.All) + rv.close() + + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @attr('IPv6') + def test_launch_and_close_v6_only(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IpVersion.V6Only) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IpVersion.V6Only) + rv.close() + class Exceptions(unittest.TestCase): @@ -672,6 +689,30 @@ def test_integration_with_listener(self): finally: zeroconf_registrar.close() + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @attr('IPv6') + def test_integration_with_listener_ipv6(self): + + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + zeroconf_registrar = Zeroconf(ip_version=r.IpVersion.V6Only) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(ip_version=r.IpVersion.V6Only, timeout=0.5) + assert type_ in service_types, service_types + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types, service_types + + finally: + zeroconf_registrar.close() + def test_integration_with_subtype_and_listener(self): subtype_ = "_subtype._sub" type_ = "_type._tcp.local." diff --git a/zeroconf.py b/zeroconf.py index 19b772519..4091159de 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -22,7 +22,9 @@ import enum import errno +import ipaddress import logging +import os import re import select import socket @@ -33,7 +35,7 @@ import warnings from functools import reduce from typing import AnyStr, Dict, List, Optional, Union, cast -from typing import Callable, Set, Tuple # noqa # used in type hints +from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -51,6 +53,7 @@ "Error", "InterfaceChoice", "ServiceStateChange", + "IpVersion", ] if sys.version_info <= (3, 3): @@ -79,6 +82,9 @@ # Some DNS constants _MDNS_ADDR = '224.0.0.251' +_MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) +_MDNS_ADDR6 = 'ff02::fb' +_MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 @@ -167,6 +173,12 @@ _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') _HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') +try: + _IPPROTO_IPV6 = socket.IPPROTO_IPV6 +except AttributeError: + # Sigh: https://bugs.python.org/issue29515 + _IPPROTO_IPV6 = 41 + int2byte = struct.Struct(">B").pack @@ -183,6 +195,13 @@ class ServiceStateChange(enum.Enum): Updated = 3 +@enum.unique +class IpVersion(enum.Enum): + V4Only = 1 + V6Only = 2 + All = 3 + + # utility functions @@ -1191,7 +1210,7 @@ def __init__(self, zc): def handle_read(self, socket_): try: - data, (addr, port) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) + data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) except Exception: self.log_exception_warning() return @@ -1206,13 +1225,13 @@ def handle_read(self, socket_): elif msg.is_query(): # Always multicast responses if port == _MDNS_PORT: - self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + self.zc.handle_query(msg, None, _MDNS_PORT) # If it's not a multicast query, reply via unicast # and multicast elif port == _DNS_PORT: self.zc.handle_query(msg, addr, port) - self.zc.handle_query(msg, _MDNS_ADDR, _MDNS_PORT) + self.zc.handle_query(msg, None, _MDNS_PORT) else: self.zc.handle_response(msg) @@ -1297,7 +1316,7 @@ def __init__( type_: str, handlers=None, listener=None, - addr: str = _MDNS_ADDR, + addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, ) -> None: @@ -1311,7 +1330,7 @@ def __init__( self.type = type_ self.addr = addr self.port = port - self.multicast = self.addr == _MDNS_ADDR + self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) self.services = {} # type: Dict[str, DNSRecord] self.next_time = current_time_millis() self.delay = delay @@ -1698,16 +1717,18 @@ def remove_service(self, zc, type_, name): pass @classmethod - def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All): + def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All, ip_version=None): """ Return all of the advertised services on any local networks. :param zc: Zeroconf() instance. Pass in if already have an instance running or if non-default interfaces are needed :param timeout: seconds to wait for any responses + :param interfaces: interfaces to listen on. + :param ip_version: IP protocol version to use. :return: tuple of service type strings """ - local_zc = zc or Zeroconf(interfaces=interfaces) + local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) listener = cls() browser = ServiceBrowser(local_zc, '_services._dns-sd._udp.local.', listener=listener) @@ -1734,18 +1755,106 @@ def get_all_addresses() -> List[str]: ) -def normalize_interface_choice(choice: Union[List[str], InterfaceChoice]) -> List[str]: +def get_all_addresses_v6() -> List[int]: + # IPv6 multicast uses positive indexes for interfaces + try: + nameindex = socket.if_nameindex + except AttributeError: + # Requires Python 3.8 on Windows. Fall back to Default. + QuietLogger.log_warning_once( + 'if_nameindex is not available, falling back to using the default IPv6 interface' + ) + return [0] + + return [tpl[0] for tpl in nameindex()] + + +def ip_to_index(adapters: List[Any], ip: str) -> int: + if os.name != 'posix': + # Adapter names that ifaddr reports are not compatible with what if_nametoindex expects on Windows. + # We need https://github.com/pydron/ifaddr/pull/21 but it seems stuck on review. + raise RuntimeError('Converting from IP addresses to indexes is not supported on non-POSIX systems') + + ipaddr = ipaddress.ip_address(ip) + for adapter in adapters: + for adapter_ip in adapter.ips: + # IPv6 addresses are represented as tuples + if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: + return socket.if_nametoindex(adapter.name) + + raise RuntimeError('No adapter found for IP address %s' % ip) + + +def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: + """Convert IPv6 interface addresses to interface indexes. + + IPv4 addresses are ignored. The conversion currently only works on POSIX + systems. + + :param interfaces: List of IP addresses and indexes. + :returns: List of indexes. + """ + result = [] + adapters = ifaddr.get_adapters() + + for iface in interfaces: + if isinstance(iface, int): + result.append(iface) + elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: + result.append(ip_to_index(adapters, iface)) + + return result + + +def normalize_interface_choice( + choice: Union[List[Union[str, int]], InterfaceChoice], ip_version: IpVersion = IpVersion.V4Only +) -> List[Union[str, int]]: + """Convert the interfaces choice into internal representation. + + :param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only). + :param ip_address: IP version to use (ignored if `choice` is a list). + :returns: List of IP addresses (for IPv4) and indexes (for IPv6). + """ + result = [] # type: List[Union[str, int]] if choice is InterfaceChoice.Default: - return ['0.0.0.0'] + if ip_version != IpVersion.V4Only: + # IPv6 multicast uses interface 0 to mean the default + result.append(0) + if ip_version != IpVersion.V6Only: + result.append('0.0.0.0') elif choice is InterfaceChoice.All: - return get_all_addresses() + if ip_version != IpVersion.V4Only: + result.extend(get_all_addresses_v6()) + if ip_version != IpVersion.V6Only: + result.extend(get_all_addresses()) + if not result: + raise RuntimeError( + 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version + ) + elif isinstance(choice, list): + # First, take IPv4 addresses. + result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] + # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. + result += ip6_addresses_to_indexes(choice) else: - assert isinstance(choice, list) - return choice + raise TypeError("choice must be a list or InterfaceChoice, got %r" % choice) + return result -def new_socket(port: int = _MDNS_PORT) -> socket.socket: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +def new_socket(port: int = _MDNS_PORT, ip_version: IpVersion = IpVersion.V4Only) -> socket.socket: + if ip_version == IpVersion.V4Only: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + else: + s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + + if ip_version == IpVersion.All: + # make V6 sockets work for both V4 and V6 (required for Windows) + try: + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) + except OSError: + log.error('Support for dual V4-V6 sockets is not present, use IpVersion.V4 or IpVersion.V6') + raise + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # SO_REUSEADDR should be equivalent to SO_REUSEPORT for @@ -1768,22 +1877,101 @@ def new_socket(port: int = _MDNS_PORT) -> socket.socket: raise if port is _MDNS_PORT: - # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and - # IP_MULTICAST_LOOP socket options as an unsigned char. ttl = struct.pack(b'B', 255) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) loop = struct.pack(b'B', 1) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + if ip_version != IpVersion.V6Only: + # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and + # IP_MULTICAST_LOOP socket options as an unsigned char. + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + if ip_version != IpVersion.V4Only: + # However, char doesn't work here (at least on Linux) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) s.bind(('', port)) return s +def add_multicast_member(listen_socket, interface): + # This is based on assumptions in normalize_interface_choice + is_v6 = isinstance(interface, int) + log.debug('Adding %r to multicast group', interface) + try: + if is_v6: + iface_bin = struct.pack('@I', interface) + _value = _MDNS_ADDR6_BYTES + iface_bin + listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) + else: + _value = _MDNS_ADDR_BYTES + socket.inet_aton(interface) + listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) + except socket.error as e: + _errno = get_errno(e) + if _errno == errno.EADDRINUSE: + log.info( + 'Address in use when adding %s to multicast group, ' + 'it is expected to happen on some systems', + interface, + ) + return + elif _errno == errno.EADDRNOTAVAIL: + log.info( + 'Address not available when adding %s to multicast ' + 'group, it is expected to happen on some systems', + interface, + ) + return + elif _errno == errno.EINVAL: + log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) + return + else: + raise + + respond_socket = new_socket(ip_version=(IpVersion.V6Only if is_v6 else IpVersion.V4Only)) + log.debug('Configuring %s with multicast interface %s', respond_socket, interface) + if is_v6: + respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) + else: + respond_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface)) + return respond_socket + + +def create_sockets( + interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, + unicast: bool = False, + ip_version: IpVersion = IpVersion.V4Only, +): + if unicast: + listen_socket = None + else: + listen_socket = new_socket(ip_version=ip_version) + + interfaces = normalize_interface_choice(interfaces, ip_version) + + respond_sockets = [] + + for i in interfaces: + if not unicast: + respond_socket = add_multicast_member(listen_socket, i) + else: + respond_socket = new_socket(port=0, ip_version=ip_version) + + if respond_socket is not None: + respond_sockets.append(respond_socket) + + return listen_socket, respond_sockets + + def get_errno(e: Exception) -> int: assert isinstance(e, socket.error) return cast(int, e.args[0]) +def can_send_to(sock, address: str): + addr = ipaddress.ip_address(address) + return addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4 + + class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -1792,57 +1980,45 @@ class Zeroconf(QuietLogger): """ def __init__( - self, interfaces: Union[List[str], InterfaceChoice] = InterfaceChoice.All, unicast: bool = False + self, + interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, + unicast: bool = False, + ip_version: Optional[IpVersion] = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. - :type interfaces: :class:`InterfaceChoice` or sequence of ip addresses - """ - # hook for threads - self._GLOBAL_DONE = False - self.unicast = unicast + :param interfaces: :class:`InterfaceChoice` or a list of IP addresses + (IPv4 and IPv6) and interface indexes (IPv6 only). - if not unicast: - self._listen_socket = new_socket() - interfaces = normalize_interface_choice(interfaces) - - self._respond_sockets = [] # type: List[socket.socket] + IPv6 notes for non-POSIX systems: + * IPv6 addresses are not supported, use indexes instead. + * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` + on Python versions before 3.8. - for i in interfaces: - if not unicast: - log.debug('Adding %r to multicast group', i) - try: - _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(i) - self._listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) - except socket.error as e: - _errno = get_errno(e) - if _errno == errno.EADDRINUSE: - log.info( - 'Address in use when adding %s to multicast group, ' - 'it is expected to happen on some systems', - i, - ) - elif _errno == errno.EADDRNOTAVAIL: - log.info( - 'Address not available when adding %s to multicast ' - 'group, it is expected to happen on some systems', - i, - ) - continue - elif _errno == errno.EINVAL: - log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', i) - continue + Also listening on loopback (``::1``) doesn't work, use a real address. + :param ip_version: IP versions to support. If `choice` is a list, the default is detected + from it. Otherwise defaults to V4 only for backward compatibility. + """ + if ip_version is None and isinstance(interfaces, list): + has_v6 = any( + isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + for i in interfaces + ) + has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) + if has_v4 and has_v6: + ip_version = IpVersion.All + elif has_v6: + ip_version = IpVersion.V6Only - else: - raise + if ip_version is None: + ip_version = IpVersion.V4Only - respond_socket = new_socket() - respond_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(i)) - else: - respond_socket = new_socket(port=0) + # hook for threads + self._GLOBAL_DONE = False + self.unicast = unicast - self._respond_sockets.append(respond_socket) + self._listen_socket, self._respond_sockets = create_sockets(interfaces, unicast, ip_version) self.listeners = [] # type: List[RecordUpdateListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] @@ -2130,7 +2306,7 @@ def handle_response(self, msg: DNSIncoming) -> None: if record.type != _TYPE_TXT: self.update_record(now, record) - def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: + def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if possible.""" out = None @@ -2231,7 +2407,7 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: out.id = msg.id self.send(out, addr, port) - def send(self, out: DNSOutgoing, addr: str = _MDNS_ADDR, port: int = _MDNS_PORT) -> None: + def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Sends an outgoing packet.""" packet = out.packet() if len(packet) > _MAX_MSG_ABSOLUTE: @@ -2242,8 +2418,22 @@ def send(self, out: DNSOutgoing, addr: str = _MDNS_ADDR, port: int = _MDNS_PORT) if self._GLOBAL_DONE: return try: - bytes_sent = s.sendto(packet, 0, (addr, port)) - except Exception: # TODO stop catching all Exceptions + if addr is None: + real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + elif not can_send_to(s, addr): + continue + else: + real_addr = addr + bytes_sent = s.sendto(packet, 0, (real_addr, port)) + except Exception as exc: # TODO stop catching all Exceptions + if ( + isinstance(exc, OSError) + and exc.errno == errno.ENETUNREACH + and s.family == socket.AF_INET6 + ): + # with IPv6 we don't have a reliable way to determine if an interface actually has IPv6 + # support, so we have to try and ignore errors. + continue # on send errors, log the exception and keep going self.log_exception_warning() else: From ceb602c0d1bc1d3a269fd233b072a9b929076438 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 22 Jul 2019 18:25:00 +0200 Subject: [PATCH 0017/1433] Rename IpVersion to IPVersion A follow up to 3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8. --- examples/browser.py | 8 ++++---- examples/registration.py | 8 ++++---- test_zeroconf.py | 12 ++++++------ zeroconf.py | 38 +++++++++++++++++++------------------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index 712390b89..bf3ebfbdc 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -8,7 +8,7 @@ from time import sleep from typing import cast -from zeroconf import IpVersion, ServiceBrowser, ServiceStateChange, Zeroconf +from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf def on_service_state_change( @@ -47,11 +47,11 @@ def on_service_state_change( if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) if args.v6: - ip_version = IpVersion.All + ip_version = IPVersion.All elif args.v6_only: - ip_version = IpVersion.V6Only + ip_version = IPVersion.V6Only else: - ip_version = IpVersion.V4Only + ip_version = IPVersion.V4Only zeroconf = Zeroconf(ip_version=ip_version) print("\nBrowsing services, press Ctrl-C to exit...\n") diff --git a/examples/registration.py b/examples/registration.py index a5e334a50..65c221996 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -7,7 +7,7 @@ import socket from time import sleep -from zeroconf import IpVersion, ServiceInfo, Zeroconf +from zeroconf import IPVersion, ServiceInfo, Zeroconf if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) @@ -22,11 +22,11 @@ if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) if args.v6: - ip_version = IpVersion.All + ip_version = IPVersion.All elif args.v6_only: - ip_version = IpVersion.V6Only + ip_version = IPVersion.V6Only else: - ip_version = IpVersion.V4Only + ip_version = IPVersion.V4Only desc = {'path': '/~paulsm/'} diff --git a/test_zeroconf.py b/test_zeroconf.py index 56746d0b0..54532f0c6 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -444,17 +444,17 @@ def test_launch_and_close(self): @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') @attr('IPv6') def test_launch_and_close_v4_v6(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IpVersion.All) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IpVersion.All) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) rv.close() @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') @attr('IPv6') def test_launch_and_close_v6_only(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IpVersion.V6Only) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IpVersion.V6Only) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) rv.close() @@ -697,7 +697,7 @@ def test_integration_with_listener_ipv6(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) - zeroconf_registrar = Zeroconf(ip_version=r.IpVersion.V6Only) + zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." @@ -705,7 +705,7 @@ def test_integration_with_listener_ipv6(self): zeroconf_registrar.register_service(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IpVersion.V6Only, timeout=0.5) + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) assert type_ in service_types, service_types service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types, service_types diff --git a/zeroconf.py b/zeroconf.py index 4091159de..1ab1b556c 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -53,7 +53,7 @@ "Error", "InterfaceChoice", "ServiceStateChange", - "IpVersion", + "IPVersion", ] if sys.version_info <= (3, 3): @@ -196,7 +196,7 @@ class ServiceStateChange(enum.Enum): @enum.unique -class IpVersion(enum.Enum): +class IPVersion(enum.Enum): V4Only = 1 V6Only = 2 All = 3 @@ -1807,7 +1807,7 @@ def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: def normalize_interface_choice( - choice: Union[List[Union[str, int]], InterfaceChoice], ip_version: IpVersion = IpVersion.V4Only + choice: Union[List[Union[str, int]], InterfaceChoice], ip_version: IPVersion = IPVersion.V4Only ) -> List[Union[str, int]]: """Convert the interfaces choice into internal representation. @@ -1817,15 +1817,15 @@ def normalize_interface_choice( """ result = [] # type: List[Union[str, int]] if choice is InterfaceChoice.Default: - if ip_version != IpVersion.V4Only: + if ip_version != IPVersion.V4Only: # IPv6 multicast uses interface 0 to mean the default result.append(0) - if ip_version != IpVersion.V6Only: + if ip_version != IPVersion.V6Only: result.append('0.0.0.0') elif choice is InterfaceChoice.All: - if ip_version != IpVersion.V4Only: + if ip_version != IPVersion.V4Only: result.extend(get_all_addresses_v6()) - if ip_version != IpVersion.V6Only: + if ip_version != IPVersion.V6Only: result.extend(get_all_addresses()) if not result: raise RuntimeError( @@ -1841,18 +1841,18 @@ def normalize_interface_choice( return result -def new_socket(port: int = _MDNS_PORT, ip_version: IpVersion = IpVersion.V4Only) -> socket.socket: - if ip_version == IpVersion.V4Only: +def new_socket(port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only) -> socket.socket: + if ip_version == IPVersion.V4Only: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) else: s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) - if ip_version == IpVersion.All: + if ip_version == IPVersion.All: # make V6 sockets work for both V4 and V6 (required for Windows) try: s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) except OSError: - log.error('Support for dual V4-V6 sockets is not present, use IpVersion.V4 or IpVersion.V6') + log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') raise s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -1879,12 +1879,12 @@ def new_socket(port: int = _MDNS_PORT, ip_version: IpVersion = IpVersion.V4Only) if port is _MDNS_PORT: ttl = struct.pack(b'B', 255) loop = struct.pack(b'B', 1) - if ip_version != IpVersion.V6Only: + if ip_version != IPVersion.V6Only: # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and # IP_MULTICAST_LOOP socket options as an unsigned char. s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) - if ip_version != IpVersion.V4Only: + if ip_version != IPVersion.V4Only: # However, char doesn't work here (at least on Linux) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) @@ -1927,7 +1927,7 @@ def add_multicast_member(listen_socket, interface): else: raise - respond_socket = new_socket(ip_version=(IpVersion.V6Only if is_v6 else IpVersion.V4Only)) + respond_socket = new_socket(ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only)) log.debug('Configuring %s with multicast interface %s', respond_socket, interface) if is_v6: respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) @@ -1939,7 +1939,7 @@ def add_multicast_member(listen_socket, interface): def create_sockets( interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, unicast: bool = False, - ip_version: IpVersion = IpVersion.V4Only, + ip_version: IPVersion = IPVersion.V4Only, ): if unicast: listen_socket = None @@ -1983,7 +1983,7 @@ def __init__( self, interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, unicast: bool = False, - ip_version: Optional[IpVersion] = None, + ip_version: Optional[IPVersion] = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -2007,12 +2007,12 @@ def __init__( ) has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) if has_v4 and has_v6: - ip_version = IpVersion.All + ip_version = IPVersion.All elif has_v6: - ip_version = IpVersion.V6Only + ip_version = IPVersion.V6Only if ip_version is None: - ip_version = IpVersion.V4Only + ip_version = IPVersion.V4Only # hook for threads self._GLOBAL_DONE = False From ea6426547f79c32c6d5d3bcc2d0a261bf503197a Mon Sep 17 00:00:00 2001 From: Scott Mertz Date: Thu, 8 Aug 2019 12:01:23 -0700 Subject: [PATCH 0018/1433] Add additional recommended records to PTR responses (#184) RFC6763 indicates a server should include the SRV/TXT/A/AAAA records when responding to a PTR record request. This optimization ensures the client doesn't have to then query for these additional records. It has been observed that when multiple Windows 10 machines are monitoring for the same service, this unoptimized response to the PTR record request can cause extremely high CPU usage in both the DHCP Client & Device Association service (I suspect due to all clients having to then sending/receiving the additional queries/responses). --- test_zeroconf.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++-- zeroconf.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index 54532f0c6..fbef8e631 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -621,7 +621,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) - assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -644,7 +644,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) - assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -1036,3 +1036,58 @@ def test_multiple_addresses(): ) assert info.addresses == [address, address] + + +def test_ptr_optimization(): + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo(type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local.") + + # we are going to monkey patch the zeroconf send to check packet sizes + old_send = zc.send + + nbr_answers = nbr_additionals = nbr_authorities = 0 + has_srv = has_txt = has_a = False + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal nbr_answers, nbr_additionals, nbr_authorities + nonlocal has_srv, has_txt, has_a + + nbr_answers += len(out.answers) + nbr_authorities += len(out.authorities) + for answer in out.additionals: + nbr_additionals += 1 + if answer.type == r._TYPE_SRV: + has_srv = True + elif answer.type == r._TYPE_TXT: + has_txt = True + elif answer.type == r._TYPE_A: + has_a = True + + old_send(out, addr=addr, port=port) + + # monkey patch the zeroconf send + setattr(zc, "send", send) + + # register + zc.register_service(info) + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # query + query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) + zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) + assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert has_srv and has_txt and has_a + + # unregister + zc.unregister_service(info) diff --git a/zeroconf.py b/zeroconf.py index 1ab1b556c..6c567ee25 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -2338,6 +2338,40 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None msg, DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name), ) + + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer( + DNSService( + service.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.priority, + service.weight, + service.port, + service.server, + ) + ) + out.add_additional_answer( + DNSText( + service.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + service.other_ttl, + service.text, + ) + ) + for address in service.addresses: + out.add_additional_answer( + DNSAddress( + service.server, + _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + address, + ) + ) else: try: if out is None: From e5323d8c9795c59019173b8d202a50a49c415039 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 17 Oct 2019 20:02:17 +0200 Subject: [PATCH 0019/1433] Improve static typing coverage --- test_zeroconf.py | 4 ++-- zeroconf.py | 39 ++++++++++++++++++++------------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index fbef8e631..253591939 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -460,7 +460,7 @@ def test_launch_and_close_v6_only(self): class Exceptions(unittest.TestCase): - browser = None + browser = None # type: Zeroconf @classmethod def setUpClass(cls): @@ -469,7 +469,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): cls.browser.close() - cls.browser = None + del cls.browser def test_bad_service_info_name(self): self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") diff --git a/zeroconf.py b/zeroconf.py index 6c567ee25..6c47b6854 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -34,7 +34,7 @@ import time import warnings from functools import reduce -from typing import AnyStr, Dict, List, Optional, Union, cast +from typing import AnyStr, Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -341,7 +341,7 @@ class BadTypeInNameException(Error): class QuietLogger: - _seen_logs = {} # type: Dict[str, tuple] + _seen_logs = {} # type: Dict[str, Union[int, tuple]] @classmethod def log_exception_warning(cls, logger_data=None): @@ -365,7 +365,7 @@ def log_warning_once(cls, *args): logger = log.warning else: logger = log.debug - cls._seen_logs[msg_str] += 1 + cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 logger(*args) @@ -598,7 +598,7 @@ class DNSText(DNSRecord): """A DNS text record""" - def __init__(self, name, type_, class_, ttl, text): + def __init__(self, name: str, type_: Optional[int], class_: int, ttl: int, text: bytes) -> None: assert isinstance(text, (bytes, type(None))) DNSRecord.__init__(self, name, type_, class_, ttl) self.text = text @@ -665,7 +665,7 @@ class DNSIncoming(QuietLogger): """Object representation of an incoming DNS packet""" - def __init__(self, data): + def __init__(self, data: bytes) -> None: """Constructor from string holding bytes of packet""" self.offset = 0 self.data = data @@ -688,13 +688,13 @@ def __init__(self, data): except (IndexError, struct.error, IncomingDecodeError): self.log_exception_warning(('Choked at offset %d while unpacking %r', self.offset, data)) - def unpack(self, format_): + def unpack(self, format_: bytes) -> tuple: length = struct.calcsize(format_) info = struct.unpack(format_, self.data[self.offset : self.offset + length]) self.offset += length return info - def read_header(self): + def read_header(self) -> None: """Reads header portion of packet""" ( self.id, @@ -705,7 +705,7 @@ def read_header(self): self.num_additionals, ) = self.unpack(b'!6H') - def read_questions(self): + def read_questions(self) -> None: """Reads questions section of packet""" for i in range(self.num_questions): name = self.read_name() @@ -718,23 +718,23 @@ def read_questions(self): # """Reads an integer from the packet""" # return self.unpack(b'!I')[0] - def read_character_string(self): + def read_character_string(self) -> bytes: """Reads a character string from the packet""" length = self.data[self.offset] self.offset += 1 return self.read_string(length) - def read_string(self, length): + def read_string(self, length) -> bytes: """Reads a string of a given length from the packet""" info = self.data[self.offset : self.offset + length] self.offset += length return info - def read_unsigned_short(self): + def read_unsigned_short(self) -> int: """Reads an unsigned short from the packet""" - return self.unpack(b'!H')[0] + return cast(int, self.unpack(b'!H')[0]) - def read_others(self): + def read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" n = self.num_answers + self.num_authorities + self.num_additionals @@ -779,15 +779,15 @@ def is_query(self) -> bool: """Returns true if this is a query""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - def is_response(self): + def is_response(self) -> bool: """Returns true if this is a response""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - def read_utf(self, offset, length): + def read_utf(self, offset: int, length: int) -> str: """Reads a UTF-8 string of a given length from the packet""" return str(self.data[offset : offset + length], 'utf-8', 'replace') - def read_name(self): + def read_name(self) -> str: """Reads a domain name from the packet""" result = '' off = self.offset @@ -1171,7 +1171,7 @@ def run(self): if len(rs) != 0: try: - rr, wr, er = select.select(rs, [], [], self.timeout) + rr, wr, er = select.select(cast(Sequence[Any], rs), [], [], self.timeout) if not self.zc.done: for socket_ in rr: reader = self.readers.get(socket_) @@ -1411,12 +1411,12 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: if not expired: enqueue_callback(ServiceStateChange.Updated, record.name) - def cancel(self): + def cancel(self) -> None: self.done = True self.zc.remove_listener(self) self.join() - def run(self): + def run(self) -> None: self.zc.add_listener(self, DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) while True: @@ -1446,6 +1446,7 @@ def run(self): class ServiceInfo(RecordUpdateListener): + text = b'' """Service information""" From 5bb9531be48f6f1e119643677c36d9e714204a8b Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Thu, 17 Oct 2019 20:45:48 +0200 Subject: [PATCH 0020/1433] Make AAAA records work (closes #52) (#191) This PR incorporates changes from the earlier PR #179 (thanks to Mikael Pahmp), adding tests and a few more fixes to make AAAA records work in practice. Note that changing addresses to container IPv6 addresses may be considered a breaking change, for example, for consumers that unconditionally apply inet_aton to them. I'm introducing a new function to be able to retries only addresses from one family. --- examples/self_test.py | 5 ++++- test_zeroconf.py | 45 ++++++++++++++++++++++++++++++++++++++++--- zeroconf.py | 30 +++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/examples/self_test.py b/examples/self_test.py index 62e325732..0b231ac34 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -18,10 +18,13 @@ r = Zeroconf() print("1. Testing registration of a service...") desc = {'version': '0.10', 'a': 'test value', 'b': 'another value'} + addresses = [socket.inet_aton("127.0.0.1")] + if socket.has_ipv6: + addresses.append(socket.inet_pton(socket.AF_INET6, '::1')) info = ServiceInfo( "_http._tcp.local.", "My Service Name._http._tcp.local.", - addresses=[socket.inet_aton("127.0.0.1")], + addresses=addresses, port=1234, properties=desc, ) diff --git a/test_zeroconf.py b/test_zeroconf.py index 253591939..94803b6e6 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -556,9 +556,16 @@ def test_incoming_unknown_type(self): assert parsed.is_query() != parsed.is_response() def test_incoming_ipv6(self): - # ::TODO:: could use a test here if we add IPV6 record handling - # ie: _TYPE_AAAA - pass + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + packed = socket.inet_pton(socket.AF_INET6, addr) + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN, 1, packed) + generated.add_additional_answer(answer) + packet = generated.packet() + parsed = r.DNSIncoming(packet) + record = parsed.answers[0] + assert isinstance(record, r.DNSAddress) + assert record.address == packed class TestRegistrar(unittest.TestCase): @@ -689,6 +696,30 @@ def test_integration_with_listener(self): finally: zeroconf_registrar.close() + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + def test_integration_with_listener_v6_records(self): + + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, socket.inet_pton(socket.AF_INET6, addr), 80, 0, 0, desc, "ash-2.local." + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') @attr('IPv6') def test_integration_with_listener_ipv6(self): @@ -1037,6 +1068,14 @@ def test_multiple_addresses(): assert info.addresses == [address, address] + if socket.has_ipv6: + address_v6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, [address, address_v6], 80, 0, 0, desc, "ash-2.local.") + assert info.addresses == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.V4Only) == [address] + assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] + def test_ptr_optimization(): diff --git a/zeroconf.py b/zeroconf.py index 6c47b6854..7457ed0ec 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -210,6 +210,10 @@ def current_time_millis() -> float: return time.time() * 1000 +def _is_v6_address(addr): + return len(addr) == 16 + + def service_type_name(type_, *, allow_underscores: bool = False): """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -1536,6 +1540,15 @@ def address(self, value): def properties(self) -> ServicePropertiesType: return self._properties + def addresses_by_version(self, version: IPVersion) -> List[bytes]: + """List addresses matching IP version.""" + if version == IPVersion.V4Only: + return [addr for addr in self.addresses if not _is_v6_address(addr)] + elif version == IPVersion.V6Only: + return list(filter(_is_v6_address, self.addresses)) + else: + return self.addresses + def _set_properties(self, properties: Union[bytes, ServicePropertiesType]): """Sets properties and text of this info from a dictionary""" if isinstance(properties, dict): @@ -1608,7 +1621,7 @@ def get_name(self): def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: """Updates service information from a DNS record""" if record is not None and not record.is_expired(now): - if record.type == _TYPE_A: + if record.type in [_TYPE_A, _TYPE_AAAA]: assert isinstance(record, DNSAddress) # if record.name == self.name: if record.name == self.server: @@ -1623,6 +1636,7 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: self.priority = record.priority # self.address = None self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) + self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) elif record.type == _TYPE_TXT: assert isinstance(record, DNSText) if record.name == self.name: @@ -1640,6 +1654,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: record_types_for_check_cache = [(_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN)] if self.server is not None: record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) + record_types_for_check_cache.append((_TYPE_AAAA, _CLASS_IN)) for record_type in record_types_for_check_cache: cached = zc.cache.get_by_details(self.name, *record_type) if cached: @@ -1664,6 +1679,10 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if self.server is not None: out.add_question(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) out.add_answer_at_time(zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN), now) + out.add_question(DNSQuestion(self.server, _TYPE_AAAA, _CLASS_IN)) + out.add_answer_at_time( + zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN), now + ) zc.send(out) next_ = now + delay delay *= 2 @@ -2143,7 +2162,8 @@ def _broadcast_service(self, info: ServiceInfo) -> None: out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) for address in info.addresses: - out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, info.host_ttl, address), 0) + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, info.host_ttl, address), 0) self.send(out) i += 1 next_time += _REGISTER_TIME @@ -2177,7 +2197,8 @@ def unregister_service(self, info: ServiceInfo) -> None: out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) for address in info.addresses: - out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, address), 0) + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -2211,7 +2232,8 @@ def unregister_all_services(self) -> None: ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) for address in info.addresses: - out.add_answer_at_time(DNSAddress(info.server, _TYPE_A, _CLASS_IN, 0, address), 0) + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME From 15118c837a148a37edd29a20294e598ecf09c3cf Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sat, 26 Oct 2019 22:45:59 +0200 Subject: [PATCH 0021/1433] Test with Python 3.8 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 4065a354a..ddeafe893 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python python: - "3.5" - "3.6" + - "3.8" - "pypy3.5-5.10.1" matrix: fast_finish: true From 5359ea0a0b4cdca0854ae97c5d11036633102c67 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sat, 26 Oct 2019 22:47:50 +0200 Subject: [PATCH 0022/1433] Simplify Travis CI configuration regarding Python 3.7 Selecting xenial manually is no longer needed. --- .travis.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index ddeafe893..b99c7f66c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,9 @@ language: python python: - "3.5" - "3.6" + - "3.7" - "3.8" - "pypy3.5-5.10.1" -matrix: - fast_finish: true - include: - - { python: "3.7", dist: xenial, sudo: true } install: - pip install -r requirements-dev.txt # mypy can't be installed on pypy From c2e8bdebc6cec128d01197d53c3402278a4b62ed Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sat, 26 Oct 2019 22:49:04 +0200 Subject: [PATCH 0023/1433] Stop specifying precise pypy3.5 version This allows us to test with the latest available one. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b99c7f66c..abcc2fb94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ python: - "3.6" - "3.7" - "3.8" - - "pypy3.5-5.10.1" + - "pypy3.5" install: - pip install -r requirements-dev.txt # mypy can't be installed on pypy From fec839ae4fdcb870066fff855809583dcf7d7a17 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sat, 26 Oct 2019 22:49:29 +0200 Subject: [PATCH 0024/1433] Test with pypy3.6 Right now this is available as pypy3 in Travis CI. Running black on PyPy needs to be disabled for now because of an issue[1] that's been patched only recently and it's not available in Travis yet. [1] https://bitbucket.org/pypy/pypy/issues/2985/pypy36-osreplace-pathlike-typeerror --- .travis.yml | 4 +++- Makefile | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index abcc2fb94..acf47981d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,13 @@ python: - "3.7" - "3.8" - "pypy3.5" + - "pypy3" install: - pip install -r requirements-dev.txt # mypy can't be installed on pypy - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi - - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* ]] ; then pip install black ; fi + - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* && "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then + pip install black ; fi script: # no IPv6 support in Travis :( - make TEST_ARGS='-a "!IPv6"' ci diff --git a/Makefile b/Makefile index d8be73fa3..2ed7cccc1 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LINT_TARGETS:=flake8 ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) LINT_TARGETS:=$(LINT_TARGETS) mypy endif -ifneq ($(findstring 3.5,$(PYTHON_VERSION)),3.5) +ifeq ($(or $(findstring 3.5,$(PYTHON_VERSION)),$(findstring PyPy,$(PYTHON_IMPLEMENTATION))),) LINT_TARGETS:=$(LINT_TARGETS) black_check endif From aae7fd3ba851d1894732c4270cef745127cc03da Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 28 Oct 2019 10:41:55 +0100 Subject: [PATCH 0025/1433] Finish AAAA records support The correct record type was missing in a few places. Also use addresses_by_version(All) in preparation for switching addresses to V4 by default. --- test_zeroconf.py | 5 ++++- zeroconf.py | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/test_zeroconf.py b/test_zeroconf.py index 94803b6e6..a8c5a81ff 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -816,8 +816,11 @@ def update_service(self, zeroconf, type, name): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} # type: r.ServicePropertiesType desc.update(properties) + addresses = [socket.inet_aton("10.0.1.2")] + if socket.has_ipv6: + addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) info_service = ServiceInfo( - subtype, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses ) zeroconf_registrar.register_service(info_service) diff --git a/zeroconf.py b/zeroconf.py index 7457ed0ec..0a0c88da8 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -1485,8 +1485,8 @@ def __init__( server: fully qualified name for service host (defaults to name) host_ttl: ttl used for A/SRV records other_ttl: ttl used for PTR/TXT records - addresses: List of IP addresses as unsigned short, network byte - order + addresses: List of IP addresses as unsigned short (IPv4) or unsigned + 128 bit number (IPv6), network byte order """ # Accept both none, or one, but not both. @@ -2161,7 +2161,7 @@ def _broadcast_service(self, info: ServiceInfo) -> None: ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) - for address in info.addresses: + for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, info.host_ttl, address), 0) self.send(out) @@ -2196,7 +2196,7 @@ def unregister_service(self, info: ServiceInfo) -> None: ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - for address in info.addresses: + for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) self.send(out) @@ -2231,7 +2231,7 @@ def unregister_all_services(self) -> None: 0, ) out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) - for address in info.addresses: + for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) self.send(out) @@ -2385,11 +2385,12 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None service.text, ) ) - for address in service.addresses: + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_additional_answer( DNSAddress( service.server, - _TYPE_A, + type_, _CLASS_IN | _CLASS_UNIQUE, service.host_ttl, address, @@ -2404,12 +2405,13 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None if question.type in (_TYPE_A, _TYPE_ANY): for service in self.services.values(): if service.server == question.name.lower(): - for address in service.addresses: + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer( msg, DNSAddress( question.name, - _TYPE_A, + type_, _CLASS_IN | _CLASS_UNIQUE, service.host_ttl, address, @@ -2447,11 +2449,12 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None ), ) if question.type == _TYPE_SRV: - for address in service.addresses: + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_additional_answer( DNSAddress( service.server, - _TYPE_A, + type_, _CLASS_IN | _CLASS_UNIQUE, service.host_ttl, address, From 98a1ce8b99ddb03de9f6cccca49396fcf177e0d0 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 21 Oct 2019 13:24:31 +0200 Subject: [PATCH 0026/1433] Rework exposing IPv6 addresses on ServiceInfo * Return backward compatibility for ServiceInfo.addresses by making it return V4 addresses only * Add ServiceInfo.parsed_addresses for convenient access to addresses * Raise TypeError if addresses are not provided as bytes (otherwise an ugly assertion error is raised when sending) * Add more IPv6 unit tests --- examples/self_test.py | 3 +++ test_zeroconf.py | 36 +++++++++++++++++++++++--- zeroconf.py | 59 +++++++++++++++++++++++++++++++++---------- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/examples/self_test.py b/examples/self_test.py index 0b231ac34..35007db13 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -19,8 +19,10 @@ print("1. Testing registration of a service...") desc = {'version': '0.10', 'a': 'test value', 'b': 'another value'} addresses = [socket.inet_aton("127.0.0.1")] + expected = {'127.0.0.1'} if socket.has_ipv6: addresses.append(socket.inet_pton(socket.AF_INET6, '::1')) + expected.add('::1') info = ServiceInfo( "_http._tcp.local.", "My Service Name._http._tcp.local.", @@ -37,6 +39,7 @@ print("3. Testing query of own service...") queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") assert queried_info + assert set(queried_info.parsed_addresses()) == expected print(" Getting self: %s" % (queried_info,)) print(" Query done.") print("4. Testing unregister of service information...") diff --git a/test_zeroconf.py b/test_zeroconf.py index a8c5a81ff..c4d5497e7 100644 --- a/test_zeroconf.py +++ b/test_zeroconf.py @@ -536,6 +536,23 @@ def test_good_service_names(self): r.service_type_name('_one_two._tcp.local.', allow_underscores=True) + def test_invalid_addresses(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + bad = ('127.0.0.1', '::1', 42) + for addr in bad: + self.assertRaisesRegex( + TypeError, + 'Addresses must be bytes', + ServiceInfo, + type_, + registration_name, + port=80, + addresses=[addr], + ) + class TestDnsIncoming(unittest.TestCase): def test_incoming_exception_handling(self): @@ -844,6 +861,9 @@ def update_service(self, zeroconf, type, name): assert info.properties[b'prop_blank'] == properties['prop_blank'] assert info.properties[b'prop_true'] is True assert info.properties[b'prop_false'] is False + assert info.addresses == addresses[:1] # no V6 by default + all_addresses = info.addresses_by_version(r.IPVersion.All) + assert all_addresses == addresses, all_addresses info = zeroconf_browser.get_service_info(subtype, registration_name) assert info is not None @@ -1039,7 +1059,8 @@ def test_multiple_addresses(): type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ desc = {'path': '/~paulsm/'} - address = socket.inet_aton("10.0.1.2") + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) # Old way info = ServiceInfo(type_, registration_name, address, 80, 0, 0, desc, "ash-2.local.") @@ -1059,6 +1080,11 @@ def test_multiple_addresses(): assert info.address is None assert info.addresses == [] + info.addresses = [address2] + + assert info.address == address2 + assert info.addresses == [address2] + # Compatibility way info = ServiceInfo(type_, registration_name, [address, address], 80, 0, 0, desc, "ash-2.local.") @@ -1072,12 +1098,16 @@ def test_multiple_addresses(): assert info.addresses == [address, address] if socket.has_ipv6: - address_v6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + address_v6_parsed = "2001:db8::1" + address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) info = ServiceInfo(type_, registration_name, [address, address_v6], 80, 0, 0, desc, "ash-2.local.") - assert info.addresses == [address, address_v6] + assert info.addresses == [address] assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] + assert info.parsed_addresses() == [address_parsed, address_v6_parsed] + assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] def test_ptr_optimization(): diff --git a/zeroconf.py b/zeroconf.py index 0a0c88da8..302b3e2f6 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -1498,15 +1498,21 @@ def __init__( self.type = type_ self.name = name if addresses is not None: - self.addresses = addresses + self._addresses = addresses elif address is not None: warnings.warn("address is deprecated, use addresses instead", DeprecationWarning) if isinstance(address, list): - self.addresses = address + self._addresses = address else: - self.addresses = [address] + self._addresses = [address] else: - self.addresses = [] + self._addresses = [] + # This results in an ugly error when registering, better check now + invalid = [a for a in self._addresses + if not isinstance(a, bytes) or len(a) not in (4, 16)] + if invalid: + raise TypeError('Addresses must be bytes, got %s. Hint: convert string addresses ' + 'with socket.inet_pton' % invalid) self.port = port self.weight = weight self.priority = priority @@ -1524,6 +1530,7 @@ def __init__( def address(self): warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) try: + # Return the first V4 address for compatibility return self.addresses[0] except IndexError: return None @@ -1532,9 +1539,27 @@ def address(self): def address(self, value): warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) if value is None: - self.addresses = [] + self._addresses = [] else: - self.addresses = [value] + self._addresses = [value] + + @property + def addresses(self): + """IPv4 addresses of this service. + + Only IPv4 addresses are returned for backward compatibility. + Use :meth:`addresses_by_version` or :meth:`parsed_addresses` to + include IPv6 addresses as well. + """ + return self.addresses_by_version(IPVersion.V4Only) + + @addresses.setter + def addresses(self, value): + """Replace the addresses list. + + This replaces all currently stored addresses, both IPv4 and IPv6. + """ + self._addresses = value @property def properties(self) -> ServicePropertiesType: @@ -1543,11 +1568,19 @@ def properties(self) -> ServicePropertiesType: def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version.""" if version == IPVersion.V4Only: - return [addr for addr in self.addresses if not _is_v6_address(addr)] + return [addr for addr in self._addresses if not _is_v6_address(addr)] elif version == IPVersion.V6Only: - return list(filter(_is_v6_address, self.addresses)) + return list(filter(_is_v6_address, self._addresses)) else: - return self.addresses + return self._addresses + + def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + """List addresses in their parsed string form.""" + result = self.addresses_by_version(version) + return [ + socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) + for addr in result + ] def _set_properties(self, properties: Union[bytes, ServicePropertiesType]): """Sets properties and text of this info from a dictionary""" @@ -1625,8 +1658,8 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: assert isinstance(record, DNSAddress) # if record.name == self.name: if record.name == self.server: - if record.address not in self.addresses: - self.addresses.append(record.address) + if record.address not in self._addresses: + self._addresses.append(record.address) elif record.type == _TYPE_SRV: assert isinstance(record, DNSService) if record.name == self.name: @@ -1660,12 +1693,12 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if cached: self.update_record(zc, now, cached) - if self.server is not None and self.text is not None and self.addresses: + if self.server is not None and self.text is not None and self._addresses: return True try: zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) - while self.server is None or self.text is None or not self.addresses: + while self.server is None or self.text is None or not self._addresses: if last <= now: return False if next_ <= now: From c86423ab0223bab682614e18a6a09050dfc80087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nepusz?= Date: Wed, 5 Jun 2019 22:30:29 +0200 Subject: [PATCH 0027/1433] ENOTCONN is not an error during shutdown When `python-zeroconf` is used in conjunction with `eventlet`, `select.select()` will return with an error code equal to `errno.ENOTCONN` instead of `errno.EBADF`. As a consequence, an exception is shown in the console during shutdown. I believe that it should not cause any harm to treat `errno.ENOTCONN` the same way as `errno.EBADF` to prevent this exception. --- zeroconf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf.py b/zeroconf.py index 302b3e2f6..f1c3a6926 100644 --- a/zeroconf.py +++ b/zeroconf.py @@ -1185,7 +1185,7 @@ def run(self): except (select.error, socket.error) as e: # If the socket was closed by another thread, during # shutdown, ignore it and exit - if e.args[0] != socket.EBADF or not self.zc.done: + if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: raise def add_reader(self, reader, socket_): From 1c33e5f5b44732d446d629cc13000cff3527afef Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 12 Nov 2019 22:31:21 +0100 Subject: [PATCH 0028/1433] Setup basic Sphinx documentation Closes #200 --- .gitignore | 1 + docs/Makefile | 177 ++++++++++++++++++++++++++++++++++ docs/api.rst | 7 ++ docs/conf.py | 253 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 31 ++++++ 5 files changed, 469 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst diff --git a/.gitignore b/.gitignore index 8b23c0e1e..ddf8a0d7e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Thumbs.db .vslick .cache .mypy_cache/ +docs/_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..a8d581c27 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/zeroconf.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/zeroconf.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/zeroconf" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/zeroconf" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 000000000..5bd2508f4 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,7 @@ +python-zeroconf API reference +============================= + +.. automodule:: zeroconf + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..b6a99cc52 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import zeroconf + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'python-zeroconf' +copyright = 'python-zeroconf authors' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = zeroconf.__version__ +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +html_sidebars = { + 'index': ('sidebar.html', 'sourcelink.html', 'searchbox.html'), + '**': ('localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'), +} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'zeroconfdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +# latex_documents = [] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +# man_pages = [] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +# texinfo_documents = [] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + + +def setup(app): + app.connect('autodoc-skip-member', skip_member) + + +def skip_member(app, what, name, obj, skip, options): + return ( + skip + or getattr(obj, '__doc__', None) is None + or getattr(obj, '__private__', False) is True + or getattr(getattr(obj, '__func__', None), '__private__', False) is True + ) diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..c4fa6143c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,31 @@ +Welcome to python-zeroconf documentation! +========================================= + +.. image:: https://travis-ci.org/jstasiak/python-zeroconf.svg?branch=master + :alt: Build status + :target: https://travis-ci.org/jstasiak/python-zeroconf + +.. image:: https://img.shields.io/pypi/v/zeroconf.svg + :target: https://pypi.python.org/pypi/zeroconf + +.. image:: https://coveralls.io/repos/github/jstasiak/python-zeroconf/badge.svg?branch=master + :alt: Covergage status + :target: https://coveralls.io/github/jstasiak/python-zeroconf?branch=master + +GitHub (code repository, issues): https://github.com/jstasiak/python-zeroconf + +PyPI (installable, stable distributions): https://pypi.org/project/zeroconf. You can install python-zeroconf using pip:: + + pip install zeroconf + +python-zeroconf works with CPython 3.5+ and PyPy 3 implementing Python 3.5+. + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + api + +See `the project's README `_ for more information. From 3db9d82d888abe880bfdd2fb2c3fe3eddcb48ae9 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 12 Nov 2019 22:44:39 +0100 Subject: [PATCH 0029/1433] Link to the documentation --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 8b4624df9..383c6c8a0 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,7 @@ python-zeroconf .. image:: https://img.shields.io/coveralls/jstasiak/python-zeroconf.svg :target: https://coveralls.io/r/jstasiak/python-zeroconf +`Documentation `_. This is fork of pyzeroconf, Multicast DNS Service Discovery for Python, originally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf), From 41b31cb338e8a8a7d1a548662db70d9014e8a352 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 18 Nov 2019 17:52:35 +0100 Subject: [PATCH 0030/1433] Add py.typed marker (closes #199) This required changing to a proper package. --- Makefile | 8 ++++---- setup.py | 5 +++-- zeroconf.py => zeroconf/__init__.py | 0 zeroconf/py.typed | 0 test_zeroconf.py => zeroconf/test.py | 0 5 files changed, 7 insertions(+), 6 deletions(-) rename zeroconf.py => zeroconf/__init__.py (100%) create mode 100644 zeroconf/py.typed rename test_zeroconf.py => zeroconf/test.py (100%) diff --git a/Makefile b/Makefile index 2ed7cccc1..ea5f8c64e 100644 --- a/Makefile +++ b/Makefile @@ -30,14 +30,14 @@ ci: test_coverage lint lint: $(LINT_TARGETS) flake8: - flake8 --max-line-length=$(MAX_LINE_LENGTH) examples *.py + flake8 --max-line-length=$(MAX_LINE_LENGTH) setup.py examples zeroconf .PHONY: black_check black_check: - black --check *.py examples + black --check setup.py examples zeroconf mypy: - mypy examples/*.py test_zeroconf.py zeroconf.py + mypy examples/*.py zeroconf/*.py test: nosetests -v $(TEST_ARGS) @@ -46,4 +46,4 @@ test_coverage: nosetests -v --with-coverage --cover-package=zeroconf $(TEST_ARGS) autopep8: - autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i examples *.py + autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/setup.py b/setup.py index b306619dc..a011e6028 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ readme = f.read() version = ( - [l for l in open(join(PROJECT_ROOT, 'zeroconf.py')) if '__version__' in l][0] + [l for l in open(join(PROJECT_ROOT, 'zeroconf', '__init__.py')) if '__version__' in l][0] .split('=')[-1] .strip() .strip('\'"') @@ -22,7 +22,8 @@ long_description=readme, author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', - py_modules=['zeroconf'], + package_data={"zeroconf": ["py.typed"]}, + packages=["zeroconf"], platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, diff --git a/zeroconf.py b/zeroconf/__init__.py similarity index 100% rename from zeroconf.py rename to zeroconf/__init__.py diff --git a/zeroconf/py.typed b/zeroconf/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/test_zeroconf.py b/zeroconf/test.py similarity index 100% rename from test_zeroconf.py rename to zeroconf/test.py From c827f9fdc4c58433143ea8815029c3387b500ff5 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 19 Nov 2019 23:14:54 +0100 Subject: [PATCH 0031/1433] Improve type hint coverage --- zeroconf/__init__.py | 69 +++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f1c3a6926..28162c8bd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -188,6 +188,9 @@ class InterfaceChoice(enum.Enum): All = 2 +InterfacesType = Union[List[Union[str, int]], InterfaceChoice] + + @enum.unique class ServiceStateChange(enum.Enum): Added = 1 @@ -1155,7 +1158,7 @@ class Engine(threading.Thread): packets. """ - def __init__(self, zc): + def __init__(self, zc: 'Zeroconf') -> None: threading.Thread.__init__(self, name='zeroconf-Engine') self.daemon = True self.zc = zc @@ -1164,7 +1167,7 @@ def __init__(self, zc): self.condition = threading.Condition() self.start() - def run(self): + def run(self) -> None: while not self.zc.done: with self.condition: rs = self.readers.keys() @@ -1188,12 +1191,12 @@ def run(self): if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: raise - def add_reader(self, reader, socket_): + def add_reader(self, reader: 'Listener', socket_: socket.socket) -> None: with self.condition: self.readers[socket_] = reader self.condition.notify() - def del_reader(self, socket_): + def del_reader(self, socket_: socket.socket) -> None: with self.condition: del self.readers[socket_] self.condition.notify() @@ -1582,7 +1585,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: for addr in result ] - def _set_properties(self, properties: Union[bytes, ServicePropertiesType]): + def _set_properties(self, properties: Union[bytes, ServicePropertiesType]) -> None: """Sets properties and text of this info from a dictionary""" if isinstance(properties, dict): self._properties = properties @@ -1612,7 +1615,7 @@ def _set_properties(self, properties: Union[bytes, ServicePropertiesType]): else: self.text = properties - def _set_text(self, text): + def _set_text(self, text: bytes) -> None: """Sets properties and text given a text field""" self.text = text result = {} # type: ServicePropertiesType @@ -1628,7 +1631,7 @@ def _set_text(self, text): for s in strs: parts = s.split(b'=', 1) try: - key, value = parts + key, value = parts # type: Tuple[bytes, Union[bool, bytes]] except ValueError: # No equals sign at all key = s @@ -1645,7 +1648,7 @@ def _set_text(self, text): self._properties = result - def get_name(self): + def get_name(self) -> str: """Name accessor""" if self.type is not None and self.name.endswith("." + self.type): return self.name[: len(self.name) - len(self.type) - 1] @@ -1760,17 +1763,23 @@ class ZeroconfServiceTypes(ServiceListener): Return all of the advertised services on any local networks """ - def __init__(self): + def __init__(self) -> None: self.found_services = set() # type: Set[str] - def add_service(self, zc, type_, name): + def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: self.found_services.add(name) - def remove_service(self, zc, type_, name): + def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: pass @classmethod - def find(cls, zc=None, timeout=5, interfaces=InterfaceChoice.All, ip_version=None): + def find( + cls, + zc: Optional['Zeroconf'] = None, + timeout: Union[int, float] = 5, + interfaces: InterfacesType = InterfaceChoice.All, + ip_version: Optional[IPVersion] = None, + ) -> Tuple[str, ...]: """ Return all of the advertised services on any local networks. @@ -1860,7 +1869,7 @@ def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: def normalize_interface_choice( - choice: Union[List[Union[str, int]], InterfaceChoice], ip_version: IPVersion = IPVersion.V4Only + choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only ) -> List[Union[str, int]]: """Convert the interfaces choice into internal representation. @@ -1946,17 +1955,17 @@ def new_socket(port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only) return s -def add_multicast_member(listen_socket, interface): +def add_multicast_member(listen_socket: socket.socket, interface: Union[str, int]) -> Optional[socket.socket]: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, int) log.debug('Adding %r to multicast group', interface) try: if is_v6: - iface_bin = struct.pack('@I', interface) + iface_bin = struct.pack('@I', cast(int, interface)) _value = _MDNS_ADDR6_BYTES + iface_bin listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) else: - _value = _MDNS_ADDR_BYTES + socket.inet_aton(interface) + _value = _MDNS_ADDR_BYTES + socket.inet_aton(cast(str, interface)) listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) except socket.error as e: _errno = get_errno(e) @@ -1966,17 +1975,17 @@ def add_multicast_member(listen_socket, interface): 'it is expected to happen on some systems', interface, ) - return + return None elif _errno == errno.EADDRNOTAVAIL: log.info( 'Address not available when adding %s to multicast ' 'group, it is expected to happen on some systems', interface, ) - return + return None elif _errno == errno.EINVAL: log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) - return + return None else: raise @@ -1985,15 +1994,17 @@ def add_multicast_member(listen_socket, interface): if is_v6: respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) else: - respond_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface)) + respond_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface)) + ) return respond_socket def create_sockets( - interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, + interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, -): +) -> Tuple[Optional[socket.socket], List[socket.socket]]: if unicast: listen_socket = None else: @@ -2005,7 +2016,7 @@ def create_sockets( for i in interfaces: if not unicast: - respond_socket = add_multicast_member(listen_socket, i) + respond_socket = add_multicast_member(cast(socket.socket, listen_socket), i) else: respond_socket = new_socket(port=0, ip_version=ip_version) @@ -2020,9 +2031,9 @@ def get_errno(e: Exception) -> int: return cast(int, e.args[0]) -def can_send_to(sock, address: str): +def can_send_to(sock: socket.socket, address: str) -> bool: addr = ipaddress.ip_address(address) - return addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4 + return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) class Zeroconf(QuietLogger): @@ -2034,7 +2045,7 @@ class Zeroconf(QuietLogger): def __init__( self, - interfaces: Union[List[Union[str, int]], InterfaceChoice] = InterfaceChoice.All, + interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: Optional[IPVersion] = None, ) -> None: @@ -2085,7 +2096,7 @@ def __init__( self.engine = Engine(self) self.listener = Listener(self) if not unicast: - self.engine.add_reader(self.listener, self._listen_socket) + self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) else: for s in self._respond_sockets: self.engine.add_reader(self.listener, s) @@ -2544,8 +2555,8 @@ def close(self) -> None: # shutdown recv socket and thread if not self.unicast: - self.engine.del_reader(self._listen_socket) - self._listen_socket.close() + self.engine.del_reader(cast(socket.socket, self._listen_socket)) + cast(socket.socket, self._listen_socket).close() else: for s in self._respond_sockets: self.engine.del_reader(s) From f03dc42d6234419053bda18ca6f2b90bec1b9257 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 19 Nov 2019 23:54:17 +0100 Subject: [PATCH 0032/1433] Release version 0.24.0 --- README.rst | 9 +++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 383c6c8a0..d7778a1d5 100644 --- a/README.rst +++ b/README.rst @@ -133,6 +133,15 @@ See examples directory for more. Changelog ========= +0.24.0 +------ + +* Added IPv6 support, thanks to Dmitry Tantsur +* Added additional recommended records to PTR responses, thanks to Scott Mertz +* Added handling of ENOTCONN being raised during shutdown when using Eventlet, thanks to Tamás Nepusz +* Included the py.typed marker in the package so that type checkers know to use type hints from the + source code, thanks to Dmitry Tantsur + 0.23.0 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 28162c8bd..3601d5e9c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -41,7 +41,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.23.0' +__version__ = '0.24.0' __license__ = 'LGPL' From 6ab7dbf27a2086e20f4486e693e2091d043af1db Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 20 Nov 2019 09:56:56 +0100 Subject: [PATCH 0033/1433] The the formatting of the IPv6 section in the readme --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index d7778a1d5..7220d06e1 100644 --- a/README.rst +++ b/README.rst @@ -66,6 +66,7 @@ IPv6 support ------------ IPv6 support is relatively new and currently limited, specifically: + * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX systems. * On Windows specific interfaces can only be requested as interface indexes, From 157fc2003318d785d07b362e1fd2ba3fe5d373f0 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 12 Dec 2019 17:34:15 +0100 Subject: [PATCH 0034/1433] Significantly improve the speed of the entries function of the cache Tested this with Python 3.6.8, Fedora 28. This was done in a network with a lot of discoverable devices. before: Total time: 1.43086 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 1138 @profile 1139 def entries(self): 1140 """Returns a list of all entries""" 1141 2063 3578.0 1.7 0.3 if not self.cache: 1142 2 3.0 1.5 0.0 return [] 1143 else: 1144 # avoid size change during iteration by copying the cache 1145 2061 22051.0 10.7 1.5 values = list(self.cache.values()) 1146 2061 1405227.0 681.8 98.2 return reduce(lambda a, b: a + b, values) After: Total time: 0.43725 s Line # Hits Time Per Hit % Time Line Contents ============================================================== 1138 @profile 1139 def entries(self): 1140 """Returns a list of all entries""" 1141 3651 10171.0 2.8 2.3 if not self.cache: 1142 2 7.0 3.5 0.0 return [] 1143 else: 1144 # avoid size change during iteration by copying the cache 1145 3649 67054.0 18.4 15.3 values = list(self.cache.values()) 1146 3649 360018.0 98.7 82.3 return list(itertools.chain.from_iterable(values)) --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3601d5e9c..a3ceb1b11 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,6 +23,7 @@ import enum import errno import ipaddress +import itertools import logging import os import re @@ -33,7 +34,6 @@ import threading import time import warnings -from functools import reduce from typing import AnyStr, Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints @@ -1142,7 +1142,7 @@ def entries(self): else: # avoid size change during iteration by copying the cache values = list(self.cache.values()) - return reduce(lambda a, b: a + b, values) + return list(itertools.chain.from_iterable(values)) class Engine(threading.Thread): From 2e9699c542f691fc605e4a1c03cbf496273a9835 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 12 Dec 2019 17:41:40 +0100 Subject: [PATCH 0035/1433] Dont recalculate the expiration and stale time every update I have a network with 170 devices running Zeroconf. Every minute a zeroconf request for broadcast is cast out. Then we were listening for Zeroconf devices on that network. To get a more realistic test, the Zeroconf ServiceBrowser is ran on a separate thread from a main thread. On the main thread an I/O limited call to QNetworkManager is made every 2 seconds, in order to include performance penalties due to thread switching. The experiment was ran on a MacBook Air 2015 (Intel Core i7-5650U) through Ubuntu 19.10 and Python 3.7. This was left running for exactly one hour, both before and after this commit. Before this commit, there were 132107499 (132M) calls to the get_expiration_time function, totalling 141.647s (just over 2 minutes). After this commit, there were 1661203 (1.6M) calls to the get_expiration_time function, totalling 2.068s. This saved about 2 minutes of processing time out of the total 60 minutes, on average 3.88% processing power on the tested CPU. It is expected to see similar improvements on all CPU architectures. --- zeroconf/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a3ceb1b11..9020121e6 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -453,6 +453,8 @@ def __init__(self, name, type_, class_, ttl: float): DNSEntry.__init__(self, name, type_, class_) self.ttl = ttl self.created = current_time_millis() + self._expiration_time = self.get_expiration_time(100) + self._stale_time = self.get_expiration_time(50) def __eq__(self, other): """Abstract method""" @@ -482,15 +484,15 @@ def get_expiration_time(self, percent: float) -> float: def get_remaining_ttl(self, now): """Returns the remaining TTL in seconds.""" - return max(0, (self.get_expiration_time(100) - now) / 1000.0) + return max(0, (self._expiration_time - now) / 1000.0) def is_expired(self, now: float) -> bool: """Returns true if this record has expired.""" - return self.get_expiration_time(100) <= now + return self._expiration_time <= now def is_stale(self, now): """Returns true if this record is at least half way expired.""" - return self.get_expiration_time(50) <= now + return self._stale_time <= now def reset_ttl(self, other): """Sets this record's TTL and created time to that of From 815ac77e9146c37afd7c5389ed45adee9f1e2e36 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Thu, 12 Dec 2019 18:19:18 +0100 Subject: [PATCH 0036/1433] Change order of equality check to favor cheaper checks first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing two strings is much cheaper than isinstance, so we should try those first A performance test was run on a network with 170 devices running Zeroconf. There was a ServiceBrowser running on a separate thread while a timer ran on the main thread that forced a thread switch every 2 seconds (to include the effect of thread switching in the measurements). Every minute, a Zeroconf broadcast was made on the network. This was ran this for an hour on a Macbook Air from 2015 (Intel Core i7-5650U) using Ubuntu 19.10 and Python 3.7, both before this commit and after. These are the results of the performance tests: Function Before count Before time Before time per count After count After time After time per count Time reduction DNSEntry.__eq__ 528 0.001s 1.9μs 538 0.001s 1.9μs 1.9% DNSPointer.__eq__ 24369256 (24.3M) 134.641s 5.5μs 25989573 (26.0M) 86.405s 3.3μs 39.8% DNSText.__eq__ 52966716 (53.0M) 190.640s 3.6μs 53604915 (53.6M) 169.104s 3.2μs 12.4% DNSService.__eq__ 52620538 (52.6M) 171.660s 3.3μs 56557448 (56.6M) 170.222s 3.0μs 7.8% --- zeroconf/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9020121e6..0b866b13c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -390,10 +390,10 @@ def __init__(self, name: str, type_: int, class_): def __eq__(self, other): """Equality test on name, type, and class""" return ( - isinstance(other, DNSEntry) - and self.name == other.name + self.name == other.name and self.type == other.type and self.class_ == other.class_ + and isinstance(other, DNSEntry) ) def __ne__(self, other): @@ -592,7 +592,7 @@ def write(self, out): def __eq__(self, other): """Tests equality on alias""" - return isinstance(other, DNSPointer) and DNSEntry.__eq__(self, other) and self.alias == other.alias + return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) def __ne__(self, other): """Non-equality test""" @@ -618,7 +618,7 @@ def write(self, out): def __eq__(self, other): """Tests equality on text""" - return isinstance(other, DNSText) and DNSEntry.__eq__(self, other) and self.text == other.text + return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) def __ne__(self, other): """Non-equality test""" @@ -654,11 +654,11 @@ def __eq__(self, other): """Tests equality on priority, weight, port and server""" return ( isinstance(other, DNSService) - and DNSEntry.__eq__(self, other) and self.priority == other.priority and self.weight == other.weight and self.port == other.port and self.server == other.server + and DNSEntry.__eq__(self, other) ) def __ne__(self, other): From 1d39b3edd141093f9e579ab83377fe8f5ecb357d Mon Sep 17 00:00:00 2001 From: humingchun Date: Sat, 14 Dec 2019 23:42:29 +0800 Subject: [PATCH 0037/1433] Bugfix: Flush outdated cache entries when incoming record is unique. According to RFC 6762 10.2. Announcements to Flush Outdated Cache Entries, when the incoming record's cache-flush bit is set (record.unique == True in this module), "Instead of merging this new record additively into the cache in addition to any previous records with the same name, rrtype, and rrclass, all old records with that name, rrtype, and rrclass that were received more than one second ago are declared invalid, and marked to expire from the cache in one second." --- zeroconf/__init__.py | 25 +++++++------- zeroconf/test.py | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0b866b13c..6c0ae690f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2358,22 +2358,23 @@ def handle_response(self, msg: DNSIncoming) -> None: are held in the cache, and listeners are notified.""" now = current_time_millis() for record in msg.answers: + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + for entry in self.cache.entries(): + if DNSEntry.__eq__(entry, record) and (record.created - entry.created > 1000): + self.cache.remove(entry) + expired = record.is_expired(now) - if record in self.cache.entries(): - if expired: - self.cache.remove(record) + entry = self.cache.get(record) + if not expired: + if entry is not None: + entry.reset_ttl(record) else: - entry = self.cache.get(record) - if entry is not None: - entry.reset_ttl(record) + self.cache.add(record) + self.update_record(now, record) else: - self.cache.add(record) - if record.type == _TYPE_TXT: + if entry is not None: self.update_record(now, record) - - for record in msg.answers: - if record.type != _TYPE_TXT: - self.update_record(now, record) + self.cache.remove(entry) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if diff --git a/zeroconf/test.py b/zeroconf/test.py index c4d5497e7..ec1d6a477 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -457,6 +457,85 @@ def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) rv.close() + def test_handle_response(self): + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + ttl = 120 + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + if service_state_change == r.ServiceStateChange.Updated: + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + return r.DNSIncoming(generated.packet()) + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_name), 0 + ) + generated.add_answer_at_time( + r.DNSService( + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + + return r.DNSIncoming(generated.packet()) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-2.local.' + service_text = b'path=/~paulsm/' + service_address = '10.0.1.2' + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + + try: + # service added + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert dns_text.text == service_text # service_text is b'path=/~paulsm/' + + # https://tools.ietf.org/html/rfc6762#section-10.2 + # Instead of merging this new record additively into the cache in addition + # to any previous records with the same name, rrtype, and rrclass, + # all old records with that name, rrtype, and rrclass that were received + # more than one second ago are declared invalid, + # and marked to expire from the cache in one second. + time.sleep(1.1) + + # service updated. currently only text record can be updated + service_text = b'path=/~humingchun/' + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert dns_text.text == service_text # service_text is b'path=/~humingchun/' + + time.sleep(1.1) + + # service removed + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is None + + finally: + zeroconf.close() + class Exceptions(unittest.TestCase): From 2a597ee80906a27effd442d033de10b5129e6900 Mon Sep 17 00:00:00 2001 From: humingchun Date: Sat, 14 Dec 2019 23:58:29 +0800 Subject: [PATCH 0038/1433] Bugfix: TXT record's name is never equal to Service Browser's type. TXT record's name is never equal to Service Browser's type. We should check whether TXT record's name ends with Service Browser's type. Otherwise, we never get updates of TXT records. --- zeroconf/__init__.py | 2 +- zeroconf/test.py | 111 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6c0ae690f..719b7af56 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1414,7 +1414,7 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: if expires < self.next_time: self.next_time = expires - elif record.type == _TYPE_TXT and record.name == self.type: + elif record.type == _TYPE_TXT and record.name.endswith(self.type): assert isinstance(record, DNSText) expired = record.is_expired(now) if not expired: diff --git a/zeroconf/test.py b/zeroconf/test.py index ec1d6a477..deab7ffbb 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -886,6 +886,9 @@ def add_service(self, zeroconf, type, name): def remove_service(self, zeroconf, type, name): service_removed.set() + def update_service(self, zeroconf, type, name): + pass + class MySubListener(r.ServiceListener): def add_service(self, zeroconf, type, name): pass @@ -975,6 +978,114 @@ def update_service(self, zeroconf, type, name): zeroconf_browser.close() +class TestServiceBrowser(unittest.TestCase): + def test_update_record(self): + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-2.local.' + service_text = b'path=/~paulsm/' + service_address = '10.0.1.2' + + service_added = False + service_removed = False + service_updated_count = 0 + service_add_event = Event() + service_removed_event = Event() + service_updated_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added + service_added = True + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_added, service_removed + service_added = False + service_removed = True + service_removed_event.set() + + def update_service(self, zc, type_, name) -> None: + nonlocal service_updated_count + service_updated_count += 1 + + service_info = zc.get_service_info(type_, name) + assert service_info.text == service_text + service_updated_event.set() + + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + ttl = 120 + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + if service_state_change == r.ServiceStateChange.Updated: + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + return r.DNSIncoming(generated.packet()) + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_name), 0 + ) + generated.add_answer_at_time( + r.DNSService( + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + + return r.DNSIncoming(generated.packet()) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) + + try: + # service added + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) + service_add_event.wait(1) + service_updated_event.wait(1) + assert service_added is True + assert service_updated_count == 1 + assert service_removed is False + + # service updated. currently only text record can be updated + service_updated_event.clear() + service_text = b'path=/~humingchun/' + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(1) + assert service_added is True + assert service_updated_count == 2 + assert service_removed is False + + # service removed + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) + service_removed_event.wait(1) + assert service_added is False + assert service_updated_count == 2 + assert service_removed is True + + finally: + service_browser.cancel() + zeroconf.remove_all_service_listeners() + zeroconf.close() + + def test_backoff(): got_query = Event() From 53dd06c37f6205129e81f5c6b69e508a54f94d07 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 16 Dec 2019 17:35:03 +0100 Subject: [PATCH 0039/1433] Release version 0.24.1 --- README.rst | 8 ++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 7220d06e1..97a33b978 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,14 @@ See examples directory for more. Changelog ========= +0.24.1 +------ + +* Applied some significant performance optimizations, thanks to Jaime van Kessel for the patch and + to Ghostkeeper for performance measurements +* Fixed flushing outdated cache entries when incoming record is unique, thanks to Michael Hu +* Fixed handling updates of TXT records (they'd not get recorded previously), thanks to Michael Hu + 0.24.0 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 719b7af56..e51843a2b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -41,7 +41,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.0' +__version__ = '0.24.1' __license__ = 'LGPL' From fcafdc1e285cc5c3c1f2c413ac9309d3426179f4 Mon Sep 17 00:00:00 2001 From: Milan Stute Date: Mon, 16 Dec 2019 23:49:30 +0100 Subject: [PATCH 0040/1433] Add support for AWDL interface on macOS The API is inspired by Apple's NetService.includesPeerToPeer (see https://developer.apple.com/documentation/foundation/netservice/1414086-includespeertopeer) --- zeroconf/__init__.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e51843a2b..395cf30a7 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -26,6 +26,7 @@ import itertools import logging import os +import platform import re import select import socket @@ -1905,7 +1906,9 @@ def normalize_interface_choice( return result -def new_socket(port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only) -> socket.socket: +def new_socket( + port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False +) -> socket.socket: if ip_version == IPVersion.V4Only: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) else: @@ -1953,11 +1956,18 @@ def new_socket(port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + if apple_p2p: + # SO_RECV_ANYIF = 0x1104 + # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h + s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + s.bind(('', port)) return s -def add_multicast_member(listen_socket: socket.socket, interface: Union[str, int]) -> Optional[socket.socket]: +def add_multicast_member( + listen_socket: socket.socket, interface: Union[str, int], apple_p2p: bool = False +) -> Optional[socket.socket]: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, int) log.debug('Adding %r to multicast group', interface) @@ -1991,7 +2001,9 @@ def add_multicast_member(listen_socket: socket.socket, interface: Union[str, int else: raise - respond_socket = new_socket(ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only)) + respond_socket = new_socket( + ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p + ) log.debug('Configuring %s with multicast interface %s', respond_socket, interface) if is_v6: respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) @@ -2006,11 +2018,12 @@ def create_sockets( interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, + apple_p2p: bool = False, ) -> Tuple[Optional[socket.socket], List[socket.socket]]: if unicast: listen_socket = None else: - listen_socket = new_socket(ip_version=ip_version) + listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p) interfaces = normalize_interface_choice(interfaces, ip_version) @@ -2018,9 +2031,9 @@ def create_sockets( for i in interfaces: if not unicast: - respond_socket = add_multicast_member(cast(socket.socket, listen_socket), i) + respond_socket = add_multicast_member(cast(socket.socket, listen_socket), i, apple_p2p=apple_p2p) else: - respond_socket = new_socket(port=0, ip_version=ip_version) + respond_socket = new_socket(port=0, ip_version=ip_version, apple_p2p=apple_p2p) if respond_socket is not None: respond_sockets.append(respond_socket) @@ -2050,6 +2063,7 @@ def __init__( interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: Optional[IPVersion] = None, + apple_p2p: bool = False, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -2065,6 +2079,7 @@ def __init__( Also listening on loopback (``::1``) doesn't work, use a real address. :param ip_version: IP versions to support. If `choice` is a list, the default is detected from it. Otherwise defaults to V4 only for backward compatibility. + :param apple_p2p: use AWDL interface (only macOS) """ if ip_version is None and isinstance(interfaces, list): has_v6 = any( @@ -2084,7 +2099,12 @@ def __init__( self._GLOBAL_DONE = False self.unicast = unicast - self._listen_socket, self._respond_sockets = create_sockets(interfaces, unicast, ip_version) + if apple_p2p and not platform.system() == 'Darwin': + raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') + + self._listen_socket, self._respond_sockets = create_sockets( + interfaces, unicast, ip_version, apple_p2p=apple_p2p + ) self.listeners = [] # type: List[RecordUpdateListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] From 5986bf66e77e77f9e0b6ba43a4758ecb0da04ff6 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 17 Dec 2019 13:17:26 +0100 Subject: [PATCH 0041/1433] Fix get_expiration_time percent parameter annotation It takes integer percentage values at the moment so let's document that. --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 395cf30a7..98cb8e6dd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -478,7 +478,7 @@ def suppressed_by_answer(self, other): and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) - def get_expiration_time(self, percent: float) -> float: + def get_expiration_time(self, percent: int) -> float: """Returns the time at which this record will have expired by a certain percentage.""" return self.created + (percent * self.ttl * 10) From f7715874c2242b95cf9815549344ea66ac107b6e Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 17 Dec 2019 14:16:56 +0100 Subject: [PATCH 0042/1433] Provide and enforce type hints everywhere except for tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests' time will come too in the future, though, I think. I believe nose has problems with running annotated tests right now so let's leave it for later. DNSEntry.to_string renamed to entry_to_string because DNSRecord subclasses DNSEntry and overrides to_string with a different signature, so just to be explicit and obvious here I renamed it – I don't think any client code will break because of this. I got rid of ServicePropertiesType in favor of generic Dict because having to type all the properties got annoying when variance got involved – maybe it'll be restored in the future but it seems like too much hassle now. --- setup.cfg | 3 + zeroconf/__init__.py | 279 ++++++++++++++++++++++++------------------- zeroconf/test.py | 16 +-- 3 files changed, 164 insertions(+), 134 deletions(-) diff --git a/setup.cfg b/setup.cfg index dd4764ed9..5610cf680 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,4 +17,7 @@ warn_unused_ignores = true warn_return_any = true # TODO: disallow untyped calls and defs once we have full type hint coverage disallow_untyped_calls = false +disallow_untyped_defs = true + +[mypy-zeroconf.test] disallow_untyped_defs = false diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 98cb8e6dd..8c98ae6f9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,7 +35,7 @@ import threading import time import warnings -from typing import AnyStr, Dict, List, Optional, Sequence, Union, cast +from typing import Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -214,11 +214,11 @@ def current_time_millis() -> float: return time.time() * 1000 -def _is_v6_address(addr): +def _is_v6_address(addr: bytes) -> bool: return len(addr) == 16 -def service_type_name(type_, *, allow_underscores: bool = False): +def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -352,7 +352,7 @@ class QuietLogger: _seen_logs = {} # type: Dict[str, Union[int, tuple]] @classmethod - def log_exception_warning(cls, logger_data=None): + def log_exception_warning(cls, logger_data: Optional[Tuple] = None) -> None: exc_info = sys.exc_info() exc_str = str(exc_info[1]) if exc_str not in cls._seen_logs: @@ -366,7 +366,7 @@ def log_exception_warning(cls, logger_data=None): logger('Exception occurred:', exc_info=True) @classmethod - def log_warning_once(cls, *args): + def log_warning_once(cls, *args: Any) -> None: msg_str = args[0] if msg_str not in cls._seen_logs: cls._seen_logs[msg_str] = 0 @@ -381,14 +381,14 @@ class DNSEntry: """A DNS entry""" - def __init__(self, name: str, type_: int, class_): + def __init__(self, name: str, type_: int, class_: int) -> None: self.key = name.lower() self.name = name self.type = type_ self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Equality test on name, type, and class""" return ( self.name == other.name @@ -397,21 +397,21 @@ def __eq__(self, other): and isinstance(other, DNSEntry) ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) @staticmethod - def get_class_(class_): + def get_class_(class_: int) -> str: """Class accessor""" return _CLASSES.get(class_, "?(%s)" % class_) @staticmethod - def get_type(t): + def get_type(t: int) -> str: """Type accessor""" return _TYPES.get(t, "?(%s)" % t) - def to_string(self, hdr, other) -> str: + def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: """String representation with additional information""" result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) if self.unique: @@ -420,7 +420,7 @@ def to_string(self, hdr, other) -> str: result += "," result += self.name if other is not None: - result += ",%s]" % other + result += ",%s]" % cast(Any, other) else: result += "]" return result @@ -443,29 +443,30 @@ def answered_by(self, rec: 'DNSRecord') -> bool: def __repr__(self) -> str: """String representation""" - return DNSEntry.to_string(self, "question", None) + return DNSEntry.entry_to_string(self, "question", None) class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" - def __init__(self, name, type_, class_, ttl: float): + # TODO: Switch to just int ttl + def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) -> None: DNSEntry.__init__(self, name, type_, class_) self.ttl = ttl self.created = current_time_millis() self._expiration_time = self.get_expiration_time(100) self._stale_time = self.get_expiration_time(50) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Abstract method""" raise AbstractMethodException - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def suppressed_by(self, msg): + def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" for record in msg.answers: @@ -473,7 +474,7 @@ def suppressed_by(self, msg): return True return False - def suppressed_by_answer(self, other): + def suppressed_by_answer(self, other: 'DNSRecord') -> bool: """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) @@ -483,7 +484,8 @@ def get_expiration_time(self, percent: int) -> float: by a certain percentage.""" return self.created + (percent * self.ttl * 10) - def get_remaining_ttl(self, now): + # TODO: Switch to just int here + def get_remaining_ttl(self, now: float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" return max(0, (self._expiration_time - now) / 1000.0) @@ -491,49 +493,49 @@ def is_expired(self, now: float) -> bool: """Returns true if this record has expired.""" return self._expiration_time <= now - def is_stale(self, now): + def is_stale(self, now: float) -> bool: """Returns true if this record is at least half way expired.""" return self._stale_time <= now - def reset_ttl(self, other): + def reset_ttl(self, other: 'DNSRecord') -> None: """Sets this record's TTL and created time to that of another record.""" self.created = other.created self.ttl = other.ttl - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Abstract method""" raise AbstractMethodException - def to_string(self, other): + def to_string(self, other: Union[bytes, str]) -> str: """String representation with additional information""" - arg = "%s/%s,%s" % (self.ttl, self.get_remaining_ttl(current_time_millis()), other) - return DNSEntry.to_string(self, "record", arg) + arg = "%s/%s,%s" % (self.ttl, self.get_remaining_ttl(current_time_millis()), cast(Any, other)) + return DNSEntry.entry_to_string(self, "record", arg) class DNSAddress(DNSRecord): """A DNS address record""" - def __init__(self, name, type_, class_, ttl, address): + def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None: DNSRecord.__init__(self, name, type_, class_, ttl) self.address = address - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_string(self.address) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Tests equality on address""" return ( isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def __repr__(self): + def __repr__(self) -> str: """String representation""" try: return str(socket.inet_ntoa(self.address)) @@ -545,23 +547,25 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" - def __init__(self, name, type_, class_, ttl, cpu, os): + def __init__( + self, name: str, type_: int, class_: int, ttl: int, cpu: Union[bytes, str], os: Union[bytes, str] + ) -> None: DNSRecord.__init__(self, name, type_, class_, ttl) try: - self.cpu = cpu.decode('utf-8') + self.cpu = cast(bytes, cpu).decode('utf-8') except AttributeError: - self.cpu = cpu + self.cpu = cast(str, cpu) try: - self.os = os.decode('utf-8') + self.os = cast(bytes, os).decode('utf-8') except AttributeError: - self.os = os + self.os = cast(str, os) - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_character_string(self.cpu.encode('utf-8')) out.write_character_string(self.os.encode('utf-8')) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" return ( isinstance(other, DNSHinfo) @@ -570,11 +574,11 @@ def __eq__(self, other): and self.os == other.os ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def __repr__(self): + def __repr__(self) -> str: """String representation""" return self.cpu + " " + self.os @@ -583,23 +587,23 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" - def __init__(self, name, type_, class_, ttl, alias): + def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> None: DNSRecord.__init__(self, name, type_, class_, ttl) self.alias = alias - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_name(self.alias) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Tests equality on alias""" return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def __repr__(self): + def __repr__(self) -> str: """String representation""" return self.to_string(self.alias) @@ -608,24 +612,24 @@ class DNSText(DNSRecord): """A DNS text record""" - def __init__(self, name: str, type_: Optional[int], class_: int, ttl: int, text: bytes) -> None: + def __init__(self, name: str, type_: int, class_: int, ttl: int, text: bytes) -> None: assert isinstance(text, (bytes, type(None))) DNSRecord.__init__(self, name, type_, class_, ttl) self.text = text - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_string(self.text) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Tests equality on text""" return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def __repr__(self): + def __repr__(self) -> str: """String representation""" if len(self.text) > 10: return self.to_string(self.text[:7]) + "..." @@ -637,21 +641,31 @@ class DNSService(DNSRecord): """A DNS service record""" - def __init__(self, name, type_, class_, ttl, priority, weight, port, server): + def __init__( + self, + name: str, + type_: int, + class_: int, + ttl: Union[float, int], + priority: int, + weight: int, + port: int, + server: str, + ) -> None: DNSRecord.__init__(self, name, type_, class_, ttl) self.priority = priority self.weight = weight self.port = port self.server = server - def write(self, out): + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_short(self.priority) out.write_short(self.weight) out.write_short(self.port) out.write_name(self.server) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Tests equality on priority, weight, port and server""" return ( isinstance(other, DNSService) @@ -662,11 +676,11 @@ def __eq__(self, other): and DNSEntry.__eq__(self, other) ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: """Non-equality test""" return not self.__eq__(other) - def __repr__(self): + def __repr__(self) -> str: """String representation""" return self.to_string("%s:%s" % (self.server, self.port)) @@ -734,7 +748,7 @@ def read_character_string(self) -> bytes: self.offset += 1 return self.read_string(length) - def read_string(self, length) -> bytes: + def read_string(self, length: int) -> bytes: """Reads a string of a given length from the packet""" info = self.data[self.offset : self.offset + length] self.offset += length @@ -835,7 +849,7 @@ class DNSOutgoing: """Object representation of an outgoing packet""" - def __init__(self, flags, multicast=True): + def __init__(self, flags: int, multicast: bool = True) -> None: self.finished = False self.id = 0 self.multicast = multicast @@ -846,11 +860,11 @@ def __init__(self, flags, multicast=True): self.state = self.State.init self.questions = [] # type: List[DNSQuestion] - self.answers = [] # type: List[Tuple[DNSEntry, float]] + self.answers = [] # type: List[Tuple[DNSRecord, float]] self.authorities = [] # type: List[DNSPointer] - self.additionals = [] # type: List[DNSAddress] + self.additionals = [] # type: List[DNSRecord] - def __repr__(self): + def __repr__(self) -> str: return '' % ', '.join( [ 'multicast=%s' % self.multicast, @@ -866,26 +880,26 @@ class State(enum.Enum): init = 0 finished = 1 - def add_question(self, record): + def add_question(self, record: DNSQuestion) -> None: """Adds a question""" self.questions.append(record) - def add_answer(self, inp, record): + def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: """Adds an answer""" if not record.suppressed_by(inp): self.add_answer_at_time(record, 0) - def add_answer_at_time(self, record, now): + def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" if record is not None: if now == 0 or not record.is_expired(now): self.answers.append((record, now)) - def add_authorative_answer(self, record): + def add_authorative_answer(self, record: DNSPointer) -> None: """Adds an authoritative answer""" self.authorities.append(record) - def add_additional_answer(self, record): + def add_additional_answer(self, record: DNSRecord) -> None: """ Adds an additional answer From: RFC 6763, DNS-Based Service Discovery, February 2013 @@ -923,34 +937,34 @@ def add_additional_answer(self, record): """ self.additionals.append(record) - def pack(self, format_, value): + def pack(self, format_: Union[bytes, str], value: Any) -> None: self.data.append(struct.pack(format_, value)) self.size += struct.calcsize(format_) - def write_byte(self, value): + def write_byte(self, value: int) -> None: """Writes a single byte to the packet""" self.pack(b'!c', int2byte(value)) - def insert_short(self, index, value): + def insert_short(self, index: int, value: int) -> None: """Inserts an unsigned short in a certain position in the packet""" self.data.insert(index, struct.pack(b'!H', value)) self.size += 2 - def write_short(self, value): + def write_short(self, value: int) -> None: """Writes an unsigned short to the packet""" self.pack(b'!H', value) - def write_int(self, value): + def write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" self.pack(b'!I', int(value)) - def write_string(self, value): + def write_string(self, value: bytes) -> None: """Writes a string to the packet""" assert isinstance(value, bytes) self.data.append(value) self.size += len(value) - def write_utf(self, s): + def write_utf(self, s: str) -> None: """Writes a UTF-8 string of a given length to the packet""" utfstr = s.encode('utf-8') length = len(utfstr) @@ -959,7 +973,7 @@ def write_utf(self, s): self.write_byte(length) self.write_string(utfstr) - def write_character_string(self, value): + def write_character_string(self, value: bytes) -> None: assert isinstance(value, bytes) length = len(value) if length > 256: @@ -967,7 +981,7 @@ def write_character_string(self, value): self.write_byte(length) self.write_string(value) - def write_name(self, name): + def write_name(self, name: str) -> None: """ Write names to packet @@ -1014,13 +1028,13 @@ def write_name(self, name): # this is the end of a name self.write_byte(0) - def write_question(self, question): + def write_question(self, question: DNSQuestion) -> None: """Writes a question to the packet""" self.write_name(question.name) self.write_short(question.type) self.write_short(question.class_) - def write_record(self, record, now): + def write_record(self, record: DNSRecord, now: float) -> int: """Writes a record (answer, authoritative answer, additional) to the packet""" if self.state == self.State.finished: @@ -1092,15 +1106,15 @@ class DNSCache: """A cache of DNS entries""" - def __init__(self): - self.cache = {} # type: Dict[str, List[DNSEntry]] + def __init__(self) -> None: + self.cache = {} # type: Dict[str, List[DNSRecord]] - def add(self, entry): + def add(self, entry: DNSRecord) -> None: """Adds an entry""" # Insert first in list so get returns newest entry self.cache.setdefault(entry.key, []).insert(0, entry) - def remove(self, entry): + def remove(self, entry: DNSRecord) -> None: """Removes an entry""" try: list_ = self.cache[entry.key] @@ -1108,7 +1122,7 @@ def remove(self, entry): except (KeyError, ValueError): pass - def get(self, entry): + def get(self, entry: DNSEntry) -> Optional[DNSRecord]: """Gets an entry by key. Will return None if there is no matching entry.""" try: @@ -1116,29 +1130,35 @@ def get(self, entry): for cached_entry in list_: if entry.__eq__(cached_entry): return cached_entry + return None except (KeyError, ValueError): return None - def get_by_details(self, name, type_, class_): + def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: """Gets an entry by details. Will return None if there is no matching entry.""" entry = DNSEntry(name, type_, class_) return self.get(entry) - def entries_with_name(self, name): + def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" try: return self.cache[name.lower()] except KeyError: return [] - def current_entry_with_name_and_alias(self, name, alias): + def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() for record in self.entries_with_name(name): - if record.type == _TYPE_PTR and not record.is_expired(now) and record.alias == alias: + if ( + record.type == _TYPE_PTR + and not record.is_expired(now) + and cast(DNSPointer, record).alias == alias + ): return record + return None - def entries(self): + def entries(self) -> List[DNSRecord]: """Returns a list of all entries""" if not self.cache: return [] @@ -1214,11 +1234,11 @@ class Listener(QuietLogger): It requires registration with an Engine object in order to have the read() method called when a socket is available for reading.""" - def __init__(self, zc): + def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc - self.data = None + self.data = None # type: Optional[bytes] - def handle_read(self, socket_): + def handle_read(self, socket_: socket.socket) -> None: try: data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) except Exception: @@ -1252,13 +1272,13 @@ class Reaper(threading.Thread): """A Reaper is used by this module to remove cache entries that have expired.""" - def __init__(self, zc): + def __init__(self, zc: 'Zeroconf') -> None: threading.Thread.__init__(self, name='zeroconf-Reaper') self.daemon = True self.zc = zc self.start() - def run(self): + def run(self) -> None: while True: self.zc.wait(10 * 1000) if self.zc.done: @@ -1271,10 +1291,10 @@ def run(self): class Signal: - def __init__(self): + def __init__(self) -> None: self._handlers = [] # type: List[Callable[..., None]] - def fire(self, **kwargs) -> None: + def fire(self, **kwargs: Any) -> None: for h in list(self._handlers): h(**kwargs) @@ -1284,14 +1304,14 @@ def registration_interface(self) -> 'SignalRegistrationInterface': class SignalRegistrationInterface: - def __init__(self, handlers): + def __init__(self, handlers: List[Callable[..., None]]) -> None: self._handlers = handlers - def register_handler(self, handler): + def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': self._handlers.append(handler) return self - def unregister_handler(self, handler): + def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': self._handlers.remove(handler) return self @@ -1302,13 +1322,13 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: class ServiceListener: - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() @@ -1324,8 +1344,8 @@ def __init__( self, zc: 'Zeroconf', type_: str, - handlers=None, - listener=None, + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, @@ -1351,14 +1371,17 @@ def __init__( self.done = False if hasattr(handlers, 'add_service'): - listener = handlers + listener = cast(ServiceListener, handlers) handlers = None - handlers = handlers or [] + handlers = cast(List[Callable[..., None]], handlers or []) if listener: - def on_change(zeroconf, service_type, name, state_change): + def on_change( + zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange + ) -> None: + assert listener is not None args = (zeroconf, service_type, name) if state_change is ServiceStateChange.Added: listener.add_service(*args) @@ -1452,9 +1475,6 @@ def run(self) -> None: handler(self.zc) -ServicePropertiesType = Dict[AnyStr, Union[None, bool, AnyStr, float]] - - class ServiceInfo(RecordUpdateListener): text = b'' @@ -1471,7 +1491,7 @@ def __init__( port: Optional[int] = None, weight: int = 0, priority: int = 0, - properties=b'', + properties: Union[bytes, Dict] = b'', server: Optional[str] = None, host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL, @@ -1526,14 +1546,14 @@ def __init__( self.server = server else: self.server = name - self._properties = {} # type: ServicePropertiesType + self._properties = {} # type: Dict self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl # fmt: on @property - def address(self): + def address(self) -> Optional[bytes]: warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) try: # Return the first V4 address for compatibility @@ -1542,7 +1562,7 @@ def address(self): return None @address.setter - def address(self, value): + def address(self, value: bytes) -> None: warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) if value is None: self._addresses = [] @@ -1550,7 +1570,7 @@ def address(self, value): self._addresses = [value] @property - def addresses(self): + def addresses(self) -> List[bytes]: """IPv4 addresses of this service. Only IPv4 addresses are returned for backward compatibility. @@ -1560,7 +1580,7 @@ def addresses(self): return self.addresses_by_version(IPVersion.V4Only) @addresses.setter - def addresses(self, value): + def addresses(self, value: List[bytes]) -> None: """Replace the addresses list. This replaces all currently stored addresses, both IPv4 and IPv6. @@ -1568,7 +1588,7 @@ def addresses(self, value): self._addresses = value @property - def properties(self) -> ServicePropertiesType: + def properties(self) -> Dict: return self._properties def addresses_by_version(self, version: IPVersion) -> List[bytes]: @@ -1588,7 +1608,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: for addr in result ] - def _set_properties(self, properties: Union[bytes, ServicePropertiesType]) -> None: + def _set_properties(self, properties: Union[bytes, Dict]) -> None: """Sets properties and text of this info from a dictionary""" if isinstance(properties, dict): self._properties = properties @@ -1621,7 +1641,7 @@ def _set_properties(self, properties: Union[bytes, ServicePropertiesType]) -> No def _set_text(self, text: bytes) -> None: """Sets properties and text given a text field""" self.text = text - result = {} # type: ServicePropertiesType + result = {} # type: Dict end = len(text) index = 0 strs = [] @@ -1657,7 +1677,7 @@ def get_name(self) -> str: return self.name[: len(self.name) - len(self.type) - 1] return self.name - def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: + def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: """Updates service information from a DNS record""" if record is not None and not record.is_expired(now): if record.type in [_TYPE_A, _TYPE_AAAA]: @@ -2220,7 +2240,7 @@ def _broadcast_service(self, info: ServiceInfo) -> None: info.host_ttl, info.priority, info.weight, - info.port, + cast(int, info.port), info.server, ), 0, @@ -2256,7 +2276,14 @@ def unregister_service(self, info: ServiceInfo) -> None: out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) out.add_answer_at_time( DNSService( - info.name, _TYPE_SRV, _CLASS_IN, 0, info.priority, info.weight, info.port, info.name + info.name, + _TYPE_SRV, + _CLASS_IN, + 0, + info.priority, + info.weight, + cast(int, info.port), + info.name, ), 0, ) @@ -2291,7 +2318,7 @@ def unregister_all_services(self) -> None: 0, info.priority, info.weight, - info.port, + cast(int, info.port), info.server, ), 0, @@ -2384,17 +2411,17 @@ def handle_response(self, msg: DNSIncoming) -> None: self.cache.remove(entry) expired = record.is_expired(now) - entry = self.cache.get(record) + maybe_entry = self.cache.get(record) if not expired: - if entry is not None: - entry.reset_ttl(record) + if maybe_entry is not None: + maybe_entry.reset_ttl(record) else: self.cache.add(record) self.update_record(now, record) else: - if entry is not None: + if maybe_entry is not None: self.update_record(now, record) - self.cache.remove(entry) + self.cache.remove(maybe_entry) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if @@ -2439,7 +2466,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None service.host_ttl, service.priority, service.weight, - service.port, + cast(int, service.port), service.server, ) ) @@ -2500,7 +2527,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None service.host_ttl, service.priority, service.weight, - service.port, + cast(int, service.port), service.server, ), ) diff --git a/zeroconf/test.py b/zeroconf/test.py index deab7ffbb..f0d5ad4b9 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -46,10 +46,10 @@ class TestDunder(unittest.TestCase): def test_dns_text_repr(self): # There was an issue on Python 3 that prevented DNSText's repr # from working when the text was longer than 10 bytes - text = DNSText('irrelevant', None, 0, 0, b'12345678901') + text = DNSText('irrelevant', 0, 0, 0, b'12345678901') repr(text) - text = DNSText('irrelevant', None, 0, 0, b'123') + text = DNSText('irrelevant', 0, 0, 0, b'123') repr(text) def test_dns_hinfo_repr_eq(self): @@ -71,7 +71,7 @@ def test_dns_question_repr(self): assert not question != question def test_dns_service_repr(self): - service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, b'a') + service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, 'a') repr(service) def test_dns_record_abc(self): @@ -84,7 +84,7 @@ def test_service_info_dunder(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) info = ServiceInfo( - type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, None, "ash-2.local." + type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, b'', "ash-2.local." ) assert not info != info @@ -116,7 +116,7 @@ def test_parse_own_packet_simple(self): r.DNSIncoming(generated.packet()) def test_parse_own_packet_simple_unicast(self): - generated = r.DNSOutgoing(0, 0) + generated = r.DNSOutgoing(0, False) r.DNSIncoming(generated.packet()) def test_parse_own_packet_flags(self): @@ -509,7 +509,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None - assert dns_text.text == service_text # service_text is b'path=/~paulsm/' + assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' # https://tools.ietf.org/html/rfc6762#section-10.2 # Instead of merging this new record additively into the cache in addition @@ -524,7 +524,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None - assert dns_text.text == service_text # service_text is b'path=/~humingchun/' + assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' time.sleep(1.1) @@ -913,7 +913,7 @@ def update_service(self, zeroconf, type, name): ) zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} # type: r.ServicePropertiesType + desc = {'path': '/~paulsm/'} # type: Dict desc.update(properties) addresses = [socket.inet_aton("10.0.1.2")] if socket.has_ipv6: From 76bc67532ad26f54c194e1e6537d2da4390f83e2 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 17 Dec 2019 16:07:00 +0100 Subject: [PATCH 0043/1433] Release version 0.24.2 --- README.rst | 7 +++++++ zeroconf/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 97a33b978..6c4913728 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,13 @@ See examples directory for more. Changelog ========= +0.24.2 +------ + +* Added support for AWDL interface on macOS (needed and used by the opendrop project but should be + useful in general), thanks to Milan Stute +* Added missing type hints + 0.24.1 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8c98ae6f9..484fef490 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.1' +__version__ = '0.24.2' __license__ = 'LGPL' From f53e24bddb3a6cb242cace2a541ed507e823be33 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 23 Dec 2019 15:53:52 +0100 Subject: [PATCH 0044/1433] Fix import-time TypeError on CPython 3.5.2 The error: TypeError: 'ellipsis' object is not iterable." Explanation can be found here: https://github.com/jstasiak/python-zeroconf/issues/208 Closes GH-208. --- zeroconf/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 484fef490..d7724671f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1303,15 +1303,17 @@ def registration_interface(self) -> 'SignalRegistrationInterface': return SignalRegistrationInterface(self._handlers) +# NOTE: Callable quoting needed on Python 3.5.2, see +# https://github.com/jstasiak/python-zeroconf/issues/208 for details. class SignalRegistrationInterface: - def __init__(self, handlers: List[Callable[..., None]]) -> None: + def __init__(self, handlers: List['Callable[..., None]']) -> None: self._handlers = handlers - def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + def register_handler(self, handler: 'Callable[..., None]') -> 'SignalRegistrationInterface': self._handlers.append(handler) return self - def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + def unregister_handler(self, handler: 'Callable[..., None]') -> 'SignalRegistrationInterface': self._handlers.remove(handler) return self @@ -1344,7 +1346,9 @@ def __init__( self, zc: 'Zeroconf', type_: str, - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + # NOTE: Callable quoting needed on Python 3.5.2, see + # https://github.com/jstasiak/python-zeroconf/issues/208 for details. + handlers: Optional[Union[ServiceListener, List['Callable[..., None]']]] = None, listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, @@ -1374,7 +1378,9 @@ def __init__( listener = cast(ServiceListener, handlers) handlers = None - handlers = cast(List[Callable[..., None]], handlers or []) + # NOTE: Callable quoting needed on Python 3.5.2, see + # https://github.com/jstasiak/python-zeroconf/issues/208 for details. + handlers = cast(List['Callable[..., None]'], handlers or []) if listener: From 2316027e5e96d8f10fae7607da5b72a9bab819fc Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 23 Dec 2019 15:59:30 +0100 Subject: [PATCH 0045/1433] Release version 0.24.3 --- README.rst | 5 +++++ zeroconf/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6c4913728..aae11245a 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,11 @@ See examples directory for more. Changelog ========= +0.24.3 +------ + +* Fixed import-time "TypeError: 'ellipsis' object is not iterable." on CPython 3.5.2 + 0.24.2 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d7724671f..6c9d55dbe 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.2' +__version__ = '0.24.3' __license__ = 'LGPL' From b47efd8eed0b5ed9d3b6bca8573a6ed1916c982a Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Mon, 30 Dec 2019 18:08:16 +0000 Subject: [PATCH 0046/1433] Fix resetting of TTL (#209) Fix resetting of TTL Previously the reset_ttl method changed the time created and the TTL value, but did not change the expiration time or stale times. As a result a record would expire even when this method had been called. --- zeroconf/__init__.py | 2 ++ zeroconf/test.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6c9d55dbe..d913d8ece 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -502,6 +502,8 @@ def reset_ttl(self, other: 'DNSRecord') -> None: another record.""" self.created = other.created self.ttl = other.ttl + self._expiration_time = self.get_expiration_time(100) + self._stale_time = self.get_expiration_time(50) def write(self, out: 'DNSOutgoing') -> None: """Abstract method""" diff --git a/zeroconf/test.py b/zeroconf/test.py index f0d5ad4b9..5a89c9f65 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -79,6 +79,21 @@ def test_dns_record_abc(self): self.assertRaises(r.AbstractMethodException, record.__eq__, record) self.assertRaises(r.AbstractMethodException, record.write, None) + def test_dns_record_reset_ttl(self): + record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + time.sleep(1) + record2 = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + now = r.current_time_millis() + + assert record.created != record2.created + assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) + + record.reset_ttl(record2) + + assert record.ttl == record2.ttl + assert record.created == record2.created + assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) + def test_service_info_dunder(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" From 8ccad54dab4a0ab7f573996f6fc0c2f2bad7eafe Mon Sep 17 00:00:00 2001 From: Jay Hogg Date: Wed, 25 Dec 2019 11:51:48 -0600 Subject: [PATCH 0047/1433] Update DNS entries so all subclasses of DNSRecord use to_string for display All records based on DNSRecord now properly use to_string in repr, some were only dumping the answer without the question (inconsistent). --- zeroconf/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d913d8ece..e963f5b1a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -540,9 +540,9 @@ def __ne__(self, other: Any) -> bool: def __repr__(self) -> str: """String representation""" try: - return str(socket.inet_ntoa(self.address)) + return self.to_string(str(socket.inet_ntoa(self.address))) except Exception: # TODO stop catching all Exceptions - return str(self.address) + return self.to_string(str(self.address)) class DNSHinfo(DNSRecord): @@ -582,7 +582,7 @@ def __ne__(self, other: Any) -> bool: def __repr__(self) -> str: """String representation""" - return self.cpu + " " + self.os + return self.to_string(self.cpu + " " + self.os) class DNSPointer(DNSRecord): From 4b735dc5411f7b563f23b60b5c2aa806151cca1a Mon Sep 17 00:00:00 2001 From: Jay Hogg Date: Wed, 25 Dec 2019 11:51:48 -0600 Subject: [PATCH 0048/1433] Clean up format to cleanly separate [question]=ttl,answer --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e963f5b1a..02f505719 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -420,7 +420,7 @@ def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: result += "," result += self.name if other is not None: - result += ",%s]" % cast(Any, other) + result += "]=%s" % cast(Any, other) else: result += "]" return result From ba1b78dbdcc64f8d35c951e7ca53d2898e7d7900 Mon Sep 17 00:00:00 2001 From: Jay Hogg Date: Wed, 25 Dec 2019 11:51:48 -0600 Subject: [PATCH 0049/1433] Clean up output of ttl remaining to be whole seconds only --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 02f505719..011b2e317 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -511,7 +511,7 @@ def write(self, out: 'DNSOutgoing') -> None: def to_string(self, other: Union[bytes, str]) -> str: """String representation with additional information""" - arg = "%s/%s,%s" % (self.ttl, self.get_remaining_ttl(current_time_millis()), cast(Any, other)) + arg = "%s/%s,%s" % (self.ttl, int(self.get_remaining_ttl(current_time_millis())), cast(Any, other)) return DNSEntry.entry_to_string(self, "record", arg) From 29432bfffd057cf4da7636ba0c28c9d8a7ad4357 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 30 Dec 2019 23:31:40 +0100 Subject: [PATCH 0050/1433] Release version 0.24.4 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index aae11245a..432d3c4be 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.24.4 +------ + +* Fixed resetting TTL in DNSRecord.reset_ttl(), thanks to Matt Saxon +* Improved various DNS class' string representations, thanks to Jay Hogg + 0.24.3 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 011b2e317..35fac80a6 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.3' +__version__ = '0.24.4' __license__ = 'LGPL' From bef8f593ae820eb8465934de91eb27468edf6444 Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Sun, 2 Feb 2020 20:30:35 +0000 Subject: [PATCH 0051/1433] Ensure all TXT, SRV, A records are unique Fixes issues with shared records being used where they shouldn't be. PTR records should be shared, but SRV, TXT and A/AAAA records should be unique. Whilst mDNS and DNS-SD in theory support shared records for these types of record, they are not implemented in python-zeroconf at the moment. See zeroconf.check_service() method which verifies the service is unique on the network before registering. --- zeroconf/__init__.py | 57 +++++++++++++++++++++++++++++++++++++------- zeroconf/test.py | 54 ++++++++++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 35fac80a6..b3df8180b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -887,6 +887,16 @@ def add_question(self, record: DNSQuestion) -> None: self.questions.append(record) def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: + + """Only support for unique answers""" + if ( + record.type == _TYPE_TXT + or record.type == _TYPE_SRV + or record.type == _TYPE_A + or record.type == _TYPE_AAAA + ): + assert record.unique + """Adds an answer""" if not record.suppressed_by(inp): self.add_answer_at_time(record, 0) @@ -894,6 +904,16 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" if record is not None: + + """Only support for unique answers""" + if ( + record.type == _TYPE_TXT + or record.type == _TYPE_SRV + or record.type == _TYPE_A + or record.type == _TYPE_AAAA + ): + assert record.unique + if now == 0 or not record.is_expired(now): self.answers.append((record, now)) @@ -937,6 +957,15 @@ def add_additional_answer(self, record: DNSRecord) -> None: o All address records (type "A" and "AAAA") named in the SRV rdata. """ + """Only support for unique answers""" + if ( + record.type == _TYPE_TXT + or record.type == _TYPE_SRV + or record.type == _TYPE_A + or record.type == _TYPE_AAAA + ): + assert record.unique + self.additionals.append(record) def pack(self, format_: Union[bytes, str], value: Any) -> None: @@ -2244,7 +2273,7 @@ def _broadcast_service(self, info: ServiceInfo) -> None: DNSService( info.name, _TYPE_SRV, - _CLASS_IN, + _CLASS_IN | _CLASS_UNIQUE, info.host_ttl, info.priority, info.weight, @@ -2254,10 +2283,14 @@ def _broadcast_service(self, info: ServiceInfo) -> None: 0, ) - out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, info.other_ttl, info.text), 0) + out.add_answer_at_time( + DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, info.other_ttl, info.text), 0 + ) for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, info.host_ttl, address), 0) + out.add_answer_at_time( + DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, info.host_ttl, address), 0 + ) self.send(out) i += 1 next_time += _REGISTER_TIME @@ -2286,7 +2319,7 @@ def unregister_service(self, info: ServiceInfo) -> None: DNSService( info.name, _TYPE_SRV, - _CLASS_IN, + _CLASS_IN | _CLASS_UNIQUE, 0, info.priority, info.weight, @@ -2295,11 +2328,13 @@ def unregister_service(self, info: ServiceInfo) -> None: ), 0, ) - out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, 0, info.text), 0) for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) + out.add_answer_at_time( + DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, 0, address), 0 + ) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -2322,7 +2357,7 @@ def unregister_all_services(self) -> None: DNSService( info.name, _TYPE_SRV, - _CLASS_IN, + _CLASS_IN | _CLASS_UNIQUE, 0, info.priority, info.weight, @@ -2331,10 +2366,14 @@ def unregister_all_services(self) -> None: ), 0, ) - out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN, 0, info.text), 0) + out.add_answer_at_time( + DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, 0, info.text), 0 + ) for address in info.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time(DNSAddress(info.server, type_, _CLASS_IN, 0, address), 0) + out.add_answer_at_time( + DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, 0, address), 0 + ) self.send(out) i += 1 next_time += _UNREGISTER_TIME diff --git a/zeroconf/test.py b/zeroconf/test.py index 5a89c9f65..3b381f602 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -146,7 +146,17 @@ def test_parse_own_packet_question(self): def test_parse_own_packet_response(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) generated.add_answer_at_time( - r.DNSService("æøå.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local."), 0 + r.DNSService( + "æøå.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + 0, ) parsed = r.DNSIncoming(generated.packet()) self.assertEqual(len(generated.answers), 1) @@ -166,13 +176,34 @@ def test_suppress_answer(self): question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) query_generated.add_question(question) answer1 = r.DNSService( - "testname1.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local." + "testname1.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", ) staleanswer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL / 2, 0, 0, 80, "foo.local." + "testname2.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL / 2, + 0, + 0, + 80, + "foo.local.", ) answer2 = r.DNSService( - "testname2.local.", r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, "foo.local." + "testname2.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", ) query_generated.add_answer_at_time(answer1, 0) query_generated.add_answer_at_time(staleanswer2, 0) @@ -444,7 +475,8 @@ def generate_host(zc, host_name, type_): out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) out.add_answer_at_time(r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, name), 0) out.add_answer_at_time( - r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, name), 0 + r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, r._DNS_HOST_TTL, 0, 0, 80, name), + 0, ) zc.send(out) @@ -487,7 +519,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ttl = 0 generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_name), 0 + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 ) generated.add_answer_at_time( r.DNSService( @@ -670,7 +702,7 @@ def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) generated = r.DNSOutgoing(0) - answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN, 1, packed) + answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN | r._CLASS_UNIQUE, 1, packed) generated.add_additional_answer(answer) packet = generated.packet() parsed = r.DNSIncoming(packet) @@ -886,6 +918,7 @@ def test_integration_with_listener_class(self): service_added = Event() service_removed = Event() service_updated = Event() + service_updated2 = Event() subtype_name = "My special Subtype" type_ = "_http._tcp.local." @@ -902,7 +935,7 @@ def remove_service(self, zeroconf, type, name): service_removed.set() def update_service(self, zeroconf, type, name): - pass + service_updated2.set() class MySubListener(r.ServiceListener): def add_service(self, zeroconf, type, name): @@ -966,7 +999,7 @@ def update_service(self, zeroconf, type, name): assert info is not None assert info.properties[b'prop_none'] is False - # Begin material test addition + # test TXT record update sublistener = MySubListener() zeroconf_browser.add_service_listener(registration_name, sublistener) properties['prop_blank'] = b'an updated string' @@ -981,7 +1014,6 @@ def update_service(self, zeroconf, type, name): info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None assert info.properties[b'prop_blank'] == properties['prop_blank'] - # End material test addition zeroconf_registrar.unregister_service(info_service) service_removed.wait(1) @@ -1043,7 +1075,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ttl = 0 generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_name), 0 + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 ) generated.add_answer_at_time( r.DNSService( From ca8e53de55a563f5c7049be2eda14ae0ecd1a7cf Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Wed, 19 Feb 2020 21:35:38 +0100 Subject: [PATCH 0052/1433] Do not exclude interfaces with host-only netmasks from InterfaceChoice.All (#227) Host-only netmasks do not forbid multicast. Tested on Debian 10 running in Qubes and on Ubuntu 18.04. --- zeroconf/__init__.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b3df8180b..5554f9b2b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1867,14 +1867,7 @@ def find( def get_all_addresses() -> List[str]: - return list( - set( - addr.ip - for iface in ifaddr.get_adapters() - for addr in iface.ips - if addr.is_IPv4 and addr.network_prefix != 32 # Host only netmask 255.255.255.255 - ) - ) + return list(set(addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4)) def get_all_addresses_v6() -> List[int]: From f6690d2048cb87cb0fb3a7c3b832cf1a1f40e61a Mon Sep 17 00:00:00 2001 From: Aldo Hoeben Date: Thu, 20 Feb 2020 13:45:38 +0100 Subject: [PATCH 0053/1433] Fix representation of IPv6 DNSAddress (#230) --- zeroconf/__init__.py | 6 +++++- zeroconf/test.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5554f9b2b..1974de834 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -540,7 +540,11 @@ def __ne__(self, other: Any) -> bool: def __repr__(self) -> str: """String representation""" try: - return self.to_string(str(socket.inet_ntoa(self.address))) + return self.to_string( + socket.inet_ntop( + socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address + ) + ) except Exception: # TODO stop catching all Exceptions return self.to_string(str(self.address)) diff --git a/zeroconf/test.py b/zeroconf/test.py index 3b381f602..4ff585cc6 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -63,7 +63,17 @@ def test_dns_pointer_repr(self): def test_dns_address_repr(self): address = r.DNSAddress('irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - repr(address) + assert repr(address).endswith("b'a'") + + address_ipv4 = r.DNSAddress( + 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') + ) + assert repr(address_ipv4).endswith('127.0.0.1') + + address_ipv6 = r.DNSAddress( + 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') + ) + assert repr(address_ipv6).endswith('::1') def test_dns_question_repr(self): question = r.DNSQuestion('irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) From 5e4f496778d91ccfc65e946d3d94c39ab6388b29 Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Mon, 3 Feb 2020 20:18:05 +0000 Subject: [PATCH 0054/1433] Refactor out unique assertion --- zeroconf/__init__.py | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1974de834..60893b398 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -886,21 +886,15 @@ class State(enum.Enum): init = 0 finished = 1 + @staticmethod + def is_type_unique(type_: int) -> bool: + return type_ == _TYPE_TXT or type_ == _TYPE_SRV or type_ == _TYPE_A or type_ == _TYPE_AAAA + def add_question(self, record: DNSQuestion) -> None: """Adds a question""" self.questions.append(record) def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: - - """Only support for unique answers""" - if ( - record.type == _TYPE_TXT - or record.type == _TYPE_SRV - or record.type == _TYPE_A - or record.type == _TYPE_AAAA - ): - assert record.unique - """Adds an answer""" if not record.suppressed_by(inp): self.add_answer_at_time(record, 0) @@ -909,13 +903,7 @@ def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int] """Adds an answer if it does not expire by a certain time""" if record is not None: - """Only support for unique answers""" - if ( - record.type == _TYPE_TXT - or record.type == _TYPE_SRV - or record.type == _TYPE_A - or record.type == _TYPE_AAAA - ): + if self.is_type_unique(record.type): assert record.unique if now == 0 or not record.is_expired(now): @@ -961,13 +949,7 @@ def add_additional_answer(self, record: DNSRecord) -> None: o All address records (type "A" and "AAAA") named in the SRV rdata. """ - """Only support for unique answers""" - if ( - record.type == _TYPE_TXT - or record.type == _TYPE_SRV - or record.type == _TYPE_A - or record.type == _TYPE_AAAA - ): + if self.is_type_unique(record.type): assert record.unique self.additionals.append(record) From d8caa4e2d71025ed42b33abb4d329329437b44fb Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Sun, 16 Feb 2020 15:56:27 +0000 Subject: [PATCH 0055/1433] Remove duplciate update messages sent to listeners The prior code used to send updates even when the new record was identical to the old. This resulted in duplciate update messages when there was in fact no update (apart from TTL refresh) --- zeroconf/__init__.py | 10 +++++++++- zeroconf/test.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 60893b398..b18b51ff4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2431,8 +2431,15 @@ def handle_response(self, msg: DNSIncoming) -> None: are held in the cache, and listeners are notified.""" now = current_time_millis() for record in msg.answers: + + updated = True + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 for entry in self.cache.entries(): + + if entry == record: + updated = False + if DNSEntry.__eq__(entry, record) and (record.created - entry.created > 1000): self.cache.remove(entry) @@ -2443,7 +2450,8 @@ def handle_response(self, msg: DNSIncoming) -> None: maybe_entry.reset_ttl(record) else: self.cache.add(record) - self.update_record(now, record) + if updated: + self.update_record(now, record) else: if maybe_entry is not None: self.update_record(now, record) diff --git a/zeroconf/test.py b/zeroconf/test.py index 4ff585cc6..5202ce975 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1125,6 +1125,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi service_updated_event.clear() service_text = b'path=/~humingchun/' zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(1) assert service_added is True assert service_updated_count == 2 From 1ca023fae4b586679446ceaf3e2e9955ea5bf180 Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Thu, 20 Feb 2020 13:02:43 +0000 Subject: [PATCH 0056/1433] Support cooperating responders (#224) --- zeroconf/__init__.py | 39 ++++++++++++++++++++++++--------------- zeroconf/test.py | 3 +++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b18b51ff4..34329c672 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2205,18 +2205,24 @@ def remove_all_service_listeners(self) -> None: self.remove_service_listener(listener) def register_service( - self, info: ServiceInfo, ttl: Optional[int] = None, allow_name_change: bool = False + self, + info: ServiceInfo, + ttl: Optional[int] = None, + allow_name_change: bool = False, + cooperating_responders: bool = False, ) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service. The name of the service may be changed if needed to make - it unique on the network.""" + it unique on the network. Additionally multiple cooperating responders + can register the same service on the network for resilience + (if you want this behavior set `cooperating_responders` to `True`).""" if ttl is not None: # ttl argument is used to maintain backward compatibility # Setting TTLs via ServiceInfo is preferred info.host_ttl = ttl info.other_ttl = ttl - self.check_service(info, allow_name_change) + self.check_service(info, allow_name_change, cooperating_responders) self.services[info.name.lower()] = info if info.type in self.servicetypes: self.servicetypes[info.type] += 1 @@ -2357,7 +2363,9 @@ def unregister_all_services(self) -> None: i += 1 next_time += _UNREGISTER_TIME - def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: + def check_service( + self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False + ) -> None: """Checks the network for a unique service name, modifying the ServiceInfo passed in if it is not unique.""" @@ -2374,17 +2382,18 @@ def check_service(self, info: ServiceInfo, allow_name_change: bool) -> None: next_time = now i = 0 while i < 3: - # check for a name conflict - while self.cache.current_entry_with_name_and_alias(info.type, info.name): - if not allow_name_change: - raise NonUniqueNameException - - # change the name and look for a conflict - info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) - next_instance_number += 1 - service_type_name(info.name) - next_time = now - i = 0 + if not cooperating_responders: + # check for a name conflict + while self.cache.current_entry_with_name_and_alias(info.type, info.name): + if not allow_name_change: + raise NonUniqueNameException + + # change the name and look for a conflict + info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) + next_instance_number += 1 + service_type_name(info.name) + next_time = now + i = 0 if now < next_time: self.wait(next_time - now) diff --git a/zeroconf/test.py b/zeroconf/test.py index 5202ce975..fb87524ba 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -463,6 +463,9 @@ def verify_name_change(self, zc, type_, name, number_hosts): # verify name conflict self.assertRaises(r.NonUniqueNameException, zc.register_service, info_service) + # verify no name conflict https://tools.ietf.org/html/rfc6762#section-6.6 + zc.register_service(info_service, cooperating_responders=True) + zc.register_service(info_service, allow_name_change=True) assert info_service.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) From 37fa0a0d59a5b5d09295a462bf911e82d2d770ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Feb 2020 14:41:10 -0600 Subject: [PATCH 0057/1433] Optimize handle_response cache check The handle_response loop would encounter a unique record it would search the cache in order to remove keys that matched the DNSEntry for the record. Since the cache is stored as a list of records with the key as the record name, we can avoid searching the entire cache each time and on search for the DNSEntry of the record. In practice this means with 5000 entries and records in the cache we now only need to search 4 or 5. When looping over the cache entries for the name, we now check the expire time first as its cheaper than calling DNSEntry.__eq__ Test environment: Home Assistant running on home networking with a /22 and a significant amount of broadcast traffic Testing was done with py-spy v0.3.3 (https://github.com/benfred/py-spy/releases) # py-spy top --pid Before: ``` Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) Total Samples 10200 GIL: 0.00%, Active: 0.00%, Threads: 35 %Own %Total OwnTime TotalTime Function (filename:line) 0.00% 0.00% 18.13s 18.13s _worker (concurrent/futures/thread.py:78) 0.00% 0.00% 2.51s 2.56s run (zeroconf/__init__.py:1221) 0.00% 0.00% 0.420s 0.420s __eq__ (zeroconf/__init__.py:394) 0.00% 0.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1260) 0.00% 0.00% 0.240s 0.670s handle_response (zeroconf/__init__.py:2452) 0.00% 0.00% 0.230s 0.230s __eq__ (zeroconf/__init__.py:606) 0.00% 0.00% 0.200s 0.810s handle_response (zeroconf/__init__.py:2449) 0.00% 0.00% 0.140s 0.150s __eq__ (zeroconf/__init__.py:632) 0.00% 0.00% 0.130s 0.130s entries (zeroconf/__init__.py:1185) 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) 0.00% 0.00% 0.080s 0.080s read_utf (zeroconf/__init__.py:818) 0.00% 0.00% 0.080s 0.080s __eq__ (zeroconf/__init__.py:678) 0.00% 0.00% 0.070s 0.080s __eq__ (zeroconf/__init__.py:533) 0.00% 0.00% 0.060s 0.060s __eq__ (zeroconf/__init__.py:677) 0.00% 0.00% 0.050s 0.050s get (zeroconf/__init__.py:1146) 0.00% 0.00% 0.050s 0.050s do_commit (sqlalchemy/engine/default.py:541) 0.00% 0.00% 0.040s 2.86s run (zeroconf/__init__.py:1226) ``` After ``` Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) Total Samples 10200 GIL: 7.00%, Active: 61.00%, Threads: 35 %Own %Total OwnTime TotalTime Function (filename:line) 47.00% 47.00% 24.84s 24.84s _worker (concurrent/futures/thread.py:78) 5.00% 5.00% 2.97s 2.97s run (zeroconf/__init__.py:1226) 1.00% 1.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1265) 1.00% 1.00% 0.200s 0.200s read_utf (zeroconf/__init__.py:818) 0.00% 0.00% 0.120s 0.120s unpack (zeroconf/__init__.py:723) 0.00% 1.00% 0.120s 0.320s read_name (zeroconf/__init__.py:834) 0.00% 0.00% 0.100s 0.240s update_record (zeroconf/__init__.py:2440) 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) 0.00% 0.00% 0.070s 0.070s update_record (zeroconf/__init__.py:1469) 0.00% 0.00% 0.060s 0.070s __eq__ (zeroconf/__init__.py:606) 0.00% 0.00% 0.050s 0.050s acquire (logging/__init__.py:843) 0.00% 0.00% 0.050s 0.050s unpack (zeroconf/__init__.py:722) 0.00% 0.00% 0.050s 0.050s read_name (zeroconf/__init__.py:828) 0.00% 0.00% 0.050s 0.050s is_expired (zeroconf/__init__.py:494) 0.00% 0.00% 0.040s 0.040s emit (logging/__init__.py:1028) 1.00% 1.00% 0.040s 0.040s __init__ (zeroconf/__init__.py:386) 0.00% 0.00% 0.040s 0.040s __enter__ (threading.py:241) ``` --- zeroconf/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 34329c672..2b23c9712 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2444,12 +2444,21 @@ def handle_response(self, msg: DNSIncoming) -> None: updated = True if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - for entry in self.cache.entries(): + # Since the cache format is keyed on the lower case record name + # we can avoid iterating everything in the cache and + # only look though entries for the specific name. + # entries_with_name will take care of converting to lowercase + # + # We make a copy of the list that entries_with_name returns + # since we cannot iterate over something we might remove + for entry in self.cache.entries_with_name(record.name).copy(): if entry == record: updated = False - if DNSEntry.__eq__(entry, record) and (record.created - entry.created > 1000): + # Check the time first because it is far cheaper + # than the __eq__ + if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): self.cache.remove(entry) expired = record.is_expired(now) From eac53f45bddb8d3d559b1d4672a926b746435771 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Mar 2020 22:03:34 +0000 Subject: [PATCH 0058/1433] Resolve memory leak in DNSCache When all the records for a given name were removed from the cache, the name itself that contain the list was never removed. This left an empty list in memory for every device that was no longer broadcasting on the network. --- zeroconf/__init__.py | 5 +++++ zeroconf/test.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 2b23c9712..1d1cb3a25 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1136,6 +1136,11 @@ def remove(self, entry: DNSRecord) -> None: try: list_ = self.cache[entry.key] list_.remove(entry) + # If we remove the last entry in the list + # we remove the key from the dict in order + # to avoid leaking memory + if not list_: + del self.cache[entry.key] except (KeyError, ValueError): pass diff --git a/zeroconf/test.py b/zeroconf/test.py index fb87524ba..a371ad89f 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -828,6 +828,17 @@ def test_order(self): cached_record = cache.get(entry) self.assertEqual(cached_record, record2) + def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): + record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache + class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): From aba28583f5431f584587770b6c149e4a607a987e Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 8 Mar 2020 00:39:22 +0100 Subject: [PATCH 0059/1433] Release version 0.24.5 --- README.rst | 13 +++++++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 432d3c4be..8c03ce6b5 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,19 @@ See examples directory for more. Changelog ========= +0.24.5 +------ + +* Fixed issues with shared records being used where they shouldn't be (TXT, SRV, A records are + unique now), thanks to Matt Saxon +* Stopped unnecessarily excluding host-only interfaces from InterfaceChoice.all as they don't + forbid multicast, thanks to Andreas Oberritter +* Fixed repr() of IPv6 DNSAddress, thanks to Aldo Hoeben +* Removed duplicate update messages sent to listeners, thanks to Matt Saxon +* Added support for cooperating responders, thanks to Matt Saxon +* Optimized handle_response cache check, thanks to J. Nick Koston +* Fixed memory leak in DNSCache, thanks to J. Nick Koston + 0.24.4 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1d1cb3a25..2b2cccb9a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.4' +__version__ = '0.24.5' __license__ = 'LGPL' From 8e3adf8300a6f2b0bc0dcc4cde54d8890e0727e9 Mon Sep 17 00:00:00 2001 From: Andreas Oberritter Date: Sun, 8 Mar 2020 00:59:46 +0100 Subject: [PATCH 0060/1433] Rationalize handling of values in TXT records * Do not interpret received values; use None if a property has no value * When encoding values, use either raw bytes or UTF-8 --- zeroconf/__init__.py | 29 ++++++++--------------------- zeroconf/test.py | 10 +++++----- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 2b2cccb9a..41714f145 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1646,20 +1646,12 @@ def _set_properties(self, properties: Union[bytes, Dict]) -> None: if isinstance(key, str): key = key.encode('utf-8') - if value is None: - suffix = b'' - elif isinstance(value, str): - suffix = value.encode('utf-8') - elif isinstance(value, bytes): - suffix = value - elif isinstance(value, int): - if value: - suffix = b'true' - else: - suffix = b'false' - else: - suffix = b'' - list_.append(b'='.join((key, suffix))) + record = key + if value is not None: + if not isinstance(value, bytes): + value = str(value).encode('utf-8') + record += b'=' + value + list_.append(record) for item in list_: result = b''.join((result, int2byte(len(item)), item)) self.text = result @@ -1682,16 +1674,11 @@ def _set_text(self, text: bytes) -> None: for s in strs: parts = s.split(b'=', 1) try: - key, value = parts # type: Tuple[bytes, Union[bool, bytes]] + key, value = parts # type: Tuple[bytes, Optional[bytes]] except ValueError: # No equals sign at all key = s - value = False - else: - if value == b'true': - value = True - elif value == b'false' or not value: - value = False + value = None # Only update non-existent properties if key and result.get(key) is None: diff --git a/zeroconf/test.py b/zeroconf/test.py index a371ad89f..2225ac1dc 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1009,19 +1009,19 @@ def update_service(self, zeroconf, type, name): # get service info without answer cache info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None - assert info.properties[b'prop_none'] is False + assert info.properties[b'prop_none'] is None assert info.properties[b'prop_string'] == properties['prop_string'] - assert info.properties[b'prop_float'] is False + assert info.properties[b'prop_float'] == b'1.0' assert info.properties[b'prop_blank'] == properties['prop_blank'] - assert info.properties[b'prop_true'] is True - assert info.properties[b'prop_false'] is False + assert info.properties[b'prop_true'] == b'1' + assert info.properties[b'prop_false'] == b'0' assert info.addresses == addresses[:1] # no V6 by default all_addresses = info.addresses_by_version(r.IPVersion.All) assert all_addresses == addresses, all_addresses info = zeroconf_browser.get_service_info(subtype, registration_name) assert info is not None - assert info.properties[b'prop_none'] is False + assert info.properties[b'prop_none'] is None # test TXT record update sublistener = MySubListener() From a79015e7c4bdc843d97bd5c82ef8ed4eeae01a34 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 3 Apr 2020 11:28:36 +0200 Subject: [PATCH 0061/1433] Remove uniqueness assertions The assertions, added in [1] and modified in [2] introduced a regression. When browsing in the presence of devices advertising SRV records not marked as unique there would be an undesired crash (from [3]): Exception in thread zeroconf-ServiceBrowser__hap._tcp.local.: Traceback (most recent call last): File "/usr/lib/python3.7/threading.py", line 917, in _bootstrap_inner self.run() File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1504, in run handler(self.zc) File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1444, in zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1322, in fire h(**kwargs) File "browser.py", line 20, in on_service_state_change info = zeroconf.get_service_info(service_type, name) File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 2191, in get_service_info if info.request(self, timeout): File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1762, in request out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN), now) File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 907, in add_answer_at_time assert record.unique AssertionError The intention is to bring those assertions back in a way that only enforces uniqueness when sending records, not when receiving them. [1] bef8f593ae82 ("Ensure all TXT, SRV, A records are unique") [2] 5e4f496778d9 ("Refactor out unique assertion") [3] https://github.com/jstasiak/python-zeroconf/issues/236 --- zeroconf/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 41714f145..e269443ad 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -902,10 +902,6 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" if record is not None: - - if self.is_type_unique(record.type): - assert record.unique - if now == 0 or not record.is_expired(now): self.answers.append((record, now)) @@ -949,9 +945,6 @@ def add_additional_answer(self, record: DNSRecord) -> None: o All address records (type "A" and "AAAA") named in the SRV rdata. """ - if self.is_type_unique(record.type): - assert record.unique - self.additionals.append(record) def pack(self, format_: Union[bytes, str], value: Any) -> None: From e839c40081ba15e228d447969b725ee42f1ef2ad Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 3 Apr 2020 12:56:50 +0200 Subject: [PATCH 0062/1433] Improve ServiceInfo documentation --- zeroconf/__init__.py | 47 ++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e269443ad..f49de1b49 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1497,9 +1497,28 @@ def run(self) -> None: class ServiceInfo(RecordUpdateListener): - text = b'' + """Service information. + + Constructor parameters are as follows: + + * type_: fully qualified service type name + * name: fully qualified service name + * address: IP address as unsigned short, network byte order (deprecated, use addresses) + * port: port that the service runs on + * weight: weight of the service + * priority: priority of the service + * properties: dictionary of properties (or a bytes object holding the contents of the `text` field). + converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to + value-less attributes. + * server: fully qualified name for service host (defaults to name) + * host_ttl: ttl used for A/SRV records + * other_ttl: ttl used for PTR/TXT records + * addresses: List of IP addresses as unsigned short (IPv4) or unsigned 128 bit number (IPv6), + network byte order + + """ - """Service information""" + text = b'' # FIXME(dtantsur): black 19.3b0 produces code that is not valid syntax on # Python 3.5: https://github.com/python/black/issues/759 @@ -1519,23 +1538,6 @@ def __init__( *, addresses: Optional[List[bytes]] = None ) -> None: - """Create a service description. - - type_: fully qualified service type name - name: fully qualified service name - address: IP address as unsigned short, network byte order (deprecated, use addresses) - port: port that the service runs on - weight: weight of the service - priority: priority of the service - properties: dictionary of properties (or a string holding the - bytes for the text field) - server: fully qualified name for service host (defaults to name) - host_ttl: ttl used for A/SRV records - other_ttl: ttl used for PTR/TXT records - addresses: List of IP addresses as unsigned short (IPv4) or unsigned - 128 bit number (IPv6), network byte order - """ - # Accept both none, or one, but not both. if address is not None and addresses is not None: raise TypeError("address and addresses cannot be provided together") @@ -1610,6 +1612,13 @@ def addresses(self, value: List[bytes]) -> None: @property def properties(self) -> Dict: + """If properties were set in the constructor this property returns the original dictionary + of type `Dict[Union[bytes, str], Any]`. + + If properties are coming from the network, after decoding a TXT record, the keys are always + bytes and the values are either bytes, if there was a value, even empty, or `None`, if there + was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. + """ return self._properties def addresses_by_version(self, version: IPVersion) -> List[bytes]: From 0cbced809989283893e02914e251a94739a41062 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 3 Apr 2020 12:59:35 +0200 Subject: [PATCH 0063/1433] Release version 0.25.0 --- README.rst | 12 ++++++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8c03ce6b5..a5471f539 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,18 @@ See examples directory for more. Changelog ========= +0.25.0 +------ + +* Reverted uniqueness assertions when browsing, they caused a regression + +Backwards incompatible: + +* Rationalized handling of TXT records. Non-bytes values are converted to str and encoded to bytes + using UTF-8 now, None values mean value-less attributes. When receiving TXT records no decoding + is performed now, keys are always bytes and values are either bytes or None in value-less + attributes. + 0.24.5 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f49de1b49..cf0e012e7 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.24.5' +__version__ = '0.25.0' __license__ = 'LGPL' From f071f3d49d82ab212b86f889532200c94b36aea6 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 9 Apr 2020 13:00:16 +0200 Subject: [PATCH 0064/1433] Switch to pytest for test running (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nose is dead for all intents and purposes (last release in 2015) and pytest provide a very valuable feature of printing relevant extra information in case of assertion failure (from[1]): ================================= FAILURES ================================= _______________________________ test_answer ________________________________ def test_answer(): > assert func(3) == 5 E assert 4 == 5 E + where 4 = func(3) test_sample.py:6: AssertionError ========================= short test summary info ========================== FAILED test_sample.py::test_answer - assert 4 == 5 ============================ 1 failed in 0.12s ============================= This should be helpful in debugging tests intermittently failing on PyPy. Several TestCase.assertEqual() calls have been replaced by plain assertions now that that method no longer provides anything we can't get without it. Few assertions have been modified to not explicitly provide extra information in case of failure – pytest will provide this automatically. Dev dependencies are forced to be the latest versions to make sure we don't fail because of outdated ones on Travis. [1] https://docs.pytest.org/en/latest/getting-started.html#create-your-first-test --- .travis.yml | 4 ++-- Makefile | 5 ++-- requirements-dev.txt | 3 ++- zeroconf/test.py | 54 +++++++++++++++++++++----------------------- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/.travis.yml b/.travis.yml index acf47981d..c5369538f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,13 +7,13 @@ python: - "pypy3.5" - "pypy3" install: - - pip install -r requirements-dev.txt + - pip install --upgrade -r requirements-dev.txt # mypy can't be installed on pypy - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* && "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install black ; fi script: # no IPv6 support in Travis :( - - make TEST_ARGS='-a "!IPv6"' ci + - SKIP_IPV6=1 make ci after_success: - coveralls diff --git a/Makefile b/Makefile index ea5f8c64e..af951f265 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ MAX_LINE_LENGTH=110 PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.version_info[:2])") -TEST_ARGS= LINT_TARGETS:=flake8 @@ -40,10 +39,10 @@ mypy: mypy examples/*.py zeroconf/*.py test: - nosetests -v $(TEST_ARGS) + pytest -v zeroconf/test.py test_coverage: - nosetests -v --with-coverage --cover-package=zeroconf $(TEST_ARGS) + pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/requirements-dev.txt b/requirements-dev.txt index ec443c0bf..127df74ef 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,5 +5,6 @@ coverage flake8>=3.6.0 flake8-import-order ifaddr -nose pep8-naming!=0.6.0 +pytest +pytest-cov diff --git a/zeroconf/test.py b/zeroconf/test.py index 2225ac1dc..802a5f110 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -6,6 +6,7 @@ import copy import logging +import os import socket import struct import time @@ -14,8 +15,6 @@ from typing import Dict, Optional # noqa # used in type hints from typing import cast -from nose.plugins.attrib import attr - import zeroconf as r from zeroconf import ( DNSHinfo, @@ -169,17 +168,17 @@ def test_parse_own_packet_response(self): 0, ) parsed = r.DNSIncoming(generated.packet()) - self.assertEqual(len(generated.answers), 1) - self.assertEqual(len(generated.answers), len(parsed.answers)) + assert len(generated.answers) == 1 + assert len(generated.answers) == len(parsed.answers) def test_match_question(self): generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) generated.add_question(question) parsed = r.DNSIncoming(generated.packet()) - self.assertEqual(len(generated.questions), 1) - self.assertEqual(len(generated.questions), len(parsed.questions)) - self.assertEqual(question, parsed.questions[0]) + assert len(generated.questions) == 1 + assert len(generated.questions) == len(parsed.questions) + assert question == parsed.questions[0] def test_suppress_answer(self): query_generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) @@ -253,8 +252,8 @@ def test_dns_hinfo(self): generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) parsed = r.DNSIncoming(generated.packet()) answer = cast(r.DNSHinfo, parsed.answers[0]) - self.assertEqual(answer.cpu, u'cpu') - self.assertEqual(answer.os, u'os') + assert answer.cpu == u'cpu' + assert answer.os == u'os' generated = r.DNSOutgoing(0) generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) @@ -267,28 +266,28 @@ def test_transaction_id(self): generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) bytes = generated.packet() id = bytes[0] << 8 | bytes[1] - self.assertEqual(id, 0) + assert id == 0 def test_query_header_bits(self): generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) bytes = generated.packet() flags = bytes[2] << 8 | bytes[3] - self.assertEqual(flags, 0x0) + assert flags == 0x0 def test_response_header_bits(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) bytes = generated.packet() flags = bytes[2] << 8 | bytes[3] - self.assertEqual(flags, 0x8000) + assert flags == 0x8000 def test_numbers(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) bytes = generated.packet() (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - self.assertEqual(num_questions, 0) - self.assertEqual(num_answers, 0) - self.assertEqual(num_authorities, 0) - self.assertEqual(num_additionals, 0) + assert num_questions == 0 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 def test_numbers_questions(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) @@ -297,10 +296,10 @@ def test_numbers_questions(self): generated.add_question(question) bytes = generated.packet() (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - self.assertEqual(num_questions, 10) - self.assertEqual(num_answers, 0) - self.assertEqual(num_authorities, 0) - self.assertEqual(num_additionals, 0) + assert num_questions == 10 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 class Names(unittest.TestCase): @@ -502,7 +501,7 @@ def test_launch_and_close(self): rv.close() @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') - @attr('IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() @@ -510,7 +509,7 @@ def test_launch_and_close_v4_v6(self): rv.close() @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') - @attr('IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() @@ -826,7 +825,7 @@ def test_order(self): cache.add(record2) entry = r.DNSEntry('a', r._TYPE_SOA, r._CLASS_IN) cached_record = cache.get(entry) - self.assertEqual(cached_record, record2) + assert cached_record == record2 def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') @@ -888,7 +887,7 @@ def test_integration_with_listener_v6_records(self): zeroconf_registrar.close() @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') - @attr('IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_ipv6(self): type_ = "_test-srvc-type._tcp.local." @@ -904,9 +903,9 @@ def test_integration_with_listener_ipv6(self): try: service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) - assert type_ in service_types, service_types + assert type_ in service_types service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types, service_types + assert type_ in service_types finally: zeroconf_registrar.close() @@ -1016,8 +1015,7 @@ def update_service(self, zeroconf, type, name): assert info.properties[b'prop_true'] == b'1' assert info.properties[b'prop_false'] == b'0' assert info.addresses == addresses[:1] # no V6 by default - all_addresses = info.addresses_by_version(r.IPVersion.All) - assert all_addresses == addresses, all_addresses + assert info.addresses_by_version(r.IPVersion.All) == addresses info = zeroconf_browser.get_service_info(subtype, registration_name) assert info is not None From cf0382ba771bcc22284fd719c80a26eaa05ba5cd Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Fri, 10 Apr 2020 19:59:41 +0100 Subject: [PATCH 0065/1433] Remove unstable IPv6 tests from Travis (#241) --- zeroconf/test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 802a5f110..13df725c1 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -863,6 +863,7 @@ def test_integration_with_listener(self): zeroconf_registrar.close() @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_v6_records(self): type_ = "_test-srvc-type._tcp.local." @@ -987,7 +988,7 @@ def update_service(self, zeroconf, type, name): desc = {'path': '/~paulsm/'} # type: Dict desc.update(properties) addresses = [socket.inet_aton("10.0.1.2")] - if socket.has_ipv6: + if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) info_service = ServiceInfo( subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses @@ -1357,7 +1358,7 @@ def test_multiple_addresses(): assert info.addresses == [address, address] - if socket.has_ipv6: + if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) info = ServiceInfo(type_, registration_name, [address, address_v6], 80, 0, 0, desc, "ash-2.local.") From 976e3dcf9d6d897b063ab6f0b7831bcfa6ac1814 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Apr 2020 20:07:42 +0200 Subject: [PATCH 0066/1433] Update Engine to immediately notify its worker thread (#243) --- zeroconf/__init__.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cf0e012e7..a148dd750 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1203,12 +1203,13 @@ def __init__(self, zc: 'Zeroconf') -> None: self.readers = {} # type: Dict[socket.socket, Listener] self.timeout = 5 self.condition = threading.Condition() + self.socketpair = socket.socketpair() self.start() def run(self) -> None: while not self.zc.done: with self.condition: - rs = self.readers.keys() + rs = list(self.readers.keys()) if len(rs) == 0: # No sockets to manage, but we wait for the timeout # or addition of a socket @@ -1216,6 +1217,7 @@ def run(self) -> None: if len(rs) != 0: try: + rs = rs + [self.socketpair[0]] rr, wr, er = select.select(cast(Sequence[Any], rs), [], [], self.timeout) if not self.zc.done: for socket_ in rr: @@ -1223,21 +1225,36 @@ def run(self) -> None: if reader: reader.handle_read(socket_) + if self.socketpair[0] in rr: + # Clear the socket's buffer + self.socketpair[0].recv(128) + except (select.error, socket.error) as e: # If the socket was closed by another thread, during # shutdown, ignore it and exit if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: raise + self.socketpair[0].close() + self.socketpair[1].close() + + def _notify(self) -> None: + self.condition.notify() + try: + self.socketpair[1].send(b'x') + except socket.error: + # The socketpair may already be closed during shutdown, ignore it + if not self.zc.done: + raise def add_reader(self, reader: 'Listener', socket_: socket.socket) -> None: with self.condition: self.readers[socket_] = reader - self.condition.notify() + self._notify() def del_reader(self, socket_: socket.socket) -> None: with self.condition: del self.readers[socket_] - self.condition.notify() + self._notify() class Listener(QuietLogger): From f8fe400e4be833728f015a3d6396bfc3f7c185c0 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 14 Apr 2020 21:01:53 +0200 Subject: [PATCH 0067/1433] Release version 0.25.1 --- README.rst | 5 +++++ zeroconf/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a5471f539..e24ddff0c 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,11 @@ See examples directory for more. Changelog ========= +0.25.1 +------ + +* Eliminated 5s hangup when calling Zeroconf.close(), thanks to Erik Montnemery + 0.25.0 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a148dd750..4f136eec7 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.25.0' +__version__ = '0.25.1' __license__ = 'LGPL' From 552a030eb592a0c07feaa7a01ece1464da4b1d0b Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Sun, 5 Apr 2020 20:03:23 +0100 Subject: [PATCH 0068/1433] Call UpdateService on SRV & A/AAAA updates as well as TXT (#239) Fix https://github.com/jstasiak/python-zeroconf/issues/235 Contains: * Add lock around handlers list * Reverse DNSCache order to ensure newest records take precedence When there are multiple records in the cache, the behaviour was inconsistent. Whilst the DNSCache.get() method returned the newest, any function which iterated over the entire cache suffered from a last write winds issue. This change makes this behaviour consistent and allows the removal of an (incorrect) wait from one of the unit tests. --- zeroconf/__init__.py | 132 ++++++++++++++++++++++++++++--------------- zeroconf/test.py | 102 +++++++++++++++++++++------------ 2 files changed, 150 insertions(+), 84 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4f136eec7..3949aa969 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,6 +35,7 @@ import threading import time import warnings +from collections import OrderedDict from typing import Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints @@ -1121,8 +1122,9 @@ def __init__(self) -> None: def add(self, entry: DNSRecord) -> None: """Adds an entry""" - # Insert first in list so get returns newest entry - self.cache.setdefault(entry.key, []).insert(0, entry) + # Insert last in list, get will return newest entry + # iteration will result in last update winning + self.cache.setdefault(entry.key, []).append(entry) def remove(self, entry: DNSRecord) -> None: """Removes an entry""" @@ -1142,7 +1144,7 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: matching entry.""" try: list_ = self.cache[entry.key] - for cached_entry in list_: + for cached_entry in reversed(list_): if entry.__eq__(cached_entry): return cached_entry return None @@ -1164,7 +1166,7 @@ def entries_with_name(self, name: str) -> List[DNSRecord]: def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() - for record in self.entries_with_name(name): + for record in reversed(self.entries_with_name(name)): if ( record.type == _TYPE_PTR and not record.is_expired(now) @@ -1400,7 +1402,7 @@ def __init__( self.services = {} # type: Dict[str, DNSRecord] self.next_time = current_time_millis() self.delay = delay - self._handlers_to_call = [] # type: List[Callable[[Zeroconf], None]] + self._handlers_to_call = OrderedDict() # type: OrderedDict[str, ServiceStateChange] self._service_state_changed = Signal() @@ -1445,14 +1447,30 @@ def service_state_changed(self) -> SignalRegistrationInterface: def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: """Callback invoked by Zeroconf when new information arrives. - Updates information required by browser in the Zeroconf cache.""" + Updates information required by browser in the Zeroconf cache. + + Ensures that there is are no unecessary duplicates in the list + + """ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: - self._handlers_to_call.append( - lambda zeroconf: self._service_state_changed.fire( - zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change + + # Code to ensure we only do a single update message + # Precedence is; Added, Remove, Update + + if ( + state_change is ServiceStateChange.Added + or ( + state_change is ServiceStateChange.Removed + and ( + self._handlers_to_call.get(name) is ServiceStateChange.Updated + or self._handlers_to_call.get(name) is ServiceStateChange.Added + or self._handlers_to_call.get(name) is None + ) ) - ) + or (state_change is ServiceStateChange.Updated and name not in self._handlers_to_call) + ): + self._handlers_to_call[name] = state_change if record.type == _TYPE_PTR and record.name == self.type: assert isinstance(record, DNSPointer) @@ -1476,8 +1494,20 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: if expires < self.next_time: self.next_time = expires - elif record.type == _TYPE_TXT and record.name.endswith(self.type): - assert isinstance(record, DNSText) + elif record.type == _TYPE_A or record.type == _TYPE_AAAA: + assert isinstance(record, DNSAddress) + + # Iterate through the DNSCache and callback any services that use this address + for service in zc.cache.entries(): + if ( + isinstance(service, DNSService) + and service.name.endswith(self.type) + and service.server == record.name + and not record.is_expired(now) + ): + enqueue_callback(ServiceStateChange.Updated, service.name) + + elif record.name.endswith(self.type): expired = record.is_expired(now) if not expired: enqueue_callback(ServiceStateChange.Updated, record.name) @@ -1509,8 +1539,11 @@ def run(self) -> None: self.delay = min(_BROWSER_BACKOFF_LIMIT * 1000, self.delay * 2) if len(self._handlers_to_call) > 0 and not self.zc.done: - handler = self._handlers_to_call.pop(0) - handler(self.zc) + with self.zc._handlers_lock: + handler = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.zc, service_type=self.type, name=handler[0], state_change=handler[1] + ) class ServiceInfo(RecordUpdateListener): @@ -2173,6 +2206,8 @@ def __init__( self.debug = None # type: Optional[DNSOutgoing] + self._handlers_lock = threading.Lock() # ensure we process a full message in one go + @property def done(self) -> bool: return self._GLOBAL_DONE @@ -2449,42 +2484,45 @@ def update_record(self, now: float, rec: DNSRecord) -> None: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" - now = current_time_millis() - for record in msg.answers: - - updated = True - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # Since the cache format is keyed on the lower case record name - # we can avoid iterating everything in the cache and - # only look though entries for the specific name. - # entries_with_name will take care of converting to lowercase - # - # We make a copy of the list that entries_with_name returns - # since we cannot iterate over something we might remove - for entry in self.cache.entries_with_name(record.name).copy(): - if entry == record: - updated = False + with self._handlers_lock: - # Check the time first because it is far cheaper - # than the __eq__ - if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): - self.cache.remove(entry) - - expired = record.is_expired(now) - maybe_entry = self.cache.get(record) - if not expired: - if maybe_entry is not None: - maybe_entry.reset_ttl(record) + now = current_time_millis() + for record in msg.answers: + + updated = True + + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + # Since the cache format is keyed on the lower case record name + # we can avoid iterating everything in the cache and + # only look though entries for the specific name. + # entries_with_name will take care of converting to lowercase + # + # We make a copy of the list that entries_with_name returns + # since we cannot iterate over something we might remove + for entry in self.cache.entries_with_name(record.name).copy(): + + if entry == record: + updated = False + + # Check the time first because it is far cheaper + # than the __eq__ + if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): + self.cache.remove(entry) + + expired = record.is_expired(now) + maybe_entry = self.cache.get(record) + if not expired: + if maybe_entry is not None: + maybe_entry.reset_ttl(record) + else: + self.cache.add(record) + if updated: + self.update_record(now, record) else: - self.cache.add(record) - if updated: - self.update_record(now, record) - else: - if maybe_entry is not None: - self.update_record(now, record) - self.cache.remove(maybe_entry) + if maybe_entry is not None: + self.update_record(now, record) + self.cache.remove(maybe_entry) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if diff --git a/zeroconf/test.py b/zeroconf/test.py index 13df725c1..dd6fc21d0 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -292,7 +292,7 @@ def test_numbers(self): def test_numbers_questions(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - for i in range(10): + for i in range(10): # pylint: disable=unused-variable generated.add_question(question) bytes = generated.packet() (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) @@ -756,7 +756,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): """Sends an outgoing packet.""" nonlocal nbr_answers, nbr_additionals, nbr_authorities - for answer, time_ in out.answers: + for answer, time_ in out.answers: # pylint: disable=unused-variable nbr_answers += 1 assert answer.ttl == get_ttl(answer.type) for answer in out.additionals: @@ -1053,12 +1053,12 @@ def test_update_record(self): service_name = 'name._type._tcp.local.' service_type = '_type._tcp.local.' - service_server = 'ash-2.local.' - service_text = b'path=/~paulsm/' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' service_address = '10.0.1.2' - service_added = False - service_removed = False + service_added_count = 0 + service_removed_count = 0 service_updated_count = 0 service_add_event = Event() service_removed_event = Event() @@ -1066,49 +1066,44 @@ def test_update_record(self): class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: - nonlocal service_added - service_added = True + nonlocal service_added_count + service_added_count += 1 service_add_event.set() def remove_service(self, zc, type_, name) -> None: - nonlocal service_added, service_removed - service_added = False - service_removed = True + nonlocal service_removed_count + service_removed_count += 1 service_removed_event.set() def update_service(self, zc, type_, name) -> None: nonlocal service_updated_count service_updated_count += 1 - service_info = zc.get_service_info(type_, name) + assert service_info.addresses[0] == socket.inet_aton(service_address) assert service_info.text == service_text + assert service_info.server == service_server service_updated_event.set() def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - ttl = 120 - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - if service_state_change == r.ServiceStateChange.Updated: - generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 - ) - return r.DNSIncoming(generated.packet()) + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) if service_state_change == r.ServiceStateChange.Removed: ttl = 0 + else: + ttl = 120 generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 ) + generated.add_answer_at_time( r.DNSService( service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server ), 0, ) - generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 - ) + generated.add_answer_at_time( r.DNSAddress( service_server, @@ -1120,36 +1115,69 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi 0, ) + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + ) + return r.DNSIncoming(generated.packet()) zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) try: + wait_time = 3 + # service added zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) - service_add_event.wait(1) - service_updated_event.wait(1) - assert service_added is True - assert service_updated_count == 1 - assert service_removed is False + service_add_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 0 + assert service_removed_count == 0 - # service updated. currently only text record can be updated + # service SRV updated service_updated_event.clear() - service_text = b'path=/~humingchun/' + service_server = 'ash-2.local.' zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 1 + assert service_removed_count == 0 + + # service TXT updated + service_updated_event.clear() + service_text = b'path=/~matt2/' zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(1) - assert service_added is True + service_updated_event.wait(wait_time) + assert service_added_count == 1 assert service_updated_count == 2 - assert service_removed is False + assert service_removed_count == 0 + + # service A updated + service_updated_event.clear() + service_address = '10.0.1.3' + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 3 + assert service_removed_count == 0 + + # service all updated + service_updated_event.clear() + service_server = 'ash-3.local.' + service_text = b'path=/~matt3/' + service_address = '10.0.1.3' + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 0 # service removed zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) - service_removed_event.wait(1) - assert service_added is False - assert service_updated_count == 2 - assert service_removed is True + service_removed_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 1 finally: service_browser.cancel() From 36941aeb72711f7954d40f0abeab4802174636df Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 26 Apr 2020 02:53:27 +0200 Subject: [PATCH 0069/1433] Release version 0.26.0 --- README.rst | 11 +++++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e24ddff0c..b23cee065 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,17 @@ See examples directory for more. Changelog ========= +0.26.0 +------ + +* Fixed a regression where service update listener wasn't called on IP address change (it's called + on SRV/A/AAAA record changes now), thanks to Matt Saxon + +Technically backwards incompatible: + +* Service update hook is no longer called on service addition (service added hook is still called), + this is related to the fix above + 0.25.1 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3949aa969..feac8cbd7 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.25.1' +__version__ = '0.26.0' __license__ = 'LGPL' From 16431b6cb51f561a4c5d2897e662b254ca4243ec Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Sun, 26 Apr 2020 11:22:30 +0100 Subject: [PATCH 0070/1433] Update .gitignore for Visual Studio config files (#244) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ddf8a0d7e..eac2c1700 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ Thumbs.db .cache .mypy_cache/ docs/_build/ +.vscode From 0540342bacd859f38f6d2a3743a7959cd3ae4d02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 May 2020 11:12:56 -0500 Subject: [PATCH 0071/1433] Avoid iterating the entire cache when an A/AAAA address has not changed (#247) Iterating the cache is an expensive operation when there is 100s of devices generating zeroconf traffic as there can be 1000s of entries in the cache. --- zeroconf/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index feac8cbd7..a4ac31ace 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1496,6 +1496,20 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: elif record.type == _TYPE_A or record.type == _TYPE_AAAA: assert isinstance(record, DNSAddress) + if record.is_expired(now): + return + + address_changed = False + for service in zc.cache.entries_with_name(record.name): + if isinstance(service, DNSAddress) and service.address != record.address: + address_changed = True + break + + # Avoid iterating the entire DNSCache if the address has not changed + # as this is an expensive operation when there many hosts + # generating zeroconf traffic. + if not address_changed: + return # Iterate through the DNSCache and callback any services that use this address for service in zc.cache.entries(): @@ -1503,7 +1517,6 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: isinstance(service, DNSService) and service.name.endswith(self.type) and service.server == record.name - and not record.is_expired(now) ): enqueue_callback(ServiceStateChange.Updated, service.name) From 0dd6fe44ca3895375ba447fed5f138042ab12ebf Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 6 May 2020 18:17:29 +0200 Subject: [PATCH 0072/1433] Remove unwanted pylint directives Those are results of a bad conflict resolution I did when merging [1]. [1] 552a030eb592 ("Call UpdateService on SRV & A/AAAA updates as well as TXT (#239)") --- zeroconf/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index dd6fc21d0..4e54c1d19 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -292,7 +292,7 @@ def test_numbers(self): def test_numbers_questions(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - for i in range(10): # pylint: disable=unused-variable + for i in range(10): generated.add_question(question) bytes = generated.packet() (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) @@ -756,7 +756,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): """Sends an outgoing packet.""" nonlocal nbr_answers, nbr_additionals, nbr_authorities - for answer, time_ in out.answers: # pylint: disable=unused-variable + for answer, time_ in out.answers: nbr_answers += 1 assert answer.ttl == get_ttl(answer.type) for answer in out.additionals: From 4c359e2e7cdf104efca90ffd9912ea7c7792e3bf Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 6 May 2020 18:25:51 +0200 Subject: [PATCH 0073/1433] Release version 0.26.1 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b23cee065..f70aabb20 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.26.1 +------ + +* Fixed a performance regression introduced in 0.26.0, thanks to J. Nick Koston (this is close in + spirit to an optimization made in 0.24.5 by the same author) + 0.26.0 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a4ac31ace..1a83c5795 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.26.0' +__version__ = '0.26.1' __license__ = 'LGPL' From 4b1d953979287e08f914857867da1000634ca3af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2020 13:55:19 -0500 Subject: [PATCH 0074/1433] Fix flake8 E741 in setup.py (#252) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a011e6028..57b8b0bec 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ readme = f.read() version = ( - [l for l in open(join(PROJECT_ROOT, 'zeroconf', '__init__.py')) if '__version__' in l][0] + [ln for ln in open(join(PROJECT_ROOT, 'zeroconf', '__init__.py')) if '__version__' in ln][0] .split('=')[-1] .strip() .strip('\'"') From 24a06191ea35469948d12124a07429207b3c1b3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2020 17:12:17 +0000 Subject: [PATCH 0075/1433] Fix race condition where a listener gets a message before the lock is created. --- zeroconf/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1a83c5795..6e7befe4e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2208,6 +2208,11 @@ def __init__( self.condition = threading.Condition() + # Ensure we create the lock before + # we add the listener as we could get + # a message before the lock is created. + self._handlers_lock = threading.Lock() # ensure we process a full message in one go + self.engine = Engine(self) self.listener = Listener(self) if not unicast: @@ -2219,8 +2224,6 @@ def __init__( self.debug = None # type: Optional[DNSOutgoing] - self._handlers_lock = threading.Lock() # ensure we process a full message in one go - @property def done(self) -> bool: return self._GLOBAL_DONE From a6ad100a60e8434cef6b411208eef98f68d594d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 May 2020 19:39:46 +0000 Subject: [PATCH 0076/1433] Add support for multiple types to ServiceBrowsers As each ServiceBrowser runs in its own thread there is a scale problem when listening for many types. ServiceBrowser can now accept a list of types in addition to a single type. --- examples/browser.py | 4 +- zeroconf/__init__.py | 89 +++++++++++++++++++++++++------------------- zeroconf/test.py | 73 ++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 40 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index bf3ebfbdc..c4ddac391 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -55,7 +55,9 @@ def on_service_state_change( zeroconf = Zeroconf(ip_version=ip_version) print("\nBrowsing services, press Ctrl-C to exit...\n") - browser = ServiceBrowser(zeroconf, "_http._tcp.local.", handlers=[on_service_state_change]) + browser = ServiceBrowser( + zeroconf, ["_http._tcp.local.", "_hap._tcp.local."], handlers=[on_service_state_change] + ) try: while True: diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6e7befe4e..4b93e4532 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1379,7 +1379,7 @@ class ServiceBrowser(RecordUpdateListener, threading.Thread): def __init__( self, zc: 'Zeroconf', - type_: str, + type_: Union[str, list], # NOTE: Callable quoting needed on Python 3.5.2, see # https://github.com/jstasiak/python-zeroconf/issues/208 for details. handlers: Optional[Union[ServiceListener, List['Callable[..., None]']]] = None, @@ -1390,19 +1390,23 @@ def __init__( ) -> None: """Creates a browser for a specific type""" assert handlers or listener, 'You need to specify at least one handler' - if not type_.endswith(service_type_name(type_, allow_underscores=True)): - raise BadTypeInNameException - threading.Thread.__init__(self, name='zeroconf-ServiceBrowser_' + type_) + self.types = set(type_ if isinstance(type_, list) else [type_]) + for check_type_ in self.types: + if not check_type_.endswith(service_type_name(check_type_, allow_underscores=True)): + raise BadTypeInNameException + threading.Thread.__init__(self, name='zeroconf-ServiceBrowser_' + '-'.join(self.types)) self.daemon = True self.zc = zc - self.type = type_ self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - self.services = {} # type: Dict[str, DNSRecord] - self.next_time = current_time_millis() - self.delay = delay - self._handlers_to_call = OrderedDict() # type: OrderedDict[str, ServiceStateChange] + self._services = { + check_type_: {} for check_type_ in self.types + } # type: Dict[str, Dict[str, DNSRecord]] + current_time = current_time_millis() + self._next_time = {check_type_: current_time for check_type_ in self.types} + self._delay = {check_type_: delay for check_type_ in self.types} + self._handlers_to_call = OrderedDict() # type: OrderedDict[str, Tuple[str, ServiceStateChange]] self._service_state_changed = Signal() @@ -1453,7 +1457,7 @@ def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: """ - def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: + def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> None: # Code to ensure we only do a single update message # Precedence is; Added, Remove, Update @@ -1470,29 +1474,29 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: ) or (state_change is ServiceStateChange.Updated and name not in self._handlers_to_call) ): - self._handlers_to_call[name] = state_change + self._handlers_to_call[name] = (type_, state_change) - if record.type == _TYPE_PTR and record.name == self.type: + if record.type == _TYPE_PTR and record.name in self.types: assert isinstance(record, DNSPointer) expired = record.is_expired(now) service_key = record.alias.lower() try: - old_record = self.services[service_key] + old_record = self._services[record.name][service_key] except KeyError: if not expired: - self.services[service_key] = record - enqueue_callback(ServiceStateChange.Added, record.alias) + self._services[record.name][service_key] = record + enqueue_callback(ServiceStateChange.Added, record.name, record.alias) else: if not expired: old_record.reset_ttl(record) else: - del self.services[service_key] - enqueue_callback(ServiceStateChange.Removed, record.alias) + del self._services[record.name][service_key] + enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) return expires = record.get_expiration_time(75) - if expires < self.next_time: - self.next_time = expires + if expires < self._next_time[record.name]: + self._next_time[record.name] = expires elif record.type == _TYPE_A or record.type == _TYPE_AAAA: assert isinstance(record, DNSAddress) @@ -1513,17 +1517,16 @@ def enqueue_callback(state_change: ServiceStateChange, name: str) -> None: # Iterate through the DNSCache and callback any services that use this address for service in zc.cache.entries(): - if ( - isinstance(service, DNSService) - and service.name.endswith(self.type) - and service.server == record.name - ): - enqueue_callback(ServiceStateChange.Updated, service.name) + if not isinstance(service, DNSService) or not service.server == record.name: + continue + for type_ in self.types: + if service.name.endswith(type_): + enqueue_callback(ServiceStateChange.Updated, type_, service.name) - elif record.name.endswith(self.type): - expired = record.is_expired(now) - if not expired: - enqueue_callback(ServiceStateChange.Updated, record.name) + elif not record.is_expired(now): + for type_ in self.types: + if record.name.endswith(type_): + enqueue_callback(ServiceStateChange.Updated, type_, record.name) def cancel(self) -> None: self.done = True @@ -1531,31 +1534,39 @@ def cancel(self) -> None: self.join() def run(self) -> None: - self.zc.add_listener(self, DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) + for type_ in self.types: + self.zc.add_listener(self, DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) while True: now = current_time_millis() - if len(self._handlers_to_call) == 0 and self.next_time > now: - self.zc.wait(self.next_time - now) + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + if len(self._handlers_to_call) == 0 and next_time > now: + self.zc.wait(next_time - now) if self.zc.done or self.done: return now = current_time_millis() - if self.next_time <= now: + for type_ in self.types: + if self._next_time[type_] > now: + continue out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) - out.add_question(DNSQuestion(self.type, _TYPE_PTR, _CLASS_IN)) - for record in self.services.values(): + out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) + for record in self._services[type_].values(): if not record.is_stale(now): out.add_answer_at_time(record, now) self.zc.send(out, addr=self.addr, port=self.port) - self.next_time = now + self.delay - self.delay = min(_BROWSER_BACKOFF_LIMIT * 1000, self.delay * 2) + self._next_time[type_] = now + self._delay[type_] + self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) if len(self._handlers_to_call) > 0 and not self.zc.done: with self.zc._handlers_lock: - handler = self._handlers_to_call.popitem(False) + (name, service_type_state_change) = self._handlers_to_call.popitem(False) self._service_state_changed.fire( - zeroconf=self.zc, service_type=self.type, name=handler[0], state_change=handler[1] + zeroconf=self.zc, + service_type=service_type_state_change[0], + name=name, + state_change=service_type_state_change[1], ) diff --git a/zeroconf/test.py b/zeroconf/test.py index 4e54c1d19..c1993a580 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1185,6 +1185,79 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi zeroconf.close() +class TestServiceBrowserMultipleTypes(unittest.TestCase): + def test_update_record(self): + + service_names = ['name._type._tcp.local.', 'name._type._udp.local'] + service_types = ['_type._tcp.local.', '_type._udp.local.'] + + service_added_count = 0 + service_removed_count = 0 + service_add_event = Event() + service_removed_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added_count + service_added_count += 1 + if service_added_count == 2: + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_removed_count + service_removed_count += 1 + if service_removed_count == 2: + service_removed_event.set() + + def mock_incoming_msg( + service_state_change: r.ServiceStateChange, service_type: str, service_name: str + ) -> r.DNSIncoming: + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + else: + ttl = 120 + + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + ) + return r.DNSIncoming(generated.packet()) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) + + try: + wait_time = 3 + + # both services added + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0]) + ) + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1]) + ) + service_add_event.wait(wait_time) + assert service_added_count == 2 + assert service_removed_count == 0 + + # both services removed + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0]) + ) + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1]) + ) + service_removed_event.wait(wait_time) + assert service_added_count == 2 + assert service_removed_count == 2 + + finally: + service_browser.cancel() + zeroconf.remove_all_service_listeners() + zeroconf.close() + + def test_backoff(): got_query = Event() From aa9de4de7202b3ab0a60f14532d227f63d7d981b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 24 May 2020 21:54:06 +0200 Subject: [PATCH 0077/1433] Improve readability of logged incoming data (#254) --- zeroconf/__init__.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4b93e4532..041dd6b29 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -719,6 +719,20 @@ def __init__(self, data: bytes) -> None: except (IndexError, struct.error, IncomingDecodeError): self.log_exception_warning(('Choked at offset %d while unpacking %r', self.offset, data)) + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'id=%s' % self.id, + 'flags=%s' % self.flags, + 'n_q=%s' % self.num_questions, + 'n_ans=%s' % self.num_answers, + 'n_auth=%s' % self.num_authorities, + 'n_add=%s' % self.num_additionals, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + ] + ) + def unpack(self, format_: bytes) -> tuple: length = struct.calcsize(format_) info = struct.unpack(format_, self.data[self.offset : self.offset + length]) @@ -1279,10 +1293,13 @@ def handle_read(self, socket_: socket.socket) -> None: self.log_exception_warning() return - log.debug('Received from %r:%r: %r ', addr, port, data) - self.data = data msg = DNSIncoming(data) + if msg.valid: + log.debug('Received from %r:%r: %r (%d bytes) as [%r]', addr, port, msg, len(data), data) + else: + log.debug('Received from %r:%r: (%d bytes) [%r]', addr, port, len(data), data) + if not msg.valid: pass @@ -2695,7 +2712,7 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return - log.debug('Sending %r (%d bytes) as %r...', out, len(packet), packet) + log.debug('Sending %r (%d bytes) as [%r]', out, len(packet), packet) for s in self._respond_sockets: if self._GLOBAL_DONE: return From 1c4d3fcbf34b09364e52a773783dc9c924a7b17a Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 22 May 2020 21:41:42 +0200 Subject: [PATCH 0078/1433] Merge 0.26.2 release commit I accidentally only pushed 0.26.2 tag (commit ffb42e5836bd) without pushing the commit to master and now I merged aa9de4de7202 so this is the best I can do without force-pushing to master. Tag 0.26.2 will continue to point to that dangling commit. --- README.rst | 7 +++++++ zeroconf/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f70aabb20..c335e2c85 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,13 @@ See examples directory for more. Changelog ========= +0.26.2 +------ + +* Added support for multiple types to ServiceBrowser, thanks to J. Nick Koston +* Fixed a race condition where a listener gets a message before the lock is created, thanks to + J. Nick Koston + 0.26.1 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 041dd6b29..e21a7f39c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.26.1' +__version__ = '0.26.2' __license__ = 'LGPL' From 445d7f5dbe38947bd0bd1e3a5b8d649c1819c21f Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 24 May 2020 22:48:03 +0200 Subject: [PATCH 0079/1433] Use equality comparison instead of identity comparison for ints Integers aren't guaranteed to have the same identity even though they may be equal. --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e21a7f39c..e2732a741 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2064,7 +2064,7 @@ def new_socket( if not err.errno == errno.ENOPROTOOPT: raise - if port is _MDNS_PORT: + if port == _MDNS_PORT: ttl = struct.pack(b'B', 255) loop = struct.pack(b'B', 1) if ip_version != IPVersion.V6Only: From 54d116fd69a66062f91be04d84ceaebcfb13cc43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 May 2020 23:27:07 +0200 Subject: [PATCH 0080/1433] Give threads unique names (#257) --- zeroconf/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e2732a741..a7b41ca4b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1213,7 +1213,7 @@ class Engine(threading.Thread): """ def __init__(self, zc: 'Zeroconf') -> None: - threading.Thread.__init__(self, name='zeroconf-Engine') + threading.Thread.__init__(self) self.daemon = True self.zc = zc self.readers = {} # type: Dict[socket.socket, Listener] @@ -1221,6 +1221,7 @@ def __init__(self, zc: 'Zeroconf') -> None: self.condition = threading.Condition() self.socketpair = socket.socketpair() self.start() + self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) def run(self) -> None: while not self.zc.done: @@ -1324,10 +1325,11 @@ class Reaper(threading.Thread): have expired.""" def __init__(self, zc: 'Zeroconf') -> None: - threading.Thread.__init__(self, name='zeroconf-Reaper') + threading.Thread.__init__(self) self.daemon = True self.zc = zc self.start() + self.name = "zeroconf-Reaper_%s" % (getattr(self, 'native_id', self.ident),) def run(self) -> None: while True: @@ -1411,7 +1413,7 @@ def __init__( for check_type_ in self.types: if not check_type_.endswith(service_type_name(check_type_, allow_underscores=True)): raise BadTypeInNameException - threading.Thread.__init__(self, name='zeroconf-ServiceBrowser_' + '-'.join(self.types)) + threading.Thread.__init__(self) self.daemon = True self.zc = zc self.addr = addr @@ -1460,6 +1462,10 @@ def on_change( self.service_state_changed.register_handler(h) self.start() + self.name = "zeroconf-ServiceBrowser_%s_%s" % ( + '-'.join(self.types), + getattr(self, 'native_id', self.ident), + ) @property def service_state_changed(self) -> SignalRegistrationInterface: From fe865667e4610d57067a8f710f4d818eaa5e14dc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 May 2020 22:50:13 +0200 Subject: [PATCH 0081/1433] Don't call callbacks when holding _handlers_lock (#258) Closes #255 Background: #239 adds the lock _handlers_lock: python-zeroconf/zeroconf/__init__.py self._handlers_lock = threading.Lock() # ensure we process a full message in one go Which is used in the engine thread: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" with self._handlers_lock: And also by the service browser when issuing the state change callbacks: if len(self._handlers_to_call) > 0 and not self.zc.done: with self.zc._handlers_lock: handler = self._handlers_to_call.popitem(False) self._service_state_changed.fire( zeroconf=self.zc, service_type=self.type, name=handler[0], state_change=handler[1] ) Both pychromecast and Home Assistant calls Zeroconf.get_service_info from the service callbacks which means the lock may be held for several seconds which will starve the engine thread. --- zeroconf/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a7b41ca4b..85a187ea2 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1585,12 +1585,12 @@ def run(self) -> None: if len(self._handlers_to_call) > 0 and not self.zc.done: with self.zc._handlers_lock: (name, service_type_state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=service_type_state_change[0], - name=name, - state_change=service_type_state_change[1], - ) + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=service_type_state_change[0], + name=name, + state_change=service_type_state_change[1], + ) class ServiceInfo(RecordUpdateListener): From fbcefca592632304579c1b3f9c7bd3dd342e1618 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 26 May 2020 23:01:48 +0200 Subject: [PATCH 0082/1433] Release version 0.26.3 --- README.rst | 9 +++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c335e2c85..84c45906d 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,15 @@ See examples directory for more. Changelog ========= +0.26.3 +------ + +* Improved readability of logged incoming data, thanks to Erik Montnemery +* Threads are given unique names now to aid debugging, thanks to Erik Montnemery +* Fixed a regression where get_service_info() called within a listener add_service method + would deadlock, timeout and incorrectly return None, fix thanks to Erik Montnemery, but + Matt Saxon and Hmmbob were also involved in debugging it. + 0.26.2 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 85a187ea2..76c5a4317 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.26.2' +__version__ = '0.26.3' __license__ = 'LGPL' From ab72aa8e5a6a83e50d24d7fb187e8fa8a549a847 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 26 May 2020 23:05:27 +0200 Subject: [PATCH 0083/1433] Remove deprecated ServiceInfo address parameter/property (#260) --- zeroconf/__init__.py | 30 ------------ zeroconf/test.py | 111 ++++++++++++++++++++++++++----------------- 2 files changed, 68 insertions(+), 73 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 76c5a4317..00bf1c6d5 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -34,7 +34,6 @@ import sys import threading import time -import warnings from collections import OrderedDict from typing import Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints @@ -1600,7 +1599,6 @@ class ServiceInfo(RecordUpdateListener): * type_: fully qualified service type name * name: fully qualified service name - * address: IP address as unsigned short, network byte order (deprecated, use addresses) * port: port that the service runs on * weight: weight of the service * priority: priority of the service @@ -1624,7 +1622,6 @@ def __init__( self, type_: str, name: str, - address: Optional[Union[bytes, List[bytes]]] = None, port: Optional[int] = None, weight: int = 0, priority: int = 0, @@ -1635,22 +1632,12 @@ def __init__( *, addresses: Optional[List[bytes]] = None ) -> None: - # Accept both none, or one, but not both. - if address is not None and addresses is not None: - raise TypeError("address and addresses cannot be provided together") - if not type_.endswith(service_type_name(name, allow_underscores=True)): raise BadTypeInNameException self.type = type_ self.name = name if addresses is not None: self._addresses = addresses - elif address is not None: - warnings.warn("address is deprecated, use addresses instead", DeprecationWarning) - if isinstance(address, list): - self._addresses = address - else: - self._addresses = [address] else: self._addresses = [] # This results in an ugly error when registering, better check now @@ -1672,23 +1659,6 @@ def __init__( self.other_ttl = other_ttl # fmt: on - @property - def address(self) -> Optional[bytes]: - warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) - try: - # Return the first V4 address for compatibility - return self.addresses[0] - except IndexError: - return None - - @address.setter - def address(self, value: bytes) -> None: - warnings.warn("ServiceInfo.address is deprecated, use addresses instead", DeprecationWarning) - if value is None: - self._addresses = [] - else: - self._addresses = [value] - @property def addresses(self) -> List[bytes]: """IPv4 addresses of this service. diff --git a/zeroconf/test.py b/zeroconf/test.py index c1993a580..2bc642c89 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -108,7 +108,7 @@ def test_service_info_dunder(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) info = ServiceInfo( - type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, b'', "ash-2.local." + type_, registration_name, 80, 0, 0, b'', "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) assert not info != info @@ -121,7 +121,7 @@ def test_service_info_text_properties_not_given(self): info = ServiceInfo( type_=type_, name=registration_name, - address=socket.inet_aton("10.0.1.2"), + addresses=[socket.inet_aton("10.0.1.2")], port=80, server="ash-2.local.", ) @@ -456,7 +456,14 @@ def on_service_state_change(zeroconf, service_type, state_change, name): def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} info_service = ServiceInfo( - type_, '%s.%s' % (name, type_), socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) # verify name conflict @@ -736,7 +743,14 @@ def test_ttl(self): desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) # we are going to monkey patch the zeroconf send to check packet sizes @@ -849,7 +863,14 @@ def test_integration_with_listener(self): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) @@ -874,7 +895,14 @@ def test_integration_with_listener_v6_records(self): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, socket.inet_pton(socket.AF_INET6, addr), 80, 0, 0, desc, "ash-2.local." + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) zeroconf_registrar.register_service(info) @@ -898,7 +926,14 @@ def test_integration_with_listener_ipv6(self): zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) @@ -922,7 +957,14 @@ def test_integration_with_subtype_and_listener(self): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} info = ServiceInfo( - discovery_type, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + discovery_type, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) @@ -1028,7 +1070,14 @@ def update_service(self, zeroconf, type, name): properties['prop_blank'] = b'an updated string' desc.update(properties) info_service = ServiceInfo( - subtype, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local." + subtype, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.update_service(info_service) service_updated.wait(1) @@ -1385,7 +1434,9 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} - info = ServiceInfo(type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local.") + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) zeroconf_registrar.register_service(info) try: @@ -1424,45 +1475,17 @@ def test_multiple_addresses(): address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - # Old way - info = ServiceInfo(type_, registration_name, address, 80, 0, 0, desc, "ash-2.local.") - - assert info.address == address - assert info.addresses == [address] - - # Updating works - address2 = socket.inet_aton("10.0.1.3") - info.address = address2 - - assert info.address == address2 - assert info.addresses == [address2] - - info.address = None - - assert info.address is None - assert info.addresses == [] - - info.addresses = [address2] - - assert info.address == address2 - assert info.addresses == [address2] - - # Compatibility way - info = ServiceInfo(type_, registration_name, [address, address], 80, 0, 0, desc, "ash-2.local.") - - assert info.addresses == [address, address] - # New kwarg way - info = ServiceInfo( - type_, registration_name, None, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) assert info.addresses == [address, address] if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) - info = ServiceInfo(type_, registration_name, [address, address_v6], 80, 0, 0, desc, "ash-2.local.") + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address_v6], + ) assert info.addresses == [address] assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] @@ -1483,7 +1506,9 @@ def test_ptr_optimization(): registration_name = "%s.%s" % (name, type_) desc = {'path': '/~paulsm/'} - info = ServiceInfo(type_, registration_name, socket.inet_aton("10.0.1.2"), 80, 0, 0, desc, "ash-2.local.") + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) # we are going to monkey patch the zeroconf send to check packet sizes old_send = zc.send From 87a0fe27a7be9d96af08f8a007f37a16105c64a0 Mon Sep 17 00:00:00 2001 From: gjbadros Date: Tue, 26 May 2020 14:07:47 -0700 Subject: [PATCH 0084/1433] Separately send large mDNS responses to comply with RFC 6762 (#248) This fixes issue #245 Split up large multi-response packets into separate packets instead of relying on IP Fragmentation. IP Fragmentation of mDNS packets causes ChromeCast Audios to crash their mDNS responder processes and RFC 6762 (https://tools.ietf.org/html/rfc6762) section 17 states some requirements for Multicast DNS Message Size, and the fourth paragraph reads: "A Multicast DNS packet larger than the interface MTU, which is sent using fragments, MUST NOT contain more than one resource record." This change makes this implementation conform with this MUST NOT clause. --- zeroconf/__init__.py | 178 ++++++++++++++++++++++++++++++------------- zeroconf/test.py | 34 +++++---- 2 files changed, 146 insertions(+), 66 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 00bf1c6d5..9fa6ae5cd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -874,9 +874,13 @@ def __init__(self, flags: int, multicast: bool = True) -> None: self.id = 0 self.multicast = multicast self.flags = flags + self.packets_data = [] # type: List[bytes] + + # these 3 are per-packet -- see also reset_for_next_packet() self.names = {} # type: Dict[str, int] self.data = [] # type: List[bytes] self.size = 12 + self.state = self.State.init self.questions = [] # type: List[DNSQuestion] @@ -884,6 +888,11 @@ def __init__(self, flags: int, multicast: bool = True) -> None: self.authorities = [] # type: List[DNSPointer] self.additionals = [] # type: List[DNSRecord] + def reset_for_next_packet(self) -> None: + self.names = {} + self.data = [] + self.size = 12 + def __repr__(self) -> str: return '' % ', '.join( [ @@ -1058,11 +1067,13 @@ def write_question(self, question: DNSQuestion) -> None: self.write_short(question.type) self.write_short(question.class_) - def write_record(self, record: DNSRecord, now: float) -> int: + def write_record(self, record: DNSRecord, now: float, allow_long: bool = False) -> bool: """Writes a record (answer, authoritative answer, additional) to - the packet""" + the packet. Returns True on success, or False if we did not (either + because the packet was already finished or because the record does + not fit.""" if self.state == self.State.finished: - return 1 + return False start_data_length, start_size = len(self.data), self.size self.write_name(record.name) @@ -1086,44 +1097,102 @@ def write_record(self, record: DNSRecord, now: float) -> int: # Here is the short we adjusted for self.insert_short(index, length) + len_limit = _MAX_MSG_ABSOLUTE if allow_long else _MAX_MSG_TYPICAL + # if we go over, then rollback and quit - if self.size > _MAX_MSG_ABSOLUTE: + if self.size > len_limit: while len(self.data) > start_data_length: self.data.pop() self.size = start_size - self.state = self.State.finished - return 1 - return 0 + return False + return True def packet(self) -> bytes: - """Returns a string containing the packet's bytes + """Returns a bytestring containing the first packet's bytes. + + Generally, you want to use packets() in case the response + does not fit in a single packet, but this exists for + backward compatibility.""" + packets = self.packets() + if len(packets) > 0: + if len(packets[0]) > _MAX_MSG_ABSOLUTE: + QuietLogger.log_warning_once( + "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] + ) + return packets[0] + else: + return b'' - No further parts should be added to the packet once this - is done.""" + def packets(self) -> List[bytes]: + """Returns a list of bytestrings containing the packets' bytes - overrun_answers, overrun_authorities, overrun_additionals = 0, 0, 0 + No further parts should be added to the packet once this + is done. The packets are each restricted to _MAX_MSG_TYPICAL + or less in length, except for the case of a single answer which + will be written out to a single oversized packet no more than + _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP + fragmentation potentially). """ - if self.state != self.State.finished: + if self.state == self.State.finished: + return self.packets_data + + answer_offset = 0 + authority_offset = 0 + additional_offset = 0 + + # we have to at least write out the question + first_time = True + + while ( + first_time + or answer_offset < len(self.answers) + or authority_offset < len(self.authorities) + or additional_offset < len(self.additionals) + ): + first_time = False + log.debug("offsets = %d, %d, %d", answer_offset, authority_offset, additional_offset) + log.debug("lengths = %d, %d, %d", len(self.answers), len(self.authorities), len(self.additionals)) + + additionals_written = 0 + authorities_written = 0 + answers_written = 0 + questions_written = 0 for question in self.questions: self.write_question(question) - for answer, time_ in self.answers: - overrun_answers += self.write_record(answer, time_) - for authority in self.authorities: - overrun_authorities += self.write_record(authority, 0) - for additional in self.additionals: - overrun_additionals += self.write_record(additional, 0) - self.state = self.State.finished - - self.insert_short(0, len(self.additionals) - overrun_additionals) - self.insert_short(0, len(self.authorities) - overrun_authorities) - self.insert_short(0, len(self.answers) - overrun_answers) - self.insert_short(0, len(self.questions)) + questions_written += 1 + allow_long = True # at most one answer is allowed to be a long packet + for answer, time_ in self.answers[answer_offset:]: + if self.write_record(answer, time_, allow_long): + answers_written += 1 + allow_long = False + for authority in self.authorities[authority_offset:]: + if self.write_record(authority, 0): + authorities_written += 1 + for additional in self.additionals[additional_offset:]: + if self.write_record(additional, 0): + additionals_written += 1 + + self.insert_short(0, additionals_written) + self.insert_short(0, authorities_written) + self.insert_short(0, answers_written) + self.insert_short(0, questions_written) self.insert_short(0, self.flags) if self.multicast: self.insert_short(0, 0) else: self.insert_short(0, self.id) - return b''.join(self.data) + self.packets_data.append(b''.join(self.data)) + self.reset_for_next_packet() + + answer_offset += answers_written + authority_offset += authorities_written + additional_offset += additionals_written + log.debug("now offsets = %d, %d, %d", answer_offset, authority_offset, additional_offset) + if answers_written == 0 and authorities_written == 0 and additional_offset == 0: + log.warning("packets() made no progress adding records; returning") + break + self.state = self.State.finished + return self.packets_data class DNSCache: @@ -2684,36 +2753,39 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Sends an outgoing packet.""" - packet = out.packet() - if len(packet) > _MAX_MSG_ABSOLUTE: - self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) - return - log.debug('Sending %r (%d bytes) as [%r]', out, len(packet), packet) - for s in self._respond_sockets: - if self._GLOBAL_DONE: + packets = out.packets() + packet_num = 0 + for packet in packets: + packet_num += 1 + if len(packet) > _MAX_MSG_ABSOLUTE: + self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return - try: - if addr is None: - real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR - elif not can_send_to(s, addr): - continue + log.debug('Sending (%d bytes #%d) %r as %r...', len(packet), packet_num, out, packet) + for s in self._respond_sockets: + if self._GLOBAL_DONE: + return + try: + if addr is None: + real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + elif not can_send_to(s, addr): + continue + else: + real_addr = addr + bytes_sent = s.sendto(packet, 0, (real_addr, port)) + except Exception as exc: # TODO stop catching all Exceptions + if ( + isinstance(exc, OSError) + and exc.errno == errno.ENETUNREACH + and s.family == socket.AF_INET6 + ): + # with IPv6 we don't have a reliable way to determine if an interface actually has + # IPV6 support, so we have to try and ignore errors. + continue + # on send errors, log the exception and keep going + self.log_exception_warning() else: - real_addr = addr - bytes_sent = s.sendto(packet, 0, (real_addr, port)) - except Exception as exc: # TODO stop catching all Exceptions - if ( - isinstance(exc, OSError) - and exc.errno == errno.ENETUNREACH - and s.family == socket.AF_INET6 - ): - # with IPv6 we don't have a reliable way to determine if an interface actually has IPv6 - # support, so we have to try and ignore errors. - continue - # on send errors, log the exception and keep going - self.log_exception_warning() - else: - if bytes_sent != len(packet): - self.log_warning_once('!!! sent %d out of %d bytes to %r' % (bytes_sent, len(packet), s)) + if bytes_sent != len(packet): + self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) def close(self) -> None: """Ends the background threads, and prevent this instance from diff --git a/zeroconf/test.py b/zeroconf/test.py index 2bc642c89..3440e5e80 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -318,6 +318,13 @@ def test_exceedingly_long_name(self): generated.add_question(question) r.DNSIncoming(generated.packet()) + def test_extra_exceedingly_long_name(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + name = "%slocal." % ("part." * 4000) + question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + r.DNSIncoming(generated.packet()) + def test_exceedingly_long_name_part(self): name = "%s.local." % ("a" * 1000) generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) @@ -355,12 +362,12 @@ def test_lots_of_names(self): def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): """Sends an outgoing packet.""" - packet = out.packet() - nonlocal longest_packet_len, longest_packet - if longest_packet_len < len(packet): - longest_packet_len = len(packet) - longest_packet = out - old_send(out, addr=addr, port=port) + for packet in out.packets(): + nonlocal longest_packet_len, longest_packet + if longest_packet_len < len(packet): + longest_packet_len = len(packet) + longest_packet = out + old_send(out, addr=addr, port=port) # monkey patch the zeroconf send setattr(zc, "send", send) @@ -374,6 +381,9 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # wait until the browse request packet has maxed out in size sleep_count = 0 + # we will never get to this large of a packet given the application-layer + # splitting of packets, but we still want to track the longest_packet_len + # for the debug message below while sleep_count < 100 and longest_packet_len < r._MAX_MSG_ABSOLUTE - 100: sleep_count += 1 time.sleep(0.1) @@ -386,8 +396,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) # now the browser has sent at least one request, verify the size - assert longest_packet_len <= r._MAX_MSG_ABSOLUTE - assert longest_packet_len >= r._MAX_MSG_ABSOLUTE - 100 + assert longest_packet_len <= r._MAX_MSG_TYPICAL + assert longest_packet_len >= r._MAX_MSG_TYPICAL - 100 # mock zeroconf's logger warning() and debug() from unittest.mock import patch @@ -407,13 +417,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count # try to send an oversized packet zc.send(out) - assert mocked_log_warn.call_count == call_counts[0] + 1 - assert mocked_log_debug.call_count == call_counts[0] + assert mocked_log_warn.call_count == call_counts[0] zc.send(out) - assert mocked_log_warn.call_count == call_counts[0] + 1 - assert mocked_log_debug.call_count == call_counts[0] + 1 + assert mocked_log_warn.call_count == call_counts[0] - # force a receive of an oversized packet + # force a receive of a packet packet = out.packet() s = zc._respond_sockets[0] From 488ee1e85762dc5856d8e132da54762e5e712c5a Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 25 May 2020 23:43:06 +0200 Subject: [PATCH 0085/1433] Warn on every call to missing update_service() listener method This is in order to provide visibility to the library users that this method exists - without it the client code may be missing data. --- zeroconf/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9fa6ae5cd..48760f678 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1521,6 +1521,12 @@ def on_change( elif state_change is ServiceStateChange.Updated: if hasattr(listener, 'update_service'): listener.update_service(*args) + else: + warnings.warn( + "%r has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory." % (listener,), + FutureWarning, + ) else: raise NotImplementedError(state_change) From 178cec75bd9a065b150b3542dfdb40682f6745b6 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 26 May 2020 23:29:51 +0200 Subject: [PATCH 0086/1433] Restore missing warnings import --- zeroconf/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 48760f678..6df3a806b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -34,6 +34,7 @@ import sys import threading import time +import warnings from collections import OrderedDict from typing import Dict, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints From 781ac834da38708d95bfe6e5f5ec7dd0f31efc54 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 26 May 2020 23:55:08 +0200 Subject: [PATCH 0087/1433] Add --find option to example/browser.py (#263, rebased #175) Co-authored-by: Perry Kundert --- examples/browser.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index c4ddac391..624aab9f2 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 -""" Example of browsing for a service (in this case, HTTP) """ +""" Example of browsing for a service. + +The default is HTTP and HAP; use --find to search for all available services in the network +""" import argparse import logging @@ -8,7 +11,7 @@ from time import sleep from typing import cast -from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf +from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf, ZeroconfServiceTypes def on_service_state_change( @@ -18,6 +21,7 @@ def on_service_state_change( if state_change is ServiceStateChange.Added: info = zeroconf.get_service_info(service_type, name) + print("Info from zeroconf.get_service_info: %r" % (info)) if info: addresses = ["%s:%d" % (socket.inet_ntoa(addr), cast(int, info.port)) for addr in info.addresses] print(" Addresses: %s" % ", ".join(addresses)) @@ -39,6 +43,7 @@ def on_service_state_change( parser = argparse.ArgumentParser() parser.add_argument('--debug', action='store_true') + parser.add_argument('--find', action='store_true', help='Browse all available services') version_group = parser.add_mutually_exclusive_group() version_group.add_argument('--v6', action='store_true') version_group.add_argument('--v6-only', action='store_true') @@ -54,10 +59,13 @@ def on_service_state_change( ip_version = IPVersion.V4Only zeroconf = Zeroconf(ip_version=ip_version) - print("\nBrowsing services, press Ctrl-C to exit...\n") - browser = ServiceBrowser( - zeroconf, ["_http._tcp.local.", "_hap._tcp.local."], handlers=[on_service_state_change] - ) + + services = ["_http._tcp.local.", "_hap._tcp.local."] + if args.find: + services = list(ZeroconfServiceTypes.find(zc=zeroconf)) + + print("\nBrowsing %d service(s), press Ctrl-C to exit...\n" % len(services)) + browser = ServiceBrowser(zeroconf, services, handlers=[on_service_state_change]) try: while True: From d881abaf591f260ad019f4ff86e7f70a6f018a64 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 27 May 2020 20:17:31 +0200 Subject: [PATCH 0088/1433] Remove no longer needed typing dependency We don't support Python older than 3.5. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 57b8b0bec..092932069 100755 --- a/setup.py +++ b/setup.py @@ -43,5 +43,5 @@ 'Programming Language :: Python :: Implementation :: PyPy', ], keywords=['Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', 'mDNS'], - install_requires=['ifaddr', 'typing;python_version<"3.5"'], + install_requires=['ifaddr'], ) From 0502f1904b0a8b9134ea2a09333232b30b3b6897 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 27 May 2020 22:02:25 +0200 Subject: [PATCH 0089/1433] Release version 0.27.0 --- README.rst | 14 ++++++++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 84c45906d..d46d64b0b 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,20 @@ See examples directory for more. Changelog ========= +0.27.0 +------ + +* Large multi-resource responses are now split into separate packets which fixes a bad + mdns-repeater/ChromeCast Audio interaction ending with ChromeCast Audio crash (and possibly + some others) and improves RFC 6762 compliance, thanks to Greg Badros +* Added a warning presented when the listener passed to ServiceBrowser lacks update_service() + callback +* Added support for finding all services available in the browser example, thanks to Perry Kunder + +Backwards incompatible: + +* Removed previously deprecated ServiceInfo address constructor parameter and property + 0.26.3 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6df3a806b..f22a77490 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.26.3' +__version__ = '0.27.0' __license__ = 'LGPL' From 6f876a7f14f0b172860005b0d6d959d82f7c1bbf Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 28 May 2020 19:54:46 +0200 Subject: [PATCH 0090/1433] Remove old Python 2-specific code --- zeroconf/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f22a77490..a1af17b8f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2111,8 +2111,7 @@ def new_socket( else: try: s.setsockopt(socket.SOL_SOCKET, reuseport, 1) - except (OSError, socket.error) as err: - # OSError on python 3, socket.error on python 2 + except OSError as err: if not err.errno == errno.ENOPROTOOPT: raise From 8045191ae6300da47d38e5cd82957965139359d2 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 28 May 2020 19:56:28 +0200 Subject: [PATCH 0091/1433] Improve ImportError message (wrong supported Python version) --- zeroconf/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a1af17b8f..a83e76356 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -58,11 +58,11 @@ "IPVersion", ] -if sys.version_info <= (3, 3): +if sys.version_info <= (3, 4): raise ImportError( ''' -Python version > 3.3 required for python-zeroconf. -If you need support for Python 2 or Python 3.3 please use version 19.1 +Python version > 3.4 required for python-zeroconf. +If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 ''' ) From d6593af2a3811b262d70bbc75c2c91613de41b21 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 29 May 2020 01:00:47 +0200 Subject: [PATCH 0092/1433] Simplify DNSHinfo constructor, cpu and os are always text (#266) --- zeroconf/__init__.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a83e76356..5f632be17 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -554,18 +554,10 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" - def __init__( - self, name: str, type_: int, class_: int, ttl: int, cpu: Union[bytes, str], os: Union[bytes, str] - ) -> None: + def __init__(self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str) -> None: DNSRecord.__init__(self, name, type_, class_, ttl) - try: - self.cpu = cast(bytes, cpu).decode('utf-8') - except AttributeError: - self.cpu = cast(str, cpu) - try: - self.os = cast(bytes, os).decode('utf-8') - except AttributeError: - self.os = cast(str, os) + self.cpu = cpu + self.os = os def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -807,7 +799,12 @@ def read_others(self) -> None: ) elif type_ == _TYPE_HINFO: rec = DNSHinfo( - domain, type_, class_, ttl, self.read_character_string(), self.read_character_string() + domain, + type_, + class_, + ttl, + self.read_character_string().decode('utf-8'), + self.read_character_string().decode('utf-8'), ) elif type_ == _TYPE_AAAA: rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) From beff99897f0a5ece17e224a7ea9b12ebd420044f Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 31 May 2020 14:49:45 +0200 Subject: [PATCH 0093/1433] Improve logging (mainly include sockets in some messages) (#271) --- zeroconf/__init__.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5f632be17..17e6ef2f9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -353,7 +353,7 @@ class QuietLogger: _seen_logs = {} # type: Dict[str, Union[int, tuple]] @classmethod - def log_exception_warning(cls, logger_data: Optional[Tuple] = None) -> None: + def log_exception_warning(cls, *logger_data: Any) -> None: exc_info = sys.exc_info() exc_str = str(exc_info[1]) if exc_str not in cls._seen_logs: @@ -362,9 +362,7 @@ def log_exception_warning(cls, logger_data: Optional[Tuple] = None) -> None: logger = log.warning else: logger = log.debug - if logger_data is not None: - logger(*logger_data) - logger('Exception occurred:', exc_info=True) + logger(*(logger_data or ['Exception occurred']), exc_info=True) @classmethod def log_warning_once(cls, *args: Any) -> None: @@ -709,7 +707,7 @@ def __init__(self, data: bytes) -> None: self.valid = True except (IndexError, struct.error, IncomingDecodeError): - self.log_exception_warning(('Choked at offset %d while unpacking %r', self.offset, data)) + self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) def __repr__(self) -> str: return '' % ', '.join( @@ -1357,15 +1355,30 @@ def handle_read(self, socket_: socket.socket) -> None: try: data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) except Exception: - self.log_exception_warning() + self.log_exception_warning('Error reading from socket %d', socket_.fileno()) return self.data = data msg = DNSIncoming(data) if msg.valid: - log.debug('Received from %r:%r: %r (%d bytes) as [%r]', addr, port, msg, len(data), data) + log.debug( + 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', + addr, + port, + socket_.fileno(), + msg, + len(data), + data, + ) else: - log.debug('Received from %r:%r: (%d bytes) [%r]', addr, port, len(data), data) + log.debug( + 'Received from %r:%r (socket %d): (%d bytes) [%r]', + addr, + port, + socket_.fileno(), + len(data), + data, + ) if not msg.valid: pass @@ -2139,7 +2152,7 @@ def add_multicast_member( ) -> Optional[socket.socket]: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, int) - log.debug('Adding %r to multicast group', interface) + log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: iface_bin = struct.pack('@I', cast(int, interface)) @@ -2173,7 +2186,7 @@ def add_multicast_member( respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p ) - log.debug('Configuring %s with multicast interface %s', respond_socket, interface) + log.debug('Configuring socket %d with multicast interface %s', respond_socket, interface) if is_v6: respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) else: @@ -2785,7 +2798,7 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P # IPV6 support, so we have to try and ignore errors. continue # on send errors, log the exception and keep going - self.log_exception_warning() + self.log_exception_warning('Error sending through socket %d', s.fileno()) else: if bytes_sent != len(packet): self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) From 10065b976247ae9247cddaff8f3e9d7b331e66d7 Mon Sep 17 00:00:00 2001 From: gjbadros Date: Tue, 2 Jun 2020 01:20:07 -0700 Subject: [PATCH 0094/1433] Fix false warning (#273) When there is nothing to write, we don't need to warn about not making progress. --- zeroconf/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 17e6ef2f9..44831f93e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1184,7 +1184,9 @@ def packets(self) -> List[bytes]: authority_offset += authorities_written additional_offset += additionals_written log.debug("now offsets = %d, %d, %d", answer_offset, authority_offset, additional_offset) - if answers_written == 0 and authorities_written == 0 and additional_offset == 0: + if (answers_written + authorities_written + additionals_written) == 0 and ( + len(self.answers) + len(self.authorities) + len(self.additionals) + ) > 0: log.warning("packets() made no progress adding records; returning") break self.state = self.State.finished From 0538abf135f5502d94dd883475bcb2781ce5ddd2 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 5 Jun 2020 11:09:58 +0200 Subject: [PATCH 0095/1433] Release version 0.27.1 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d46d64b0b..b2318d6a7 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.27.1 +------ + +* Improved the logging situation (includes fixing a false-positive "packets() made no progress + adding records", thanks to Greg Badros) + 0.27.0 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 44831f93e..b212d1b8c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -43,7 +43,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.27.0' +__version__ = '0.27.1' __license__ = 'LGPL' From c31ae7fd519df04f41939d3c60c2b88960737fd6 Mon Sep 17 00:00:00 2001 From: Sandy Patterson Date: Fri, 5 Jun 2020 16:41:53 -0400 Subject: [PATCH 0096/1433] Support Windows when using socket errno checks (#274) Windows reports errno.WSAEINVAL(10022) instead of errno.EINVAL(22). This issue is triggered when a device has two IP's assigned under windows. This fixes #189 --- zeroconf/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b212d1b8c..25d819903 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2154,6 +2154,9 @@ def add_multicast_member( ) -> Optional[socket.socket]: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, int) + err_einval = {errno.EINVAL} + if sys.platform == 'win32': + err_einval |= {errno.WSAEINVAL} log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: @@ -2179,7 +2182,7 @@ def add_multicast_member( interface, ) return None - elif _errno == errno.EINVAL: + elif _errno in err_einval: log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) return None else: From 0a9aa8d31bffec5d7b7291b84fbc95222b10d189 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 26 May 2020 13:34:43 +0200 Subject: [PATCH 0097/1433] Add support for passing text addresses to ServiceInfo Not sure if parsed_addresses is the best way to name the parameter, but we already have a parsed_addresses property so for the sake of consistency let's stick to that. --- zeroconf/__init__.py | 18 +++++++++++++++--- zeroconf/test.py | 45 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 25d819903..ed5b98f05 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -219,6 +219,12 @@ def _is_v6_address(addr: bytes) -> bool: return len(addr) == 16 +def _encode_address(address: str) -> bytes: + is_ipv6 = ':' in address + address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET + return socket.inet_pton(address_family, address) + + def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -1696,8 +1702,8 @@ class ServiceInfo(RecordUpdateListener): * server: fully qualified name for service host (defaults to name) * host_ttl: ttl used for A/SRV records * other_ttl: ttl used for PTR/TXT records - * addresses: List of IP addresses as unsigned short (IPv4) or unsigned 128 bit number (IPv6), - network byte order + * addresses and parsed_addresses: List of IP addresses (either as bytes, network byte order, or in parsed + form as text; at most one of those parameters can be provided) """ @@ -1718,14 +1724,20 @@ def __init__( host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL, *, - addresses: Optional[List[bytes]] = None + addresses: Optional[List[bytes]] = None, + parsed_addresses: Optional[List[str]] = None ) -> None: + # Accept both none, or one, but not both. + if addresses is not None and parsed_addresses is not None: + raise TypeError("addresses and parsed_addresses cannot be provided together") if not type_.endswith(service_type_name(name, allow_underscores=True)): raise BadTypeInNameException self.type = type_ self.name = name if addresses is not None: self._addresses = addresses + elif parsed_addresses is not None: + self._addresses = [_encode_address(a) for a in parsed_addresses] else: self._addresses = [] # This results in an ugly error when registering, better check now diff --git a/zeroconf/test.py b/zeroconf/test.py index 3440e5e80..06db1ed64 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1488,19 +1488,44 @@ def test_multiple_addresses(): assert info.addresses == [address, address] + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_parsed], + ) + assert info.addresses == [address, address] + if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address_v6], - ) - assert info.addresses == [address] - assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] - assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] - assert info.parsed_addresses() == [address_parsed, address_v6_parsed] - assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] + infos = [ + ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address_v6], + ), + ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_v6_parsed], + ), + ] + for info in infos: + assert info.addresses == [address] + assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.V4Only) == [address] + assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] + assert info.parsed_addresses() == [address_parsed, address_v6_parsed] + assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] def test_ptr_optimization(): From 328abfc54138e68e36a9f5381650bd6997701e73 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 12 Jun 2020 22:43:48 +0200 Subject: [PATCH 0098/1433] Fix one log format string (we use a socket object here) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ed5b98f05..daf723aca 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2203,7 +2203,7 @@ def add_multicast_member( respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p ) - log.debug('Configuring socket %d with multicast interface %s', respond_socket, interface) + log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) if is_v6: respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) else: From 3b6906ab94f8d9ebeb1c97b6026ab7f9be226eab Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 17 Jun 2020 01:23:48 +0200 Subject: [PATCH 0099/1433] Log listen and respond sockets just in case --- zeroconf/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index daf723aca..e2018f348 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2304,6 +2304,7 @@ def __init__( self._listen_socket, self._respond_sockets = create_sockets( interfaces, unicast, ip_version, apple_p2p=apple_p2p ) + log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) self.listeners = [] # type: List[RecordUpdateListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] From 023e72d821faed9513ee0ef3a22a00231d87389e Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 17 Jun 2020 01:37:11 +0200 Subject: [PATCH 0100/1433] Exclude a problematic pep8-naming version --- requirements-dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 127df74ef..2d0490aed 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,7 @@ coverage flake8>=3.6.0 flake8-import-order ifaddr -pep8-naming!=0.6.0 +# 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152 +pep8-naming!=0.6.0,!=0.11.0 pytest pytest-cov From 64056ab4aa55eb11c185c9879462ba1f82c7e886 Mon Sep 17 00:00:00 2001 From: PhilippSelenium <31542906+PhilippSelenium@users.noreply.github.com> Date: Mon, 29 Jun 2020 15:25:05 +0200 Subject: [PATCH 0101/1433] Use Adapter.index from ifaddr. (#280) Co-authored-by: PhilippSelenium --- setup.py | 2 +- zeroconf/__init__.py | 12 ++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index 092932069..f90d31750 100755 --- a/setup.py +++ b/setup.py @@ -43,5 +43,5 @@ 'Programming Language :: Python :: Implementation :: PyPy', ], keywords=['Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', 'mDNS'], - install_requires=['ifaddr'], + install_requires=['ifaddr>=0.1.7'], ) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e2018f348..dc33082eb 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -25,7 +25,6 @@ import ipaddress import itertools import logging -import os import platform import re import select @@ -2032,17 +2031,12 @@ def get_all_addresses_v6() -> List[int]: def ip_to_index(adapters: List[Any], ip: str) -> int: - if os.name != 'posix': - # Adapter names that ifaddr reports are not compatible with what if_nametoindex expects on Windows. - # We need https://github.com/pydron/ifaddr/pull/21 but it seems stuck on review. - raise RuntimeError('Converting from IP addresses to indexes is not supported on non-POSIX systems') - ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return socket.if_nametoindex(adapter.name) + return adapter.index raise RuntimeError('No adapter found for IP address %s' % ip) @@ -2050,8 +2044,7 @@ def ip_to_index(adapters: List[Any], ip: str) -> int: def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: """Convert IPv6 interface addresses to interface indexes. - IPv4 addresses are ignored. The conversion currently only works on POSIX - systems. + IPv4 addresses are ignored. :param interfaces: List of IP addresses and indexes. :returns: List of indexes. @@ -2271,7 +2264,6 @@ def __init__( (IPv4 and IPv6) and interface indexes (IPv6 only). IPv6 notes for non-POSIX systems: - * IPv6 addresses are not supported, use indexes instead. * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on Python versions before 3.8. From 4381784150e07625b4acd2034b253bf2ed320c5f Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 12:25:42 +0200 Subject: [PATCH 0102/1433] Make Mypy happy (#281) Otherwise it'd complain: % make mypy mypy examples/*.py zeroconf/*.py zeroconf/__init__.py:2039: error: Returning Any from function declared to return "int" Found 1 error in 1 file (checked 6 source files) make: *** [mypy] Error 1 --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dc33082eb..cef46ab1f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2036,7 +2036,7 @@ def ip_to_index(adapters: List[Any], ip: str) -> int: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return adapter.index + return cast(int, adapter.index) raise RuntimeError('No adapter found for IP address %s' % ip) From a7f9823cbed254b506a09cc514d86d9f5dc61ad3 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 12:50:06 +0200 Subject: [PATCH 0103/1433] Stop using socket.if_nameindex (#282) This improves Windows compatibility --- zeroconf/__init__.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cef46ab1f..ed79350df 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2018,16 +2018,7 @@ def get_all_addresses() -> List[str]: def get_all_addresses_v6() -> List[int]: # IPv6 multicast uses positive indexes for interfaces - try: - nameindex = socket.if_nameindex - except AttributeError: - # Requires Python 3.8 on Windows. Fall back to Default. - QuietLogger.log_warning_once( - 'if_nameindex is not available, falling back to using the default IPv6 interface' - ) - return [0] - - return [tpl[0] for tpl in nameindex()] + return [adapter.index for adapter in ifaddr.get_adapters()] def ip_to_index(adapters: List[Any], ip: str) -> int: From fc92b1e2635868792aa7ebe937a9cfef2e2f0418 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 13:11:44 +0200 Subject: [PATCH 0104/1433] Fix an OS X edge case (#270, #188) This contains two major changes: * Listen on data from respond_sockets in addition to listen_socket * Do not bind respond sockets to 0.0.0.0 or ::/0 The description of the original change by Emil: <<< Without either of these changes, I get no replies at all when browsing for services using the browser example. I'm on a corporate network, and when connecting to a different network it works without these changes, so maybe it's something about the network configuration in this particular network that breaks the previous behavior. Unfortunately, I have no idea how this affects other platforms, or what the changes really mean. However, it works for me and it seems reasonable to get replies back on the same socket where they are sent. >>> The tests pass and it's been confirmed to a reasonable degree that this doesn't break the previously working use cases. Additionally this removes a memory leak where data sent to some of the respond sockets would not be ever read from them (#171). Co-authored-by: Emil Styrke --- zeroconf/__init__.py | 88 ++++++++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ed79350df..3a1c5f66a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -189,7 +189,7 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[List[Union[str, int]], InterfaceChoice] +InterfacesType = Union[List[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] @enum.unique @@ -2016,23 +2016,39 @@ def get_all_addresses() -> List[str]: return list(set(addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4)) -def get_all_addresses_v6() -> List[int]: +def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces - return [adapter.index for adapter in ifaddr.get_adapters()] + # TODO: What about multi-address interfaces? + return list( + set((addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6) + ) -def ip_to_index(adapters: List[Any], ip: str) -> int: +def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return cast(int, adapter.index) + return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index)) raise RuntimeError('No adapter found for IP address %s' % ip) -def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: +def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: + for adapter in adapters: + if adapter.index == index: + for adapter_ip in adapter.ips: + # IPv6 addresses are represented as tuples + if isinstance(adapter_ip.ip, tuple): + return cast(Tuple[str, int, int], adapter_ip.ip) + + raise RuntimeError('No adapter found for index %s' % index) + + +def ip6_addresses_to_indexes( + interfaces: List[Union[str, int, Tuple[Tuple[str, int, int], int]]] +) -> List[Tuple[Tuple[str, int, int], int]]: """Convert IPv6 interface addresses to interface indexes. IPv4 addresses are ignored. @@ -2045,27 +2061,27 @@ def ip6_addresses_to_indexes(interfaces: List[Union[str, int]]) -> List[int]: for iface in interfaces: if isinstance(iface, int): - result.append(iface) + result.append((interface_index_to_ip6_address(adapters, iface), iface)) elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: - result.append(ip_to_index(adapters, iface)) + result.append(ip6_to_address_and_index(adapters, iface)) return result def normalize_interface_choice( choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only -) -> List[Union[str, int]]: +) -> List[Union[str, Tuple[Tuple[str, int, int], int]]]: """Convert the interfaces choice into internal representation. :param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only). :param ip_address: IP version to use (ignored if `choice` is a list). :returns: List of IP addresses (for IPv4) and indexes (for IPv6). """ - result = [] # type: List[Union[str, int]] + result = [] # type: List[Union[str, Tuple[Tuple[str, int, int], int]]] if choice is InterfaceChoice.Default: if ip_version != IPVersion.V4Only: # IPv6 multicast uses interface 0 to mean the default - result.append(0) + result.append((('', 0, 0), 0)) if ip_version != IPVersion.V6Only: result.append('0.0.0.0') elif choice is InterfaceChoice.All: @@ -2088,8 +2104,18 @@ def normalize_interface_choice( def new_socket( - port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False + bind_addr: Union[Tuple[str], Tuple[str, int, int]], + port: int = _MDNS_PORT, + ip_version: IPVersion = IPVersion.V4Only, + apple_p2p: bool = False, ) -> socket.socket: + log.debug( + 'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r', + port, + ip_version, + apple_p2p, + bind_addr, + ) if ip_version == IPVersion.V4Only: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) else: @@ -2141,22 +2167,25 @@ def new_socket( # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) - s.bind(('', port)) + s.bind((bind_addr[0], port, *bind_addr[1:])) + log.debug('Created socket %s', s) return s def add_multicast_member( - listen_socket: socket.socket, interface: Union[str, int], apple_p2p: bool = False + listen_socket: socket.socket, + interface: Union[str, Tuple[Tuple[str, int, int], int]], + apple_p2p: bool = False, ) -> Optional[socket.socket]: # This is based on assumptions in normalize_interface_choice - is_v6 = isinstance(interface, int) + is_v6 = isinstance(interface, tuple) err_einval = {errno.EINVAL} if sys.platform == 'win32': err_einval |= {errno.WSAEINVAL} log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: - iface_bin = struct.pack('@I', cast(int, interface)) + iface_bin = struct.pack('@I', cast(int, interface[1])) _value = _MDNS_ADDR6_BYTES + iface_bin listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) else: @@ -2185,7 +2214,9 @@ def add_multicast_member( raise respond_socket = new_socket( - ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p + ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), + apple_p2p=apple_p2p, + bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), ) log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) if is_v6: @@ -2206,17 +2237,22 @@ def create_sockets( if unicast: listen_socket = None else: - listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p) + listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=('',)) - interfaces = normalize_interface_choice(interfaces, ip_version) + normalized_interfaces = normalize_interface_choice(interfaces, ip_version) respond_sockets = [] - for i in interfaces: + for i in normalized_interfaces: if not unicast: respond_socket = add_multicast_member(cast(socket.socket, listen_socket), i, apple_p2p=apple_p2p) else: - respond_socket = new_socket(port=0, ip_version=ip_version, apple_p2p=apple_p2p) + respond_socket = new_socket( + port=0, + ip_version=ip_version, + apple_p2p=apple_p2p, + bind_addr=i[0] if isinstance(i, tuple) else (i,), + ) if respond_socket is not None: respond_sockets.append(respond_socket) @@ -2307,9 +2343,8 @@ def __init__( self.listener = Listener(self) if not unicast: self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) - else: - for s in self._respond_sockets: - self.engine.add_reader(self.listener, s) + for s in self._respond_sockets: + self.engine.add_reader(self.listener, s) self.reaper = Reaper(self) self.debug = None # type: Optional[DNSOutgoing] @@ -2817,9 +2852,8 @@ def close(self) -> None: if not self.unicast: self.engine.del_reader(cast(socket.socket, self._listen_socket)) cast(socket.socket, self._listen_socket).close() - else: - for s in self._respond_sockets: - self.engine.del_reader(s) + for s in self._respond_sockets: + self.engine.del_reader(s) self.engine.join() # shutdown the rest From 02bcad902c516a5a2d2aa3302bca9871900da6e3 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 13:21:57 +0200 Subject: [PATCH 0105/1433] Advertise Python 3.8 compatibility --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f90d31750..ac24ca7ff 100755 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], From 0fdbf5e197a9f76e9e9c91a5e0908a0c66370dbd Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 13:21:44 +0200 Subject: [PATCH 0106/1433] Release version 0.28.0 --- README.rst | 14 ++++++++++++++ zeroconf/__init__.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b2318d6a7..27925eb9c 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,20 @@ See examples directory for more. Changelog ========= +0.28.0 +====== + +* Improved Windows support when using socket errno checks, thanks to Sandy Patterson. +* Added support for passing text addresses to ServiceInfo. +* Improved logging (includes fixing an incorrect logging call) +* Improved Windows compatibility by using Adapter.index from ifaddr, thanks to PhilippSelenium. +* Improved Windows compatibility by stopping using socket.if_nameindex. +* Fixed an OS X edge case which should also eliminate a memory leak, thanks to Emil Styrke. + +Technically backwards incompatible: + +* ``ifaddr`` 0.1.7 or newer is required now. + 0.27.1 ------ diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3a1c5f66a..07e628457 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.27.1' +__version__ = '0.28.0' __license__ = 'LGPL' From 19e33a6829846008b50f408c77ac3e8e73176529 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 7 Jul 2020 13:25:26 +0200 Subject: [PATCH 0107/1433] Gitignore some build artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index eac2c1700..0af9ce1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ Thumbs.db .mypy_cache/ docs/_build/ .vscode +/dist/ +/zeroconf.egg-info/ From c9f3c91da568fdbd26d571eed8a636a49e527b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2020 15:21:04 -0500 Subject: [PATCH 0108/1433] Ensure all listeners are cleaned up on ServiceBrowser cancelation (#290) When creating listeners for a ServiceBrowser with multiple types they would not all be removed on cancelation. This led to a build up of stale listeners when ServiceBrowsers were frequently added and removed. --- zeroconf/__init__.py | 32 ++++++++++++++--------- zeroconf/test.py | 61 +++++++++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 07e628457..f1f187417 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -174,6 +174,10 @@ _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') _HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') +_EXPIRE_FULL_TIME_PERCENT = 100 +_EXPIRE_STALE_TIME_PERCENT = 50 +_EXPIRE_REFRESH_TIME_PERCENT = 75 + try: _IPPROTO_IPV6 = socket.IPPROTO_IPV6 except AttributeError: @@ -459,8 +463,8 @@ def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) - DNSEntry.__init__(self, name, type_, class_) self.ttl = ttl self.created = current_time_millis() - self._expiration_time = self.get_expiration_time(100) - self._stale_time = self.get_expiration_time(50) + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) + self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) def __eq__(self, other: Any) -> bool: """Abstract method""" @@ -506,8 +510,8 @@ def reset_ttl(self, other: 'DNSRecord') -> None: another record.""" self.created = other.created self.ttl = other.ttl - self._expiration_time = self.get_expiration_time(100) - self._stale_time = self.get_expiration_time(50) + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) + self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) def write(self, out: 'DNSOutgoing') -> None: """Abstract method""" @@ -1609,7 +1613,7 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) return - expires = record.get_expiration_time(75) + expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) if expires < self._next_time[record.name]: self._next_time[record.name] = expires @@ -1649,8 +1653,8 @@ def cancel(self) -> None: self.join() def run(self) -> None: - for type_ in self.types: - self.zc.add_listener(self, DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) + questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] + self.zc.add_listener(self, questions) while True: now = current_time_millis() @@ -2595,16 +2599,20 @@ def check_service( i += 1 next_time += _CHECK_TIME - def add_listener(self, listener: RecordUpdateListener, question: Optional[DNSQuestion]) -> None: + def add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to - answer the question.""" + answer the question(s).""" now = current_time_millis() self.listeners.append(listener) if question is not None: - for record in self.cache.entries_with_name(question.name): - if question.answered_by(record) and not record.is_expired(now): - listener.update_record(self, now, record) + questions = [question] if isinstance(question, DNSQuestion) else question + for single_question in questions: + for record in self.cache.entries_with_name(single_question.name): + if single_question.answered_by(record) and not record.is_expired(now): + listener.update_record(self, now, record) self.notify_all() def remove_listener(self, listener: RecordUpdateListener) -> None: diff --git a/zeroconf/test.py b/zeroconf/test.py index 06db1ed64..96be62cf4 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -24,6 +24,7 @@ ServiceStateChange, Zeroconf, ZeroconfServiceTypes, + _EXPIRE_REFRESH_TIME_PERCENT, ) log = logging.getLogger('zeroconf') @@ -1237,7 +1238,9 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi assert service_removed_count == 1 finally: + assert len(zeroconf.listeners) == 1 service_browser.cancel() + assert len(zeroconf.listeners) == 0 zeroconf.remove_all_service_listeners() zeroconf.close() @@ -1245,8 +1248,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi class TestServiceBrowserMultipleTypes(unittest.TestCase): def test_update_record(self): - service_names = ['name._type._tcp.local.', 'name._type._udp.local'] - service_types = ['_type._tcp.local.', '_type._udp.local.'] + service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] + service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] service_added_count = 0 service_removed_count = 0 @@ -1257,25 +1260,19 @@ class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: nonlocal service_added_count service_added_count += 1 - if service_added_count == 2: + if service_added_count == 3: service_add_event.set() def remove_service(self, zc, type_, name) -> None: nonlocal service_removed_count service_removed_count += 1 - if service_removed_count == 2: + if service_removed_count == 3: service_removed_event.set() def mock_incoming_msg( - service_state_change: r.ServiceStateChange, service_type: str, service_name: str + service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int ) -> r.DNSIncoming: generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - - if service_state_change == r.ServiceStateChange.Removed: - ttl = 0 - else: - ttl = 120 - generated.add_answer_at_time( r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 ) @@ -1287,30 +1284,54 @@ def mock_incoming_msg( try: wait_time = 3 - # both services added + # all three services added + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120) + ) zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0]) + mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120) ) zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1]) + mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) ) + + called_with_refresh_time_check = False + + def _mock_get_expiration_time(self, percent): + nonlocal called_with_refresh_time_check + if percent == _EXPIRE_REFRESH_TIME_PERCENT: + called_with_refresh_time_check = True + return 0 + return self.created + (percent * self.ttl * 10) + + # Set an expire time that will force a refresh + with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) + ) service_add_event.wait(wait_time) - assert service_added_count == 2 + assert called_with_refresh_time_check is True + assert service_added_count == 3 assert service_removed_count == 0 - # both services removed + # all three services removed + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0) + ) zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0]) + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0) ) zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1]) + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0) ) service_removed_event.wait(wait_time) - assert service_added_count == 2 - assert service_removed_count == 2 + assert service_added_count == 3 + assert service_removed_count == 3 finally: + assert len(zeroconf.listeners) == 1 service_browser.cancel() + assert len(zeroconf.listeners) == 0 zeroconf.remove_all_service_listeners() zeroconf.close() From 3c5d3856e286824611712de13aa0fcbe94e4313f Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 17 Aug 2020 22:23:35 +0200 Subject: [PATCH 0109/1433] Release version 0.28.1 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 27925eb9c..10a0b4358 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.1 +====== + +* Fixed a resource leak connected to using ServiceBrowser with multiple types, thanks to + J. Nick Koston. + 0.28.0 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f1f187417..1feeaf87a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.0' +__version__ = '0.28.1' __license__ = 'LGPL' From 0f7366423fab8369700be086f3007c20897fde1f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2020 16:07:03 +0200 Subject: [PATCH 0110/1433] Remove initial delay before querying for service info --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1feeaf87a..895083a9d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1894,7 +1894,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: """ now = current_time_millis() delay = _LISTENER_TIME - next_ = now + delay + next_ = now last = now + timeout record_types_for_check_cache = [(_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN)] From fca090db06a0d481ad7f608c4fde3e936ad2f80e Mon Sep 17 00:00:00 2001 From: Paul Daumlechner Date: Wed, 19 Aug 2020 10:33:41 +0200 Subject: [PATCH 0111/1433] Don't ask already answered questions (#292) Fixes GH-288. Co-authored-by: Erik --- zeroconf/__init__.py | 27 +++--- zeroconf/test.py | 225 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 895083a9d..f49c263c3 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1916,19 +1916,24 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: return False if next_ <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) - out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN), now) - - out.add_question(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) - out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN), now) + cached_entry = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) + if not cached_entry: + out.add_question(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) + out.add_answer_at_time(cached_entry, now) + cached_entry = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) + if not cached_entry: + out.add_question(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) + out.add_answer_at_time(cached_entry, now) if self.server is not None: - out.add_question(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) - out.add_answer_at_time(zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN), now) - out.add_question(DNSQuestion(self.server, _TYPE_AAAA, _CLASS_IN)) - out.add_answer_at_time( - zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN), now - ) + cached_entry = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) + if not cached_entry: + out.add_question(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) + out.add_answer_at_time(cached_entry, now) + cached_entry = zc.cache.get_by_details(self.name, _TYPE_AAAA, _CLASS_IN) + if not cached_entry: + out.add_question(DNSQuestion(self.server, _TYPE_AAAA, _CLASS_IN)) + out.add_answer_at_time(cached_entry, now) zc.send(out) next_ = now + delay delay *= 2 diff --git a/zeroconf/test.py b/zeroconf/test.py index 96be62cf4..6b7a31cd6 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -9,6 +9,7 @@ import os import socket import struct +import threading import time import unittest from threading import Event @@ -1245,6 +1246,230 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi zeroconf.close() +class TestServiceInfo(unittest.TestCase): + def test_get_info_partial(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # monkey patch the zeroconf send + setattr(zc, "send", send) + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packet()) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for SRV, A, AAAA + last_sent = None + send_event.clear() + zc.handle_response( + mock_incoming_msg( + [r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text)] + ) + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 3 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for A, AAAA + last_sent = None + send_event.clear() + zc.handle_response( + mock_incoming_msg( + [ + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ) + ] + ) + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 2 + assert r.DNSQuestion(service_server, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + last_sent = None + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + zc.handle_response( + mock_incoming_msg( + [ + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ) + ] + ) + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + def test_get_info_single(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # monkey patch the zeroconf send + setattr(zc, "send", send) + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packet()) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + zc.handle_response( + mock_incoming_msg( + [ + r.DNSText( + service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text + ), + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ), + ] + ) + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + class TestServiceBrowserMultipleTypes(unittest.TestCase): def test_update_record(self): From 3be96b014d61c94d71ae3aa23ba223eead4f4cb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2020 08:33:25 -0500 Subject: [PATCH 0112/1433] Increase test coverage for dns cache --- zeroconf/test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index 6b7a31cd6..1046bf77a 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -862,6 +862,20 @@ def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): cache.remove(record2) assert 'a' not in cache.cache + def test_cache_empty_multiple_calls_does_not_throw(self): + record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + # Ensure multiple removes does not throw + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache + class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): From f64768a7253829f9d8f7796a6a5c8129b92f2aad Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 27 Aug 2020 00:22:14 +0200 Subject: [PATCH 0113/1433] Release version 0.28.2 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 10a0b4358..704c39fe1 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.2 +====== + +* Stopped asking questions we already have answers for in cache, thanks to Paul Daumlechner. +* Removed initial delay before querying for service info, thanks to Erik Montnemery. + 0.28.1 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f49c263c3..78cafc3fe 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.1' +__version__ = '0.28.2' __license__ = 'LGPL' From 57d89d85e52dea1f8cb7f6d4b02c0281d5ba0540 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 27 Aug 2020 00:29:57 +0200 Subject: [PATCH 0114/1433] Reformat using the latest black (20.8b1) --- zeroconf/__init__.py | 4 ++-- zeroconf/test.py | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 78cafc3fe..b9ffa9686 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -938,7 +938,7 @@ def add_authorative_answer(self, record: DNSPointer) -> None: self.authorities.append(record) def add_additional_answer(self, record: DNSRecord) -> None: - """ Adds an additional answer + """Adds an additional answer From: RFC 6763, DNS-Based Service Discovery, February 2013 @@ -1136,7 +1136,7 @@ def packets(self) -> List[bytes]: or less in length, except for the case of a single answer which will be written out to a single oversized packet no more than _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP - fragmentation potentially). """ + fragmentation potentially).""" if self.state == self.State.finished: return self.packets_data diff --git a/zeroconf/test.py b/zeroconf/test.py index 1046bf77a..33757cd19 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -110,7 +110,14 @@ def test_service_info_dunder(self): name = "xxxyyy" registration_name = "%s.%s" % (name, type_) info = ServiceInfo( - type_, registration_name, 80, 0, 0, b'', "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], + type_, + registration_name, + 80, + 0, + 0, + b'', + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) assert not info != info @@ -1765,7 +1772,14 @@ def test_multiple_addresses(): address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) infos = [ ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address_v6], + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[address, address_v6], ), ServiceInfo( type_, From 5a359bb0931fbda8444e30d07a50e59cf4ccca8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2020 15:44:54 +0000 Subject: [PATCH 0115/1433] Reduce the time window that the handlers lock is held Only hold the lock if we have an update. --- zeroconf/__init__.py | 79 ++++++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b9ffa9686..26770326f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2638,45 +2638,52 @@ def update_record(self, now: float, rec: DNSRecord) -> None: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" + updates = [] # type: List[Tuple[float, DNSRecord, Optional[DNSRecord]]] + now = current_time_millis() + for record in msg.answers: - with self._handlers_lock: + updated = True - now = current_time_millis() - for record in msg.answers: - - updated = True - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # Since the cache format is keyed on the lower case record name - # we can avoid iterating everything in the cache and - # only look though entries for the specific name. - # entries_with_name will take care of converting to lowercase - # - # We make a copy of the list that entries_with_name returns - # since we cannot iterate over something we might remove - for entry in self.cache.entries_with_name(record.name).copy(): - - if entry == record: - updated = False - - # Check the time first because it is far cheaper - # than the __eq__ - if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): - self.cache.remove(entry) - - expired = record.is_expired(now) - maybe_entry = self.cache.get(record) - if not expired: - if maybe_entry is not None: - maybe_entry.reset_ttl(record) - else: - self.cache.add(record) - if updated: - self.update_record(now, record) + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + # Since the cache format is keyed on the lower case record name + # we can avoid iterating everything in the cache and + # only look though entries for the specific name. + # entries_with_name will take care of converting to lowercase + # + # We make a copy of the list that entries_with_name returns + # since we cannot iterate over something we might remove + for entry in self.cache.entries_with_name(record.name).copy(): + + if entry == record: + updated = False + + # Check the time first because it is far cheaper + # than the __eq__ + if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): + self.cache.remove(entry) + + expired = record.is_expired(now) + maybe_entry = self.cache.get(record) + if not expired: + if maybe_entry is not None: + maybe_entry.reset_ttl(record) else: - if maybe_entry is not None: - self.update_record(now, record) - self.cache.remove(maybe_entry) + self.cache.add(record) + if updated: + updates.append((now, record, None)) + elif maybe_entry is not None: + updates.append((now, record, maybe_entry)) + + if not updates: + return + + # Only hold the lock if we have updates + with self._handlers_lock: + for update in updates: + now, record, entry_to_remove = update + self.update_record(update[0], update[1]) + if entry_to_remove: + self.cache.remove(entry_to_remove) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if From 0e49aeca6497ede18a3f0c71ea69f2343934ba19 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 31 Aug 2020 12:57:18 +0200 Subject: [PATCH 0116/1433] Release version 0.28.3 --- README.rst | 5 +++++ zeroconf/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 704c39fe1..82464cb35 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,11 @@ See examples directory for more. Changelog ========= +0.28.3 +====== + +* Reduced a time an internal lock is held which should eliminate deadlocks in high-traffic networks. + 0.28.2 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 26770326f..279d85f63 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.2' +__version__ = '0.28.3' __license__ = 'LGPL' From 9e27d126d75c73466584c417ab35c1d6cf47ca8b Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 31 Aug 2020 12:58:57 +0200 Subject: [PATCH 0117/1433] Add an author in the last changelog entry --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 82464cb35..da99ed00e 100644 --- a/README.rst +++ b/README.rst @@ -137,7 +137,8 @@ Changelog 0.28.3 ====== -* Reduced a time an internal lock is held which should eliminate deadlocks in high-traffic networks. +* Reduced a time an internal lock is held which should eliminate deadlocks in high-traffic networks, + thanks to J. Nick Koston. 0.28.2 ====== From 1e4aaeaa10c306b9447dacefa03b89ce1e9d7493 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Sep 2020 10:35:58 -0500 Subject: [PATCH 0118/1433] Avoid copying the entires cache and reduce frequency of Reaper The cache reaper was running at least every 10 seconds, making a copy of the cache, and iterated all the entries to check if they were expired so they could be removed. In practice the reaper was actually running much more frequently because it used self.zc.wait which would unblock any time a record was updated, a listener was added, or when a listener was removed. This change ensures the reaper frequency is only every 10s, and will first attempt to iterate the cache before falling back to making a copy. Previously it made sense to expire the cache more frequently because we had places were we frequently had to enumerate all the cache entries. With #247 and #232 we no longer have to account for this concern. On a mostly idle RPi running HomeAssistant and a busy network the total time spent reaping the cache was more than the total time spent processing the mDNS traffic. Top 10 functions, idle RPi (before) %Own %Total OwnTime TotalTime Function (filename:line) 0.00% 0.00% 2.69s 2.69s handle_read (zeroconf/__init__.py:1367) <== Incoming mDNS 0.00% 0.00% 1.51s 2.98s run (zeroconf/__init__.py:1431) <== Reaper 0.00% 0.00% 1.42s 1.42s is_expired (zeroconf/__init__.py:502) <== Reaper 0.00% 0.00% 1.12s 1.12s entries (zeroconf/__init__.py:1274) <== Reaper 0.00% 0.00% 0.620s 0.620s do_execute (sqlalchemy/engine/default.py:593) 0.00% 0.00% 0.620s 0.620s read_utf (zeroconf/__init__.py:837) 0.00% 0.00% 0.610s 0.610s do_commit (sqlalchemy/engine/default.py:546) 0.00% 0.00% 0.540s 1.16s read_name (zeroconf/__init__.py:853) 0.00% 0.00% 0.380s 0.380s do_close (sqlalchemy/engine/default.py:549) 0.00% 0.00% 0.340s 0.340s write (asyncio/selector_events.py:908) After this change, the Reaper code paths do not show up in the top 10 function sample. %Own %Total OwnTime TotalTime Function (filename:line) 4.00% 4.00% 2.72s 2.72s handle_read (zeroconf/__init__.py:1378) <== Incoming mDNS 4.00% 4.00% 1.81s 1.81s read_utf (zeroconf/__init__.py:837) 1.00% 5.00% 1.68s 3.51s read_name (zeroconf/__init__.py:853) 0.00% 0.00% 1.32s 1.32s do_execute (sqlalchemy/engine/default.py:593) 0.00% 0.00% 0.960s 0.960s readinto (socket.py:669) 0.00% 0.00% 0.950s 0.950s create_connection (urllib3/util/connection.py:74) 0.00% 0.00% 0.910s 0.910s do_commit (sqlalchemy/engine/default.py:546) 1.00% 1.00% 0.880s 0.880s write (asyncio/selector_events.py:908) 0.00% 0.00% 0.700s 0.810s __eq__ (zeroconf/__init__.py:606) 2.00% 2.00% 0.670s 0.670s unpack (zeroconf/__init__.py:737) --- zeroconf/__init__.py | 54 +++++++++++++++++++++++++++++++++++--------- zeroconf/test.py | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 279d85f63..0e8b2a9f8 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,7 +35,7 @@ import time import warnings from collections import OrderedDict -from typing import Dict, List, Optional, Sequence, Union, cast +from typing import Dict, Iterable, List, Optional, Sequence, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -1268,10 +1268,21 @@ def entries(self) -> List[DNSRecord]: """Returns a list of all entries""" if not self.cache: return [] - else: - # avoid size change during iteration by copying the cache - values = list(self.cache.values()) - return list(itertools.chain.from_iterable(values)) + + # avoid size change during iteration by copying the cache + return list(itertools.chain.from_iterable(list(self.cache.values()))) + + def iterable_entries(self) -> Iterable[DNSRecord]: + """Returns an iterable of all entries. + + This function is provided to avoid copying + the entries but is not threadsafe as the + contents of the cache can change during iteration. + + Callers should trap RuntimeError and fallback + to calling entries. + """ + return itertools.chain.from_iterable(self.cache.values()) class Engine(threading.Thread): @@ -1422,15 +1433,29 @@ def __init__(self, zc: 'Zeroconf') -> None: self.name = "zeroconf-Reaper_%s" % (getattr(self, 'native_id', self.ident),) def run(self) -> None: + """Perodic removal of expired entries from the cache.""" while True: - self.zc.wait(10 * 1000) + with self.zc.reaper_condition: + self.zc.reaper_condition.wait(10) + if self.zc.done: return - now = current_time_millis() - for record in self.zc.cache.entries(): - if record.is_expired(now): - self.zc.update_record(now, record) - self.zc.cache.remove(record) + try: + # We try to iterate the cache without copying the whole + # cache as this can be quite an expensive operation. + self._cleanup_cache(self.zc.cache.iterable_entries()) + except RuntimeError: + # If the cache changes during iteration, we fallback + # to making a copy before iteraiton. + self._cleanup_cache(self.zc.cache.entries()) + + def _cleanup_cache(self, entries: Iterable[DNSRecord]) -> None: + """Remove expired entries from the cache.""" + now = current_time_millis() + for record in entries: + if record.is_expired(now): + self.zc.update_record(now, record) + self.zc.cache.remove(record) class Signal: @@ -2342,6 +2367,7 @@ def __init__( self.cache = DNSCache() self.condition = threading.Condition() + self.reaper_condition = threading.Condition() # Ensure we create the lock before # we add the listener as we could get @@ -2373,6 +2399,11 @@ def notify_all(self) -> None: with self.condition: self.condition.notify_all() + def notify_reaper(self) -> None: + """Notifies reaper""" + with self.reaper_condition: + self.reaper_condition.notify_all() + def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, @@ -2878,6 +2909,7 @@ def close(self) -> None: # shutdown the rest self.notify_all() + self.notify_reaper() self.reaper.join() for s in self._respond_sockets: s.close() diff --git a/zeroconf/test.py b/zeroconf/test.py index 33757cd19..e4ee1bfae 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -884,6 +884,54 @@ def test_cache_empty_multiple_calls_does_not_throw(self): assert 'a' not in cache.cache +class TestReaper(unittest.TestCase): + def test_reaper(self): + zeroconf = Zeroconf(interfaces=['127.0.0.1']) + original_entries = zeroconf.cache.entries() + record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + zeroconf.cache.add(record_with_10s_ttl) + zeroconf.cache.add(record_with_1s_ttl) + entries_with_cache = zeroconf.cache.entries() + time.sleep(1.05) + zeroconf.notify_reaper() + time.sleep(0.05) + entries = zeroconf.cache.entries() + + try: + iterable_entries = list(zeroconf.cache.iterable_entries()) + finally: + zeroconf.close() + + assert entries != original_entries + assert entries_with_cache != original_entries + assert record_with_10s_ttl in entries + assert record_with_1s_ttl not in entries + assert record_with_10s_ttl in iterable_entries + assert record_with_1s_ttl not in iterable_entries + + def test_reaper_with_dict_change_during_iteration(self): + zeroconf = Zeroconf(interfaces=['127.0.0.1']) + original_entries = zeroconf.cache.entries() + record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + zeroconf.cache.add(record_with_10s_ttl) + zeroconf.cache.add(record_with_1s_ttl) + entries_with_cache = zeroconf.cache.entries() + with unittest.mock.patch("zeroconf.DNSCache.iterable_entries", side_effect=RuntimeError): + time.sleep(1.05) + zeroconf.notify_reaper() + time.sleep(0.05) + + entries = zeroconf.cache.entries() + zeroconf.close() + + assert entries != original_entries + assert entries_with_cache != original_entries + assert record_with_10s_ttl in entries + assert record_with_1s_ttl not in entries + + class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): From 0265a9d57630a4a19bcd3638a6bb3f4b18eba01b Mon Sep 17 00:00:00 2001 From: Justin Nesselrotte Date: Sun, 6 Sep 2020 14:05:18 -0600 Subject: [PATCH 0119/1433] Add ServiceListener to __all__ for Zeroconf module (#298) It's part of the public API. --- zeroconf/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0e8b2a9f8..664aa5545 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -51,6 +51,7 @@ "Zeroconf", "ServiceInfo", "ServiceBrowser", + "ServiceListener", "Error", "InterfaceChoice", "ServiceStateChange", From fb876d6013979cdaa8c0ddebe81e7520e9ee8cc9 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 6 Sep 2020 22:10:07 +0200 Subject: [PATCH 0120/1433] Release version 0.28.4 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index da99ed00e..442ebf43f 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.4 +====== + +* Improved cache reaper performance significantly, thanks to J. Nick Koston. +* Added ServiceListener to __all__ as it's part of the public API, thanks to Justin Nesselrotte. + 0.28.3 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 664aa5545..3fa9b15ac 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.3' +__version__ = '0.28.4' __license__ = 'LGPL' From 1f81e0bcad1cae735ba532758d167368925c8ede Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 9 Sep 2020 18:02:54 +0200 Subject: [PATCH 0121/1433] Test with the development version of Python 3.9 (#300) There've been reports of test failures on Python 3.9, let's verify this. Allowing failures for now until it goes stable. --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index c5369538f..6937b7cd1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,12 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9-dev" - "pypy3.5" - "pypy3" +matrix: + allow_failures: + - python: "3.9-dev" install: - pip install --upgrade -r requirements-dev.txt # mypy can't be installed on pypy From f3219326e65f4410d45ace05f88082354a2f7525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Sep 2020 03:36:09 -0500 Subject: [PATCH 0122/1433] Ignore duplicate messages (#299) When watching packet captures, I noticed that zeroconf was processing incoming data 3x on a my Home Assistant OS install because there are three interfaces. We can skip processing duplicate packets in order to reduce the overhead of decoding data we have already processed. Before Idle cpu ~8.3% recvfrom 4 times 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 sendto 8 times 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 After Idle cpu ~4.1% recvfrom 4 times (no change): 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 sendto 2 times (reduced by 4x): 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 With debug logging on for ~5 minutes bash-5.0# grep 'Received from' home-assistant.log |wc 11458 499196 19706165 bash-5.0# grep 'Ignoring' home-assistant.log |wc 9357 210562 9299687 --- zeroconf/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3fa9b15ac..ad4855c1c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1381,6 +1381,17 @@ def handle_read(self, socket_: socket.socket) -> None: self.log_exception_warning('Error reading from socket %d', socket_.fileno()) return + if self.data == data: + log.debug( + 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', + addr, + port, + socket_.fileno(), + len(data), + data, + ) + return + self.data = data msg = DNSIncoming(data) if msg.valid: From 2db7fff033937a929cdfee1fc7c93c594872799e Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 11 Sep 2020 03:10:50 +0200 Subject: [PATCH 0123/1433] Fix AttributeError: module 'unittest' has no attribute 'mock' (#302) We only had module-level unittest import before now, but code accessing mock through unittest.mock was working because we have a test-level import from unittest.mock which causes unittest to gain the mock attribute and if the test was run before other tests (those using unittest.mock.patch) all was good. If the test was not run before them, though, they'd fail. Closes GH-295. --- zeroconf/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index e4ee1bfae..2b1fb9085 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -12,6 +12,7 @@ import threading import time import unittest +import unittest.mock from threading import Event from typing import Dict, Optional # noqa # used in type hints from typing import cast From eda1b3dd17329c40a59b628b4bbca15c42af43b7 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Fri, 11 Sep 2020 03:14:09 +0200 Subject: [PATCH 0124/1433] Release version 0.28.5 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 442ebf43f..f0c9da169 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.5 +====== + +* Enabled ignoring duplicated messages which decreases CPU usage, thanks to J. Nick Koston. +* Fixed spurious AttributeError: module 'unittest' has no attribute 'mock' in tests. + 0.28.4 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ad4855c1c..d0a922d90 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.4' +__version__ = '0.28.5' __license__ = 'LGPL' From 6ab0cd0a0446f158a1d8a64a3bc548cf9e103179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2020 06:43:48 -0500 Subject: [PATCH 0125/1433] Loosen validation to ensure get_service_info can handle production devices (#307) Validation of names was too strict and rejected devices that are otherwise functional. A partial list of devices that unexpectedly triggered a BadTypeInNameException: Bose Soundtouch Yeelights Rachio Sprinklers iDevices --- zeroconf/__init__.py | 90 ++++++++++++++++++++++++++++---------------- zeroconf/test.py | 56 ++++++++++++++++++++------- 2 files changed, 101 insertions(+), 45 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d0a922d90..d35df9d05 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -179,6 +179,10 @@ _EXPIRE_STALE_TIME_PERCENT = 50 _EXPIRE_REFRESH_TIME_PERCENT = 75 +_LOCAL_TRAILER = '.local.' +_TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' +_NONTCP_PROTOCOL_LOCAL_TRAILER = '._udp.local.' + try: _IPPROTO_IPV6 = socket.IPPROTO_IPV6 except AttributeError: @@ -229,7 +233,7 @@ def _encode_address(address: str) -> bytes: return socket.inet_pton(address_family, address) -def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: +def service_type_name(type_: str, *, allow_underscores: bool = False, strict: bool = True) -> str: """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -246,9 +250,11 @@ def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: This is true because we are implementing mDNS and since the 'm' means multi-cast, the 'local.' domain is mandatory. - 2) local is preceded with either '_udp.' or '_tcp.' + 2) local is preceded with either '_udp.' or '_tcp.' unless + strict is False - 3) service name precedes <_tcp|_udp> + 3) service name precedes <_tcp|_udp> unless + strict is False The rules for Service Names [RFC6335] state that they may be no more than fifteen characters long (not counting the mandatory underscore), @@ -269,45 +275,65 @@ def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: :param type_: Type, SubType or service name to validate :return: fully qualified service name (eg: _http._tcp.local.) """ - if not (type_.endswith('._tcp.local.') or type_.endswith('._udp.local.')): - raise BadTypeInNameException("Type '%s' must end with '._tcp.local.' or '._udp.local.'" % type_) - remaining = type_[: -len('._tcp.local.')].split('.') - name = remaining.pop() - if not name: - raise BadTypeInNameException("No Service name found") + if type_.endswith(_TCP_PROTOCOL_LOCAL_TRAILER) or type_.endswith(_NONTCP_PROTOCOL_LOCAL_TRAILER): + remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') + trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] + has_protocol = True + elif strict: + raise BadTypeInNameException( + "Type '%s' must end with '%s' or '%s'" + % (type_, _TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER) + ) + elif type_.endswith(_LOCAL_TRAILER): + remaining = type_[: -len(_LOCAL_TRAILER)].split('.') + trailer = type_[-len(_LOCAL_TRAILER) + 1 :] + has_protocol = False + else: + raise BadTypeInNameException("Type '%s' must end with '%s'" % (type_, _LOCAL_TRAILER)) - if len(remaining) == 1 and len(remaining[0]) == 0: - raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) + if strict or has_protocol: + service_name = remaining.pop() + if not service_name: + raise BadTypeInNameException("No Service name found") - if name[0] != '_': - raise BadTypeInNameException("Service name (%s) must start with '_'" % name) + if len(remaining) == 1 and len(remaining[0]) == 0: + raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) - # remove leading underscore - name = name[1:] + if service_name[0] != '_': + raise BadTypeInNameException("Service name (%s) must start with '_'" % service_name) - if len(name) > 15: - raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % name) + test_service_name = service_name[1:] - if '--' in name: - raise BadTypeInNameException("Service name (%s) must not contain '--'" % name) + if len(test_service_name) > 15: + raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % test_service_name) - if '-' in (name[0], name[-1]): - raise BadTypeInNameException("Service name (%s) may not start or end with '-'" % name) + if '--' in test_service_name: + raise BadTypeInNameException("Service name (%s) must not contain '--'" % test_service_name) - if not _HAS_A_TO_Z.search(name): - raise BadTypeInNameException("Service name (%s) must contain at least one letter (eg: 'A-Z')" % name) + if '-' in (test_service_name[0], test_service_name[-1]): + raise BadTypeInNameException( + "Service name (%s) may not start or end with '-'" % test_service_name + ) - allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE if allow_underscores else _HAS_ONLY_A_TO_Z_NUM_HYPHEN - ) + if not _HAS_A_TO_Z.search(test_service_name): + raise BadTypeInNameException( + "Service name (%s) must contain at least one letter (eg: 'A-Z')" % test_service_name + ) - if not allowed_characters_re.search(name): - raise BadTypeInNameException( - "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" % (name, ", underscore ('_')" if allow_underscores else "") + allowed_characters_re = ( + _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE if allow_underscores else _HAS_ONLY_A_TO_Z_NUM_HYPHEN ) + if not allowed_characters_re.search(test_service_name): + raise BadTypeInNameException( + "Service name (%s) must contain only these characters: " + "A-Z, a-z, 0-9, hyphen ('-')%s" + % (test_service_name, ", underscore ('_')" if allow_underscores else "") + ) + else: + service_name = '' + if remaining and remaining[-1] == '_sub': remaining.pop() if len(remaining) == 0 or len(remaining[0]) == 0: @@ -326,7 +352,7 @@ def service_type_name(type_: str, *, allow_underscores: bool = False) -> str: "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0] ) - return '_' + name + type_[-len('._tcp.local.') :] + return service_name + trailer # Exceptions @@ -1770,7 +1796,7 @@ def __init__( # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: raise TypeError("addresses and parsed_addresses cannot be provided together") - if not type_.endswith(service_type_name(name, allow_underscores=True)): + if not type_.endswith(service_type_name(name, strict=False, allow_underscores=True)): raise BadTypeInNameException self.type = type_ self.name = name diff --git a/zeroconf/test.py b/zeroconf/test.py index 2b1fb9085..596f666af 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -657,14 +657,43 @@ def test_bad_service_names(self): for name in bad_names_to_try: self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) + def test_bad_local_names_for_get_service_info(self): + bad_names_to_try = ( + 'homekitdev._nothttp._tcp.local.', + 'homekitdev._http._udp.local.', + ) + for name in bad_names_to_try: + self.assertRaises( + r.BadTypeInNameException, self.browser.get_service_info, '_http._tcp.local.', name + ) + def test_good_instance_names(self): + assert r.service_type_name('.._x._tcp.local.') == '_x._tcp.local.' + assert r.service_type_name('x.sub._http._tcp.local.') == '_http._tcp.local.' + assert ( + r.service_type_name('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') + == '_http._tcp.local.' + ) + + def test_good_instance_names_without_protocol(self): good_names_to_try = ( - '.._x._tcp.local.', - 'x.sub._http._tcp.local.', - '6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.', + "Rachio-C73233.local.", + 'YeelightColorBulb-3AFD.local.', + 'YeelightTunableBulb-7220.local.', + "AlexanderHomeAssistant 74651D.local.", + 'iSmartGate-152.local.', + 'MyQ-FGA.local.', + 'lutron-02c4392a.local.', + 'WICED-hap-3E2734.local.', + 'MyHost.local.', + 'MyHost.sub.local.', ) for name in good_names_to_try: - r.service_type_name(name) + assert r.service_type_name(name, strict=False) == 'local.' + + for name in good_names_to_try: + # Raises without strict=False + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) def test_bad_types(self): bad_names_to_try = ( @@ -687,17 +716,18 @@ def test_bad_sub_types(self): def test_good_service_names(self): good_names_to_try = ( - '_x._tcp.local.', - '_x._udp.local.', - '_12345-67890-abc._udp.local.', - 'x._sub._http._tcp.local.', - 'a' * 63 + '._sub._http._tcp.local.', - 'a' * 61 + u'â._sub._http._tcp.local.', + ('_x._tcp.local.', '_x._tcp.local.'), + ('_x._udp.local.', '_x._udp.local.'), + ('_12345-67890-abc._udp.local.', '_12345-67890-abc._udp.local.'), + ('x._sub._http._tcp.local.', '_http._tcp.local.'), + ('a' * 63 + '._sub._http._tcp.local.', '_http._tcp.local.'), + ('a' * 61 + u'â._sub._http._tcp.local.', '_http._tcp.local.'), ) - for name in good_names_to_try: - r.service_type_name(name) - r.service_type_name('_one_two._tcp.local.', allow_underscores=True) + for name, result in good_names_to_try: + assert r.service_type_name(name) == result + + assert r.service_type_name('_one_two._tcp.local.', allow_underscores=True) == '_one_two._tcp.local.' def test_invalid_addresses(self): type_ = "_test-srvc-type._tcp.local." From 6a0c5dd4e84c30264747847e8f1045ece2a14288 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 13 Oct 2020 20:06:40 +0200 Subject: [PATCH 0126/1433] Merge strict and allow_underscores (#309) Those really serve the same purpose -- are we receiving data (and want to be flexible) or registering services (and want to be strict). --- zeroconf/__init__.py | 11 +++++------ zeroconf/test.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d35df9d05..30b26a2c0 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -233,7 +233,7 @@ def _encode_address(address: str) -> bytes: return socket.inet_pton(address_family, address) -def service_type_name(type_: str, *, allow_underscores: bool = False, strict: bool = True) -> str: +def service_type_name(type_: str, *, strict: bool = True) -> str: """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -322,14 +322,13 @@ def service_type_name(type_: str, *, allow_underscores: bool = False, strict: bo ) allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE if allow_underscores else _HAS_ONLY_A_TO_Z_NUM_HYPHEN + _HAS_ONLY_A_TO_Z_NUM_HYPHEN if strict else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE ) if not allowed_characters_re.search(test_service_name): raise BadTypeInNameException( "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" - % (test_service_name, ", underscore ('_')" if allow_underscores else "") + "A-Z, a-z, 0-9, hyphen ('-')%s" % (test_service_name, "" if strict else ", underscore ('_')") ) else: service_name = '' @@ -1564,7 +1563,7 @@ def __init__( assert handlers or listener, 'You need to specify at least one handler' self.types = set(type_ if isinstance(type_, list) else [type_]) for check_type_ in self.types: - if not check_type_.endswith(service_type_name(check_type_, allow_underscores=True)): + if not check_type_.endswith(service_type_name(check_type_, strict=False)): raise BadTypeInNameException threading.Thread.__init__(self) self.daemon = True @@ -1796,7 +1795,7 @@ def __init__( # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: raise TypeError("addresses and parsed_addresses cannot be provided together") - if not type_.endswith(service_type_name(name, strict=False, allow_underscores=True)): + if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException self.type = type_ self.name = name diff --git a/zeroconf/test.py b/zeroconf/test.py index 596f666af..9d1544e72 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -727,7 +727,7 @@ def test_good_service_names(self): for name, result in good_names_to_try: assert r.service_type_name(name) == result - assert r.service_type_name('_one_two._tcp.local.', allow_underscores=True) == '_one_two._tcp.local.' + assert r.service_type_name('_one_two._tcp.local.', strict=False) == '_one_two._tcp.local.' def test_invalid_addresses(self): type_ = "_test-srvc-type._tcp.local." From 474442750d5d529436a118fda98a0b5f4680dc4d Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Tue, 13 Oct 2020 20:09:25 +0200 Subject: [PATCH 0127/1433] Release version 0.28.6 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f0c9da169..e1581b418 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.6 +====== + +* Loosened service name validation when receiving from the network this lets us handle + some real world devices previously causing errors, thanks to J. Nick Koston. + 0.28.5 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 30b26a2c0..40bacfa0e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.5' +__version__ = '0.28.6' __license__ = 'LGPL' From 4da1612b728acbcf2ab0c4bee09891c46f387bfb Mon Sep 17 00:00:00 2001 From: Alexey Vazhnov Date: Mon, 19 Oct 2020 22:42:10 +0000 Subject: [PATCH 0128/1433] Restore IPv6 addresses output Before this change, script `examples/browser.py` printed IPv4 only, even with `--v6` argument. With this change, `examples/browser.py` prints both IPv4 + IPv6 by default, and IPv6 only with `--v6-only` argument. I took the idea from the fork https://github.com/ad3angel1s/python-zeroconf/blob/master/examples/browser.py --- examples/browser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index 624aab9f2..2f2644399 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -7,7 +7,6 @@ import argparse import logging -import socket from time import sleep from typing import cast @@ -23,7 +22,7 @@ def on_service_state_change( info = zeroconf.get_service_info(service_type, name) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = ["%s:%d" % (socket.inet_ntoa(addr), cast(int, info.port)) for addr in info.addresses] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(" Server: %s" % (info.server,)) From 41368588e5fcc6ec9596f306e39e2eaac2a9ec18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Oct 2020 11:44:05 -1000 Subject: [PATCH 0129/1433] Prevent crash when a service is added or removed during handle_response Services are now modified under a lock. The service processing is now done in a try block to ensure RuntimeError is caught which prevents the zeroconf engine from unexpectedly terminating. --- zeroconf/__init__.py | 144 +++++++++++++++++++++++-------------------- 1 file changed, 78 insertions(+), 66 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 40bacfa0e..d9faa960c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2410,6 +2410,7 @@ def __init__( # we add the listener as we could get # a message before the lock is created. self._handlers_lock = threading.Lock() # ensure we process a full message in one go + self._service_lock = threading.Lock() # add and remove services thread safe self.engine = Engine(self) self.listener = Listener(self) @@ -2487,11 +2488,12 @@ def register_service( info.host_ttl = ttl info.other_ttl = ttl self.check_service(info, allow_name_change, cooperating_responders) - self.services[info.name.lower()] = info - if info.type in self.servicetypes: - self.servicetypes[info.type] += 1 - else: - self.servicetypes[info.type] = 1 + with self._service_lock: + self.services[info.name.lower()] = info + if info.type in self.servicetypes: + self.servicetypes[info.type] += 1 + else: + self.servicetypes[info.type] = 1 self._broadcast_service(info) @@ -2500,9 +2502,10 @@ def update_service(self, info: ServiceInfo) -> None: Zeroconf will then respond to requests for information for that service.""" - assert self.services[info.name.lower()] is not None + with self._service_lock: + assert self.services[info.name.lower()] is not None - self.services[info.name.lower()] = info + self.services[info.name.lower()] = info self._broadcast_service(info) @@ -2546,14 +2549,12 @@ def _broadcast_service(self, info: ServiceInfo) -> None: def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service.""" - try: + with self._service_lock: del self.services[info.name.lower()] if self.servicetypes[info.type] > 1: self.servicetypes[info.type] -= 1 else: del self.servicetypes[info.type] - except Exception as e: # TODO stop catching all Exceptions - log.exception('Unknown error, possibly benign: %r', e) now = current_time_millis() next_time = now i = 0 @@ -2600,7 +2601,7 @@ def unregister_all_services(self) -> None: now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - for info in self.services.values(): + for info in list(self.services.values()): out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) out.add_answer_at_time( DNSService( @@ -2766,69 +2767,77 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None out.add_question(question) for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name == "_services._dns-sd._udp.local.": - for stype in self.servicetypes.keys(): - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer( - msg, - DNSPointer( - "_services._dns-sd._udp.local.", _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype - ), - ) - for service in self.services.values(): - if question.name == service.type: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer( - msg, - DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name), - ) - - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer( - DNSService( - service.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.priority, - service.weight, - cast(int, service.port), - service.server, + try: + if question.type == _TYPE_PTR: + if question.name == "_services._dns-sd._udp.local.": + for stype in self.servicetypes.keys(): + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer( + msg, + DNSPointer( + "_services._dns-sd._udp.local.", + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + ), ) - ) - out.add_additional_answer( - DNSText( - service.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, - service.text, + for service in self.services.values(): + if question.name == service.type: + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer( + msg, + DNSPointer( + service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name + ), ) - ) - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. out.add_additional_answer( - DNSAddress( - service.server, - type_, + DNSService( + service.name, + _TYPE_SRV, _CLASS_IN | _CLASS_UNIQUE, service.host_ttl, - address, + service.priority, + service.weight, + cast(int, service.port), + service.server, ) ) - else: - try: + out.add_additional_answer( + DNSText( + service.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + service.other_ttl, + service.text, + ) + ) + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_additional_answer( + DNSAddress( + service.server, + type_, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + address, + ) + ) + else: if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): for service in self.services.values(): - if service.server == question.name.lower(): + if service.server == name_to_find: for address in service.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer( @@ -2842,10 +2851,9 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None ), ) - name_to_find = question.name.lower() - if name_to_find not in self.services: + service = self.services.get(name_to_find) # type: ignore + if service is None: continue - service = self.services[name_to_find] if question.type in (_TYPE_SRV, _TYPE_ANY): out.add_answer( @@ -2884,8 +2892,12 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None address, ) ) - except Exception: # TODO stop catching all Exceptions - self.log_exception_warning() + except Exception: # TODO stop catching all Exceptions + # RuntimeError is expected because the service + # could be added/removed while iterating services + # and we cannot lock here because it would be too + # expensive. + self.log_exception_warning() if out is not None and out.answers: out.id = msg.id From 2708fef6052f7e6e6eb36a157438b316e6d38b21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2020 03:36:22 -1000 Subject: [PATCH 0130/1433] Refactor to move service registration into a registry This permits removing the broad exception catch that was expanded to avoid a crash in when adding or removing a service --- zeroconf/__init__.py | 471 ++++++++++++++++++++++--------------------- zeroconf/test.py | 37 ++++ 2 files changed, 283 insertions(+), 225 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d9faa960c..9fd70b5c4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -381,6 +381,10 @@ class BadTypeInNameException(Error): pass +class ServiceNameAlreadyRegistered(Error): + pass + + # implementation classes @@ -2341,6 +2345,96 @@ def can_send_to(sock: socket.socket, address: str) -> bool: return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) +class ServiceRegistry: + """A registry to keep track of services. + + This class exists to ensure services can + be safely added and removed with thread + safety. + """ + + def __init__( + self, + ) -> None: + """Create the ServiceRegistry class.""" + self.services = {} # type: Dict[str, ServiceInfo] + self.types = {} # type: Dict[str, List] + self.servers = {} # type: Dict[str, List] + self._lock = threading.Lock() # add and remove services thread safe + + def add(self, info: ServiceInfo) -> None: + """Add a new service to the registry.""" + + with self._lock: + self._add(info) + + def remove(self, info: ServiceInfo) -> None: + """Remove a new service from the registry.""" + + with self._lock: + self._remove(info) + + def update(self, info: ServiceInfo) -> None: + """Update new service in the registry.""" + + with self._lock: + self._remove(info) + self._add(info) + + def get_service_infos(self) -> List[ServiceInfo]: + """Return all ServiceInfo.""" + return list(self.services.values()) + + def get_info_name(self, name: str) -> Optional[ServiceInfo]: + """Return all ServiceInfo for the name.""" + return self.services.get(name) + + def get_types(self) -> List[str]: + """Return all types.""" + return list(self.types.keys()) + + def get_infos_type(self, type_: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching type.""" + return self._get_by_index("types", type_) + + def get_infos_server(self, server: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching server.""" + return self._get_by_index("servers", server) + + def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching the index.""" + service_infos = [] + + for name in getattr(self, attr).get(key, [])[:]: + info = self.services.get(name) + # Since we do not get under a lock since it would be + # a performance issue, its possible + # the service can be unregistered during the get + # so we must check if info is None + if info is not None: + service_infos.append(info) + + return service_infos + + def _add(self, info: ServiceInfo) -> None: + """Add a new service under the lock.""" + lower_name = info.name.lower() + if lower_name in self.services: + raise ServiceNameAlreadyRegistered + + self.services[lower_name] = info + self.types.setdefault(info.type, []).append(lower_name) + self.servers.setdefault(info.server, []).append(lower_name) + + def _remove(self, info: ServiceInfo) -> None: + """Remove a service under the lock.""" + lower_name = info.name.lower() + old_service_info = self.services[lower_name] + self.types[old_service_info.type].remove(lower_name) + self.servers[old_service_info.server].remove(lower_name) + del self.services[lower_name] + + class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -2398,8 +2492,7 @@ def __init__( self.listeners = [] # type: List[RecordUpdateListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] - self.services = {} # type: Dict[str, ServiceInfo] - self.servicetypes = {} # type: Dict[str, int] + self.registry = ServiceRegistry() self.cache = DNSCache() @@ -2410,7 +2503,6 @@ def __init__( # we add the listener as we could get # a message before the lock is created. self._handlers_lock = threading.Lock() # ensure we process a full message in one go - self._service_lock = threading.Lock() # add and remove services thread safe self.engine = Engine(self) self.listener = Listener(self) @@ -2488,29 +2580,18 @@ def register_service( info.host_ttl = ttl info.other_ttl = ttl self.check_service(info, allow_name_change, cooperating_responders) - with self._service_lock: - self.services[info.name.lower()] = info - if info.type in self.servicetypes: - self.servicetypes[info.type] += 1 - else: - self.servicetypes[info.type] = 1 - - self._broadcast_service(info) + self.registry.add(info) + self._broadcast_service(info, _REGISTER_TIME, None) def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service.""" - with self._service_lock: - assert self.services[info.name.lower()] is not None - - self.services[info.name.lower()] = info - - self._broadcast_service(info) - - def _broadcast_service(self, info: ServiceInfo) -> None: + self.registry.update(info) + self._broadcast_service(info, _REGISTER_TIME, None) + def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: now = current_time_millis() next_time = now i = 0 @@ -2519,42 +2600,51 @@ def _broadcast_service(self, info: ServiceInfo) -> None: self.wait(next_time - now) now = current_time_millis() continue + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name), 0) - out.add_answer_at_time( - DNSService( - info.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - info.host_ttl, - info.priority, - info.weight, - cast(int, info.port), - info.server, - ), - 0, - ) + self._add_broadcast_answer(out, info, ttl) + self.send(out) + i += 1 + next_time += interval + + def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None: + """Add answers to broadcast a service.""" + other_ttl = info.other_ttl if override_ttl is None else override_ttl + host_ttl = info.host_ttl if override_ttl is None else override_ttl + out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, other_ttl, info.name), 0) + out.add_answer_at_time( + DNSService( + info.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + host_ttl, + info.priority, + info.weight, + cast(int, info.port), + info.server, + ), + 0, + ) + out.add_answer_at_time( + DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, other_ttl, info.text), 0 + ) + for address in info.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, info.other_ttl, info.text), 0 + DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, host_ttl, address), 0 ) - for address in info.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time( - DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, info.host_ttl, address), 0 - ) - self.send(out) - i += 1 - next_time += _REGISTER_TIME def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service.""" - with self._service_lock: - del self.services[info.name.lower()] - if self.servicetypes[info.type] > 1: - self.servicetypes[info.type] -= 1 - else: - del self.servicetypes[info.type] + self.registry.remove(info) + self._broadcast_service(info, _UNREGISTER_TIME, 0) + + def unregister_all_services(self) -> None: + """Unregister all registered services.""" + service_infos = self.registry.get_service_infos() + if not service_infos: + return now = current_time_millis() next_time = now i = 0 @@ -2564,70 +2654,12 @@ def unregister_service(self, info: ServiceInfo) -> None: now = current_time_millis() continue out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.add_answer_at_time( - DNSService( - info.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - 0, - info.priority, - info.weight, - cast(int, info.port), - info.name, - ), - 0, - ) - out.add_answer_at_time(DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, 0, info.text), 0) - - for address in info.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time( - DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, 0, address), 0 - ) + for info in service_infos: + self._add_broadcast_answer(out, info, 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME - def unregister_all_services(self) -> None: - """Unregister all registered services.""" - if len(self.services) > 0: - now = current_time_millis() - next_time = now - i = 0 - while i < 3: - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - for info in list(self.services.values()): - out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, 0, info.name), 0) - out.add_answer_at_time( - DNSService( - info.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - 0, - info.priority, - info.weight, - cast(int, info.port), - info.server, - ), - 0, - ) - out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, 0, info.text), 0 - ) - for address in info.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time( - DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, 0, address), 0 - ) - self.send(out) - i += 1 - next_time += _UNREGISTER_TIME - def check_service( self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False ) -> None: @@ -2767,137 +2799,126 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None out.add_question(question) for question in msg.questions: - try: - if question.type == _TYPE_PTR: - if question.name == "_services._dns-sd._udp.local.": - for stype in self.servicetypes.keys(): - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer( - msg, - DNSPointer( - "_services._dns-sd._udp.local.", - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - for service in self.services.values(): - if question.name == service.type: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer( - msg, - DNSPointer( - service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name - ), - ) - - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer( - DNSService( - service.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.priority, - service.weight, - cast(int, service.port), - service.server, - ) - ) - out.add_additional_answer( - DNSText( - service.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, - service.text, - ) - ) - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_additional_answer( - DNSAddress( - service.server, - type_, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - address, - ) - ) - else: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - name_to_find = question.name.lower() - - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.services.values(): - if service.server == name_to_find: - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer( - msg, - DNSAddress( - question.name, - type_, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - address, - ), - ) - - service = self.services.get(name_to_find) # type: ignore - if service is None: - continue - - if question.type in (_TYPE_SRV, _TYPE_ANY): + if question.type == _TYPE_PTR: + if question.name == "_services._dns-sd._udp.local.": + for stype in self.registry.get_types(): + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer( msg, - DNSService( - question.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.priority, - service.weight, - cast(int, service.port), - service.server, + DNSPointer( + "_services._dns-sd._udp.local.", + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, ), ) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer( - msg, - DNSText( - question.name, - _TYPE_TXT, + for service in self.registry.get_infos_type(question.name): + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + out.add_answer( + msg, + DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name), + ) + + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer( + DNSService( + service.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.priority, + service.weight, + cast(int, service.port), + service.server, + ) + ) + out.add_additional_answer( + DNSText( + service.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + service.other_ttl, + service.text, + ) + ) + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_additional_answer( + DNSAddress( + service.server, + type_, _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, - service.text, - ), + service.host_ttl, + address, + ) ) - if question.type == _TYPE_SRV: + else: + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + name_to_find = question.name.lower() + + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): for address in service.addresses_by_version(IPVersion.All): type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_additional_answer( + out.add_answer( + msg, DNSAddress( - service.server, + question.name, type_, _CLASS_IN | _CLASS_UNIQUE, service.host_ttl, address, - ) + ), ) - except Exception: # TODO stop catching all Exceptions - # RuntimeError is expected because the service - # could be added/removed while iterating services - # and we cannot lock here because it would be too - # expensive. - self.log_exception_warning() + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + continue + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer( + msg, + DNSService( + question.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + service.priority, + service.weight, + cast(int, service.port), + service.server, + ), + ) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer( + msg, + DNSText( + question.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + service.other_ttl, + service.text, + ), + ) + if question.type == _TYPE_SRV: + for address in service.addresses_by_version(IPVersion.All): + type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A + out.add_additional_answer( + DNSAddress( + service.server, + type_, + _CLASS_IN | _CLASS_UNIQUE, + service.host_ttl, + address, + ) + ) if out is not None and out.answers: out.id = msg.id diff --git a/zeroconf/test.py b/zeroconf/test.py index 9d1544e72..cfbd2a799 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -878,6 +878,43 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): nbr_answers = nbr_additionals = nbr_authorities = 0 +class TestServiceRegistry(unittest.TestCase): + def test_only_register_once(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) + registry.remove(info) + registry.add(info) + + def test_lookups(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + + assert registry.get_service_infos() == [info] + assert registry.get_info_name(registration_name) == info + assert registry.get_infos_type(type_) == [info] + assert registry.get_infos_server("ash-2.local.") == [info] + assert registry.get_types() == [type_] + + class TestDNSCache(unittest.TestCase): def test_order(self): record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') From 8f7effd2f89c542162d0e5ac257c561501690d16 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Sun, 13 Dec 2020 02:08:20 +0100 Subject: [PATCH 0131/1433] Release version 0.28.7 --- README.rst | 7 +++++++ zeroconf/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e1581b418..0bf8fcdc8 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,13 @@ See examples directory for more. Changelog ========= +0.28.7 +====== + +* Fixed the IPv6 address rendering in the browser example, thanks to Alexey Vazhnov. +* Fixed a crash happening when a service is added or removed during handle_response + and improved exception handling, thanks to J. Nick Koston. + 0.28.6 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9fd70b5c4..50359458c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.6' +__version__ = '0.28.7' __license__ = 'LGPL' From 86b4e11434d44e2f9a42354109a10f601c44d66a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Dec 2020 13:50:16 -1000 Subject: [PATCH 0132/1433] Ensure the name cache is rolled back when the packet reaches maximum size If the packet was too large, it would be rolled back at the end of write_record. We need to remove the names that were added to the name cache (self.names) as well to avoid a case were we would create a pointer to a name that was rolled back. The size of the packet was incorrect at the end after the inserts because insert_short would increase self.size even though it was already accounted before. To resolve this insert_short_at_start was added which does not increase self.size. This did not cause an actual bug, however it sure made debugging this problem far more difficult. Additionally the size now inserted and then replaced when the actual size is known because it made debugging quite difficult since the size did not previously agree with the data. --- zeroconf/__init__.py | 43 ++++++------ zeroconf/test.py | 153 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 19 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 50359458c..0fb9aec51 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1013,10 +1013,13 @@ def write_byte(self, value: int) -> None: """Writes a single byte to the packet""" self.pack(b'!c', int2byte(value)) - def insert_short(self, index: int, value: int) -> None: - """Inserts an unsigned short in a certain position in the packet""" - self.data.insert(index, struct.pack(b'!H', value)) - self.size += 2 + def insert_short_at_start(self, value: int) -> None: + """Inserts an unsigned short at the start of the packet""" + self.data.insert(0, struct.pack(b'!H', value)) + + def replace_short(self, index: int, value: int) -> None: + """Replaces an unsigned short in a certain position in the packet""" + self.data[index] = struct.pack(b'!H', value) def write_short(self, value: int) -> None: """Writes an unsigned short to the packet""" @@ -1123,15 +1126,13 @@ def write_record(self, record: DNSRecord, now: float, allow_long: bool = False) self.write_int(record.get_remaining_ttl(now)) index = len(self.data) - # Adjust size for the short we will write before this record - self.size += 2 + self.write_short(0) # Will get replaced with the actual size record.write(self) - self.size -= 2 - - length = sum((len(d) for d in self.data[index:])) - # Here is the short we adjusted for - self.insert_short(index, length) - + # Adjust size for the short we will write before this record + length = sum((len(d) for d in self.data[index + 1 :])) + # Here we replace the 0 length short we wrote + # before with the actual length + self.replace_short(index, length) len_limit = _MAX_MSG_ABSOLUTE if allow_long else _MAX_MSG_TYPICAL # if we go over, then rollback and quit @@ -1139,6 +1140,10 @@ def write_record(self, record: DNSRecord, now: float, allow_long: bool = False) while len(self.data) > start_data_length: self.data.pop() self.size = start_size + + rollback_names = [name for name, idx in self.names.items() if idx >= start_size] + for name in rollback_names: + del self.names[name] return False return True @@ -1207,15 +1212,15 @@ def packets(self) -> List[bytes]: if self.write_record(additional, 0): additionals_written += 1 - self.insert_short(0, additionals_written) - self.insert_short(0, authorities_written) - self.insert_short(0, answers_written) - self.insert_short(0, questions_written) - self.insert_short(0, self.flags) + self.insert_short_at_start(additionals_written) + self.insert_short_at_start(authorities_written) + self.insert_short_at_start(answers_written) + self.insert_short_at_start(questions_written) + self.insert_short_at_start(self.flags) if self.multicast: - self.insert_short(0, 0) + self.insert_short_at_start(0) else: - self.insert_short(0, self.id) + self.insert_short_at_start(self.id) self.packets_data.append(b''.join(self.data)) self.reset_for_next_packet() diff --git a/zeroconf/test.py b/zeroconf/test.py index cfbd2a799..f558e7111 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1973,3 +1973,156 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): # unregister zc.unregister_service(info) + + +def test_dns_compression_rollback_for_corruption(): + """Verify rolling back does not lead to dns compression corruption.""" + out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + r.DNSService( + record["name"], # type: ignore + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSText( + record["name"], # type: ignore + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSAddress( + record["name"], # type: ignore + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + for packet in out.packets(): + # Verify we can process the packets we created to + # ensure there is no corruption with the dns compression + incoming = r.DNSIncoming(packet) + assert incoming.valid is True From 1d726b551a49e945b134df6e29b352697030c5a9 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 4 Jan 2021 19:47:33 +0100 Subject: [PATCH 0133/1433] Release version 0.28.8 --- README.rst | 6 ++++++ zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0bf8fcdc8..0fe2164d2 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,12 @@ See examples directory for more. Changelog ========= +0.28.8 +====== + +* Fixed the packet generation when multiple packets are necessary, previously invalid + packets were generated sometimes. Patch thanks to J. Nick Koston. + 0.28.7 ====== diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0fb9aec51..2535e8ede 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.7' +__version__ = '0.28.8' __license__ = 'LGPL' From c5a675d22788aa905a4e47feb1d4c30f30416356 Mon Sep 17 00:00:00 2001 From: Pack3tL0ss Date: Wed, 27 Jan 2021 01:52:09 -0600 Subject: [PATCH 0134/1433] Fix link to readme md --> rst (#324) --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index c4fa6143c..59952189e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,4 +28,4 @@ Contents api -See `the project's README `_ for more information. +See `the project's README `_ for more information. From 5e268faeaa99f0a513c7bbeda8f447f4eb36a747 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Feb 2021 09:25:25 -1000 Subject: [PATCH 0135/1433] Simplify read_name (venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result = "".join((result, "thisisaname" + "."))' 20000 loops, best of 5: 16.4 usec per loop (venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result += "thisisaname" + "."' 2000000 loops, best of 5: 0.105 usec per loop --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 2535e8ede..dbeef01bc 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -880,7 +880,7 @@ def read_name(self) -> str: break t = length & 0xC0 if t == 0x00: - result = ''.join((result, self.read_utf(off, length) + '.')) + result += self.read_utf(off, length) + '.' off += length elif t == 0xC0: if next_ < 0: From 6beefbbe76a0e261394b308c8cc68545be653019 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Mar 2021 09:20:16 -1000 Subject: [PATCH 0136/1433] Use a single socket for InterfaceChoice.Default When using multiple sockets with multi-cast, the outgoing socket's responses could be read back on the incoming socket, which leads to duplicate processing and could fill up the incoming buffer before it could be processed. This behavior manifested with error similar to `OSError: [Errno 105] No buffer space available` By using a single socket with InterfaceChoice.Default we avoid this case. --- zeroconf/__init__.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dbeef01bc..e21c3e807 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2255,8 +2255,7 @@ def new_socket( def add_multicast_member( listen_socket: socket.socket, interface: Union[str, Tuple[Tuple[str, int, int], int]], - apple_p2p: bool = False, -) -> Optional[socket.socket]: +) -> None: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, tuple) err_einval = {errno.EINVAL} @@ -2293,6 +2292,12 @@ def add_multicast_member( else: raise + +def new_respond_socket( + interface: Union[str, Tuple[Tuple[str, int, int], int]], + apple_p2p: bool = False, +) -> Optional[socket.socket]: + is_v6 = isinstance(interface, tuple) respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p, @@ -2300,6 +2305,7 @@ def add_multicast_member( ) log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) if is_v6: + iface_bin = struct.pack('@I', cast(int, interface[1])) respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) else: respond_socket.setsockopt( @@ -2321,11 +2327,19 @@ def create_sockets( normalized_interfaces = normalize_interface_choice(interfaces, ip_version) + # If we are using InterfaceChoice.Default we can use + # a single socket to listen and respond. + if not unicast and interfaces is InterfaceChoice.Default: + for i in normalized_interfaces: + add_multicast_member(cast(socket.socket, listen_socket), i) + return listen_socket, [listen_socket] + respond_sockets = [] for i in normalized_interfaces: if not unicast: - respond_socket = add_multicast_member(cast(socket.socket, listen_socket), i, apple_p2p=apple_p2p) + add_multicast_member(cast(socket.socket, listen_socket), i) + respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) else: respond_socket = new_socket( port=0, @@ -2494,6 +2508,7 @@ def __init__( interfaces, unicast, ip_version, apple_p2p=apple_p2p ) log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) + self.multi_socket = unicast or interfaces is not InterfaceChoice.Default self.listeners = [] # type: List[RecordUpdateListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] @@ -2513,8 +2528,9 @@ def __init__( self.listener = Listener(self) if not unicast: self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) - for s in self._respond_sockets: - self.engine.add_reader(self.listener, s) + if self.multi_socket: + for s in self._respond_sockets: + self.engine.add_reader(self.listener, s) self.reaper = Reaper(self) self.debug = None # type: Optional[DNSOutgoing] @@ -2978,8 +2994,9 @@ def close(self) -> None: if not self.unicast: self.engine.del_reader(cast(socket.socket, self._listen_socket)) cast(socket.socket, self._listen_socket).close() - for s in self._respond_sockets: - self.engine.del_reader(s) + if self.multi_socket: + for s in self._respond_sockets: + self.engine.del_reader(s) self.engine.join() # shutdown the rest From 3e6f24a5fd562d3ee3985cc3cb83bcb276abe9d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Mar 2021 11:33:59 -1000 Subject: [PATCH 0137/1433] cast listen_socket to socket.socket in create_sockets Resolves typing error --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e21c3e807..867506515 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2332,7 +2332,7 @@ def create_sockets( if not unicast and interfaces is InterfaceChoice.Default: for i in normalized_interfaces: add_multicast_member(cast(socket.socket, listen_socket), i) - return listen_socket, [listen_socket] + return listen_socket, [cast(socket.socket, listen_socket)] respond_sockets = [] From ab67a7aecd63042178061f0d1a76f9a7f6e1559a Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 24 Mar 2021 23:23:23 +0100 Subject: [PATCH 0138/1433] Drop Python 3.5 compatibilty, it reached its end of life --- .travis.yml | 6 +----- Makefile | 5 +---- README.rst | 9 ++++++++- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6937b7cd1..c9d32a7f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,9 @@ language: python python: - - "3.5" - "3.6" - "3.7" - "3.8" - "3.9-dev" - - "pypy3.5" - "pypy3" matrix: allow_failures: @@ -13,9 +11,7 @@ matrix: install: - pip install --upgrade -r requirements-dev.txt # mypy can't be installed on pypy - - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install mypy ; fi - - if [[ "${TRAVIS_PYTHON_VERSION}" != *"3.5"* && "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then - pip install black ; fi + - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install black mypy ; fi script: # no IPv6 support in Travis :( - SKIP_IPV6=1 make ci diff --git a/Makefile b/Makefile index af951f265..25fdbb2c3 100644 --- a/Makefile +++ b/Makefile @@ -6,10 +6,7 @@ PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.ver LINT_TARGETS:=flake8 ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) - LINT_TARGETS:=$(LINT_TARGETS) mypy -endif -ifeq ($(or $(findstring 3.5,$(PYTHON_VERSION)),$(findstring PyPy,$(PYTHON_IMPLEMENTATION))),) - LINT_TARGETS:=$(LINT_TARGETS) black_check + LINT_TARGETS:=$(LINT_TARGETS) mypy black_check endif diff --git a/README.rst b/README.rst index 0fe2164d2..4ec6e73f1 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.5+ +* CPython 3.6+ * PyPy3 5.8+ Versioning @@ -134,6 +134,13 @@ See examples directory for more. Changelog ========= +0.29.0 (not released yet) +========================= + +Backwards incompatible: + +* Dropped Python 3.5 support + 0.28.8 ====== From bd80d20682c0af5e15a4b7102dcfe814cdba3a01 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 24 Mar 2021 23:27:25 +0100 Subject: [PATCH 0139/1433] Switch from Travis CI/Coveralls to GH Actions/Codecov Travis CI free tier is going away and Codecov is my go-to code coverage service now. Closes GH-332. --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ .travis.yml | 19 ------------------- README.rst | 10 +++++----- docs/index.rst | 12 +++++------- requirements-dev.txt | 2 ++ 5 files changed, 45 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..86cc95a7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - "**" + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install --upgrade -r requirements-dev.txt + pip install . + - name: Run tests + run: make ci + env: + SKIP_IPV6: 1 + - name: Report coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c9d32a7f4..000000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" - - "3.9-dev" - - "pypy3" -matrix: - allow_failures: - - python: "3.9-dev" -install: - - pip install --upgrade -r requirements-dev.txt - # mypy can't be installed on pypy - - if [[ "${TRAVIS_PYTHON_VERSION}" != "pypy"* ]] ; then pip install black mypy ; fi -script: - # no IPv6 support in Travis :( - - SKIP_IPV6=1 make ci -after_success: - - coveralls diff --git a/README.rst b/README.rst index 4ec6e73f1..887fd8e49 100644 --- a/README.rst +++ b/README.rst @@ -1,14 +1,14 @@ python-zeroconf =============== -.. image:: https://travis-ci.org/jstasiak/python-zeroconf.svg?branch=master - :target: https://travis-ci.org/jstasiak/python-zeroconf - +.. image:: https://github.com/jstasiak/python-zeroconf/workflows/CI/badge.svg + :target: https://github.com/jstasiak/python-zeroconf?query=workflow%3ACI+branch%3Amaster + .. image:: https://img.shields.io/pypi/v/zeroconf.svg :target: https://pypi.python.org/pypi/zeroconf -.. image:: https://img.shields.io/coveralls/jstasiak/python-zeroconf.svg - :target: https://coveralls.io/r/jstasiak/python-zeroconf +.. image:: https://codecov.io/gh/jstasiak/python-zeroconf/branch/master/graph/badge.svg + :target: https://codecov.io/gh/jstasiak/python-zeroconf `Documentation `_. diff --git a/docs/index.rst b/docs/index.rst index 59952189e..de5ba41af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,16 +1,14 @@ Welcome to python-zeroconf documentation! ========================================= -.. image:: https://travis-ci.org/jstasiak/python-zeroconf.svg?branch=master - :alt: Build status - :target: https://travis-ci.org/jstasiak/python-zeroconf +.. image:: https://github.com/jstasiak/python-zeroconf/workflows/CI/badge.svg + :target: https://github.com/jstasiak/python-zeroconf?query=workflow%3ACI+branch%3Amaster .. image:: https://img.shields.io/pypi/v/zeroconf.svg :target: https://pypi.python.org/pypi/zeroconf - -.. image:: https://coveralls.io/repos/github/jstasiak/python-zeroconf/badge.svg?branch=master - :alt: Covergage status - :target: https://coveralls.io/github/jstasiak/python-zeroconf?branch=master + +.. image:: https://codecov.io/gh/jstasiak/python-zeroconf/branch/master/graph/badge.svg + :target: https://codecov.io/gh/jstasiak/python-zeroconf GitHub (code repository, issues): https://github.com/jstasiak/python-zeroconf diff --git a/requirements-dev.txt b/requirements-dev.txt index 2d0490aed..8c1527b44 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,12 @@ autopep8 +black;implementation_name=="cpython" coveralls coverage # Version restricted because of https://github.com/PyCQA/pycodestyle/issues/741 flake8>=3.6.0 flake8-import-order ifaddr +mypy;implementation_name=="cpython" # 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152 pep8-naming!=0.6.0,!=0.11.0 pytest From 6482da05344e6ae8c4da440da4a704a20c344bb6 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 24 Mar 2021 23:44:46 +0100 Subject: [PATCH 0140/1433] Silence a mypy false-positive --- zeroconf/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 867506515..53d49ff47 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2260,7 +2260,8 @@ def add_multicast_member( is_v6 = isinstance(interface, tuple) err_einval = {errno.EINVAL} if sys.platform == 'win32': - err_einval |= {errno.WSAEINVAL} + # No WSAEINVAL definition in typeshed + err_einval |= {cast(Any, errno).WSAEINVAL} log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: From bc6ef8c65b22d982798104d5bdf11b78746a8ddd Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 24 Mar 2021 23:57:58 +0100 Subject: [PATCH 0141/1433] Silence a flaky test on PyPy --- zeroconf/test.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index f558e7111..fce2512fb 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -7,6 +7,7 @@ import copy import logging import os +import platform import socket import struct import threading @@ -17,6 +18,8 @@ from typing import Dict, Optional # noqa # used in type hints from typing import cast +import pytest + import zeroconf as r from zeroconf import ( DNSHinfo, @@ -1126,6 +1129,7 @@ def test_integration_with_subtype_and_listener(self): class ListenerTest(unittest.TestCase): + @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason="Flaky on PyPy") def test_integration_with_listener_class(self): service_added = Event() From f871b90d25c0f788590ceb14237b08a6b5e6eeeb Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 25 Mar 2021 00:10:19 +0100 Subject: [PATCH 0142/1433] Make mypy configuration more lenient We want to be able to call untyped modules. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 5610cf680..d4354ef45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,7 +6,7 @@ ignore=E203,W503 [mypy] ignore_missing_imports = true -follow_imports = error +follow_imports = ignore check_untyped_defs = true no_implicit_optional = true warn_incomplete_stub = true From 53cb8044bfb4256f570d438817fd37acc8b78511 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 25 Mar 2021 00:00:35 +0100 Subject: [PATCH 0143/1433] Fill a missing changelog entry --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 887fd8e49..9b8a4b980 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,9 @@ Changelog 0.29.0 (not released yet) ========================= +* A single socket is used for listening on responding when `InterfaceChoice.Default` is chosen. + Thanks to J. Nick Koston. + Backwards incompatible: * Dropped Python 3.5 support From 203ec2e26e6f0f676e7d88b4a1b0c80ad74659f1 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Thu, 25 Mar 2021 00:01:25 +0100 Subject: [PATCH 0144/1433] Release version 0.29.0 --- README.rst | 4 ++-- zeroconf/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 9b8a4b980..956eab05a 100644 --- a/README.rst +++ b/README.rst @@ -134,8 +134,8 @@ See examples directory for more. Changelog ========= -0.29.0 (not released yet) -========================= +0.29.0 +====== * A single socket is used for listening on responding when `InterfaceChoice.Default` is chosen. Thanks to J. Nick Koston. diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 53d49ff47..d8890565d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -42,7 +42,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.28.8' +__version__ = '0.29.0' __license__ = 'LGPL' From fe948105cc0923336ffa6d93cbe7d45470612a36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 May 2021 20:00:57 -1000 Subject: [PATCH 0145/1433] Simplify cache iteration (#340) - Remove the need to trap runtime error - Only copy the names of the keys when iterating the cache - Fixes RuntimeError: list changed size during iterating entries_from_name - Cache services - The Repear thread is no longer aware of the cache internals --- zeroconf/__init__.py | 87 +++++++++++++++++--------------------------- zeroconf/test.py | 41 ++++----------------- 2 files changed, 42 insertions(+), 86 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d8890565d..ca9303866 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,7 +23,6 @@ import enum import errno import ipaddress -import itertools import logging import platform import re @@ -1243,23 +1242,29 @@ class DNSCache: def __init__(self) -> None: self.cache = {} # type: Dict[str, List[DNSRecord]] + self.service_cache = {} # type: Dict[str, List[DNSRecord]] def add(self, entry: DNSRecord) -> None: """Adds an entry""" # Insert last in list, get will return newest entry # iteration will result in last update winning self.cache.setdefault(entry.key, []).append(entry) + if isinstance(entry, DNSService): + self.service_cache.setdefault(entry.server, []).append(entry) def remove(self, entry: DNSRecord) -> None: - """Removes an entry""" + """Removes an entry.""" + if isinstance(entry, DNSService): + DNSCache.remove_key(self.service_cache, entry.server, entry) + DNSCache.remove_key(self.cache, entry.key, entry) + + @staticmethod + def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: + """Forgiving remove of a cache key.""" try: - list_ = self.cache[entry.key] - list_.remove(entry) - # If we remove the last entry in the list - # we remove the key from the dict in order - # to avoid leaking memory - if not list_: - del self.cache[entry.key] + cache[key].remove(entry) + if not cache[key]: + del cache[key] except (KeyError, ValueError): pass @@ -1281,12 +1286,13 @@ def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSReco entry = DNSEntry(name, type_, class_) return self.get(entry) + def entries_with_server(self, server: str) -> List[DNSRecord]: + """Returns a list of entries whose server matches the name.""" + return self.service_cache.get(server, [])[:] + def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" - try: - return self.cache[name.lower()] - except KeyError: - return [] + return self.cache.get(name.lower(), [])[:] def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() @@ -1299,25 +1305,17 @@ def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[D return record return None - def entries(self) -> List[DNSRecord]: - """Returns a list of all entries""" - if not self.cache: - return [] - - # avoid size change during iteration by copying the cache - return list(itertools.chain.from_iterable(list(self.cache.values()))) + def names(self) -> List[str]: + """Return a copy of the list of current cache names.""" + return list(self.cache) - def iterable_entries(self) -> Iterable[DNSRecord]: - """Returns an iterable of all entries. - - This function is provided to avoid copying - the entries but is not threadsafe as the - contents of the cache can change during iteration. - - Callers should trap RuntimeError and fallback - to calling entries. - """ - return itertools.chain.from_iterable(self.cache.values()) + def expire(self, now: float) -> Iterable[DNSRecord]: + """Purge expired entries from the cache.""" + for name in self.names(): + for record in self.entries_with_name(name): + if record.is_expired(now): + self.remove(record) + yield record class Engine(threading.Thread): @@ -1486,22 +1484,10 @@ def run(self) -> None: if self.zc.done: return - try: - # We try to iterate the cache without copying the whole - # cache as this can be quite an expensive operation. - self._cleanup_cache(self.zc.cache.iterable_entries()) - except RuntimeError: - # If the cache changes during iteration, we fallback - # to making a copy before iteraiton. - self._cleanup_cache(self.zc.cache.entries()) - - def _cleanup_cache(self, entries: Iterable[DNSRecord]) -> None: - """Remove expired entries from the cache.""" - now = current_time_millis() - for record in entries: - if record.is_expired(now): + + now = current_time_millis() + for record in self.zc.cache.expire(now): self.zc.update_record(now, record) - self.zc.cache.remove(record) class Signal: @@ -1706,9 +1692,7 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> return # Iterate through the DNSCache and callback any services that use this address - for service in zc.cache.entries(): - if not isinstance(service, DNSService) or not service.server == record.name: - continue + for service in self.zc.cache.entries_with_server(record.name): for type_ in self.types: if service.name.endswith(type_): enqueue_callback(ServiceStateChange.Updated, type_, service.name) @@ -2772,10 +2756,7 @@ def handle_response(self, msg: DNSIncoming) -> None: # we can avoid iterating everything in the cache and # only look though entries for the specific name. # entries_with_name will take care of converting to lowercase - # - # We make a copy of the list that entries_with_name returns - # since we cannot iterate over something we might remove - for entry in self.cache.entries_with_name(record.name).copy(): + for entry in self.cache.entries_with_name(record.name): if entry == record: updated = False diff --git a/zeroconf/test.py b/zeroconf/test.py index fce2512fb..7349b42a5 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -5,6 +5,7 @@ """ Unit tests for zeroconf.py """ import copy +import itertools import logging import os import platform @@ -958,45 +959,18 @@ def test_cache_empty_multiple_calls_does_not_throw(self): class TestReaper(unittest.TestCase): def test_reaper(self): zeroconf = Zeroconf(interfaces=['127.0.0.1']) - original_entries = zeroconf.cache.entries() + cache = zeroconf.cache + original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') zeroconf.cache.add(record_with_10s_ttl) zeroconf.cache.add(record_with_1s_ttl) - entries_with_cache = zeroconf.cache.entries() + entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) time.sleep(1.05) zeroconf.notify_reaper() time.sleep(0.05) - entries = zeroconf.cache.entries() - - try: - iterable_entries = list(zeroconf.cache.iterable_entries()) - finally: - zeroconf.close() - - assert entries != original_entries - assert entries_with_cache != original_entries - assert record_with_10s_ttl in entries - assert record_with_1s_ttl not in entries - assert record_with_10s_ttl in iterable_entries - assert record_with_1s_ttl not in iterable_entries - - def test_reaper_with_dict_change_during_iteration(self): - zeroconf = Zeroconf(interfaces=['127.0.0.1']) - original_entries = zeroconf.cache.entries() - record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') - zeroconf.cache.add(record_with_10s_ttl) - zeroconf.cache.add(record_with_1s_ttl) - entries_with_cache = zeroconf.cache.entries() - with unittest.mock.patch("zeroconf.DNSCache.iterable_entries", side_effect=RuntimeError): - time.sleep(1.05) - zeroconf.notify_reaper() - time.sleep(0.05) - - entries = zeroconf.cache.entries() + entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) zeroconf.close() - assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries @@ -1196,8 +1170,9 @@ def update_service(self, zeroconf, type, name): time.sleep(3) # clear the answer cache to force query - for record in zeroconf_browser.cache.entries(): - zeroconf_browser.cache.remove(record) + for name in zeroconf_browser.cache.names(): + for record in zeroconf_browser.cache.entries_with_name(name): + zeroconf_browser.cache.remove(record) # get service info without answer cache info = zeroconf_browser.get_service_info(type_, registration_name) From beccad1f0b41730f541b2e90ea2eaa2496de5044 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 May 2021 20:16:26 -1000 Subject: [PATCH 0146/1433] Skip socket creation if add_multicast_member fails (windows) (#341) Co-authored-by: Timothee 'TTimo' Besset --- zeroconf/__init__.py | 15 +++++++++------ zeroconf/test.py | 19 +++++++++++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ca9303866..76f32174e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2239,7 +2239,7 @@ def new_socket( def add_multicast_member( listen_socket: socket.socket, interface: Union[str, Tuple[Tuple[str, int, int], int]], -) -> None: +) -> bool: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, tuple) err_einval = {errno.EINVAL} @@ -2263,19 +2263,20 @@ def add_multicast_member( 'it is expected to happen on some systems', interface, ) - return None + return False elif _errno == errno.EADDRNOTAVAIL: log.info( 'Address not available when adding %s to multicast ' 'group, it is expected to happen on some systems', interface, ) - return None + return False elif _errno in err_einval: log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) - return None + return False else: raise + return True def new_respond_socket( @@ -2323,8 +2324,10 @@ def create_sockets( for i in normalized_interfaces: if not unicast: - add_multicast_member(cast(socket.socket, listen_socket), i) - respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) + if add_multicast_member(cast(socket.socket, listen_socket), i): + respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) + else: + respond_socket = None else: respond_socket = new_socket( port=0, diff --git a/zeroconf/test.py b/zeroconf/test.py index 7349b42a5..60bfb4d56 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -5,6 +5,7 @@ """ Unit tests for zeroconf.py """ import copy +import errno import itertools import logging import os @@ -16,8 +17,7 @@ import unittest import unittest.mock from threading import Event -from typing import Dict, Optional # noqa # used in type hints -from typing import cast +from typing import Dict, Optional, cast # noqa # used in type hints import pytest @@ -2105,3 +2105,18 @@ def test_dns_compression_rollback_for_corruption(): # ensure there is no corruption with the dns compression incoming = r.DNSIncoming(packet) assert incoming.valid is True + + +@pytest.mark.parametrize( + "errno,expected_result", + [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], +) +def test_add_multicast_member_socket_errors(errno, expected_result): + """Test we handle socket errors when adding multicast members.""" + if errno: + setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, "Error: {}".format(errno))) + else: + setsockopt_mock = unittest.mock.Mock() + fileno_mock = unittest.mock.PropertyMock(return_value=10) + socket_mock = unittest.mock.Mock(setsockopt=setsockopt_mock, fileno=fileno_mock) + assert r.add_multicast_member(socket_mock, "0.0.0.0") == expected_result From 523aefb0b0c477489e4e1e4ab763ce56c57295b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 May 2021 16:10:41 -1000 Subject: [PATCH 0147/1433] Return early when already closed (#350) - Reduce indentation with a return early guard in close --- zeroconf/__init__.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 76f32174e..0284b51db 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2969,24 +2969,25 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P def close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" - if not self._GLOBAL_DONE: - # remove service listeners - self.remove_all_service_listeners() - self.unregister_all_services() - self._GLOBAL_DONE = True - - # shutdown recv socket and thread - if not self.unicast: - self.engine.del_reader(cast(socket.socket, self._listen_socket)) - cast(socket.socket, self._listen_socket).close() - if self.multi_socket: - for s in self._respond_sockets: - self.engine.del_reader(s) - self.engine.join() - - # shutdown the rest - self.notify_all() - self.notify_reaper() - self.reaper.join() + if self._GLOBAL_DONE: + return + # remove service listeners + self.remove_all_service_listeners() + self.unregister_all_services() + self._GLOBAL_DONE = True + + # shutdown recv socket and thread + if not self.unicast: + self.engine.del_reader(cast(socket.socket, self._listen_socket)) + cast(socket.socket, self._listen_socket).close() + if self.multi_socket: for s in self._respond_sockets: - s.close() + self.engine.del_reader(s) + self.engine.join() + + # shutdown the rest + self.notify_all() + self.notify_reaper() + self.reaper.join() + for s in self._respond_sockets: + s.close() From 781627864efbb3c8285e1b75144d688083414cf3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 May 2021 21:47:36 -1000 Subject: [PATCH 0148/1433] Eliminate the reaper thread (#349) - Cache is now purged between reads when the interval is reached - Reduce locking since we are already making a copy of the readers and not reading under the lock - Simplify shutdown process --- zeroconf/__init__.py | 97 +++++++++++++++++--------------------------- zeroconf/test.py | 19 +++++++-- 2 files changed, 53 insertions(+), 63 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0284b51db..ba5886443 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -34,7 +34,7 @@ import time import warnings from collections import OrderedDict -from typing import Dict, Iterable, List, Optional, Sequence, Union, cast +from typing import Dict, Iterable, List, Optional, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -1337,39 +1337,51 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.readers = {} # type: Dict[socket.socket, Listener] self.timeout = 5 + self.cache_cleanup_interval_ms = 10000.0 self.condition = threading.Condition() self.socketpair = socket.socketpair() + self._last_cache_cleanup = 0.0 self.start() self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) def run(self) -> None: while not self.zc.done: - with self.condition: - rs = list(self.readers.keys()) - if len(rs) == 0: - # No sockets to manage, but we wait for the timeout - # or addition of a socket + rs = list(self.readers.keys()) + if not rs: + # No sockets to manage, but we wait for the timeout + # or addition of a socket + with self.condition: self.condition.wait(self.timeout) + continue + + try: + rs.append(self.socketpair[0]) + rr, wr, er = select.select(rs, [], [], self.timeout) + + if self.zc.done: + return + + for socket_ in rr: + reader = self.readers.get(socket_) + if reader: + reader.handle_read(socket_) + + if self.socketpair[0] in rr: + # Clear the socket's buffer + self.socketpair[0].recv(128) + + except (select.error, socket.error) as e: + # If the socket was closed by another thread, during + # shutdown, ignore it and exit + if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: + raise + + now = current_time_millis() + if now - self._last_cache_cleanup >= self.cache_cleanup_interval_ms: + self._last_cache_cleanup = now + for record in self.zc.cache.expire(now): + self.zc.update_record(now, record) - if len(rs) != 0: - try: - rs = rs + [self.socketpair[0]] - rr, wr, er = select.select(cast(Sequence[Any], rs), [], [], self.timeout) - if not self.zc.done: - for socket_ in rr: - reader = self.readers.get(socket_) - if reader: - reader.handle_read(socket_) - - if self.socketpair[0] in rr: - # Clear the socket's buffer - self.socketpair[0].recv(128) - - except (select.error, socket.error) as e: - # If the socket was closed by another thread, during - # shutdown, ignore it and exit - if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: - raise self.socketpair[0].close() self.socketpair[1].close() @@ -1464,32 +1476,6 @@ def handle_read(self, socket_: socket.socket) -> None: self.zc.handle_response(msg) -class Reaper(threading.Thread): - - """A Reaper is used by this module to remove cache entries that - have expired.""" - - def __init__(self, zc: 'Zeroconf') -> None: - threading.Thread.__init__(self) - self.daemon = True - self.zc = zc - self.start() - self.name = "zeroconf-Reaper_%s" % (getattr(self, 'native_id', self.ident),) - - def run(self) -> None: - """Perodic removal of expired entries from the cache.""" - while True: - with self.zc.reaper_condition: - self.zc.reaper_condition.wait(10) - - if self.zc.done: - return - - now = current_time_millis() - for record in self.zc.cache.expire(now): - self.zc.update_record(now, record) - - class Signal: def __init__(self) -> None: self._handlers = [] # type: List[Callable[..., None]] @@ -2505,7 +2491,6 @@ def __init__( self.cache = DNSCache() self.condition = threading.Condition() - self.reaper_condition = threading.Condition() # Ensure we create the lock before # we add the listener as we could get @@ -2519,7 +2504,6 @@ def __init__( if self.multi_socket: for s in self._respond_sockets: self.engine.add_reader(self.listener, s) - self.reaper = Reaper(self) self.debug = None # type: Optional[DNSOutgoing] @@ -2538,11 +2522,6 @@ def notify_all(self) -> None: with self.condition: self.condition.notify_all() - def notify_reaper(self) -> None: - """Notifies reaper""" - with self.reaper_condition: - self.reaper_condition.notify_all() - def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, @@ -2987,7 +2966,5 @@ def close(self) -> None: # shutdown the rest self.notify_all() - self.notify_reaper() - self.reaper.join() for s in self._respond_sockets: s.close() diff --git a/zeroconf/test.py b/zeroconf/test.py index 60bfb4d56..044909a3d 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -529,6 +529,17 @@ def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) rv.close() + def test_launch_and_close_unicast(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, unicast=True) + rv.close() + + def test_close_multiple_times(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) + rv.close() + rv.close() + @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v4_v6(self): @@ -966,9 +977,11 @@ def test_reaper(self): zeroconf.cache.add(record_with_10s_ttl) zeroconf.cache.add(record_with_1s_ttl) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - time.sleep(1.05) - zeroconf.notify_reaper() - time.sleep(0.05) + zeroconf.engine.cache_cleanup_interval_ms = 10 + time.sleep(1) + with zeroconf.engine.condition: + zeroconf.engine._notify() + time.sleep(0.1) entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) zeroconf.close() assert entries != original_entries From a41d7b8aa5572f3faf29eb087cc18a1343bbcdfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 May 2021 15:09:56 -0500 Subject: [PATCH 0149/1433] 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. --- Makefile | 4 +- requirements-dev.txt | 1 + zeroconf/__init__.py | 74 +++++++++++--------- zeroconf/asyncio.py | 137 ++++++++++++++++++++++++++++++++++++ zeroconf/test_asyncio.py | 145 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 35 deletions(-) create mode 100644 zeroconf/asyncio.py create mode 100644 zeroconf/test_asyncio.py diff --git a/Makefile b/Makefile index 25fdbb2c3..fed4d9b17 100644 --- a/Makefile +++ b/Makefile @@ -36,10 +36,10 @@ mypy: mypy examples/*.py zeroconf/*.py test: - pytest -v zeroconf/test.py + pytest -v zeroconf/test.py zeroconf/test_asyncio.py test_coverage: - pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py + pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py zeroconf/test_asyncio.py autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/requirements-dev.txt b/requirements-dev.txt index 8c1527b44..30b906e44 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,4 +10,5 @@ mypy;implementation_name=="cpython" # 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152 pep8-naming!=0.6.0,!=0.11.0 pytest +pytest-asyncio pytest-cov diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ba5886443..484a7ee23 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -353,6 +353,16 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: return service_name + trailer +def instance_name_from_service_info(info: "ServiceInfo") -> str: + """Calculate the instance name from the ServiceInfo.""" + # This is kind of funky because of the subtype based tests + # need to make subtypes a first class citizen + service_name = service_type_name(info.name) + if not info.type.endswith(service_name): + raise BadTypeInNameException + return info.name[: -len(service_name) - 1] + + # Exceptions @@ -2505,8 +2515,6 @@ def __init__( for s in self._respond_sockets: self.engine.add_reader(self.listener, s) - self.debug = None # type: Optional[DNSOutgoing] - @property def done(self) -> bool: return self._GLOBAL_DONE @@ -2580,6 +2588,7 @@ def update_service(self, info: ServiceInfo) -> None: self._broadcast_service(info, _REGISTER_TIME, None) def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + """Send a broadcasts to announce a service at intervals.""" now = current_time_millis() next_time = now i = 0 @@ -2589,12 +2598,23 @@ def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int now = current_time_millis() continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - self._add_broadcast_answer(out, info, ttl) - self.send(out) + self.send_service_broadcast(info, ttl) i += 1 next_time += interval + def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None: + """Send a broadcast to announce a service.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + self._add_broadcast_answer(out, info, ttl) + self.send(out) + + def send_service_query(self, info: ServiceInfo) -> None: + """Send a query to lookup a service.""" + out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) + out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) + out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) + self.send(out) + def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None: """Add answers to broadcast a service.""" other_ttl = info.other_ttl if override_ttl is None else override_ttl @@ -2653,43 +2673,31 @@ def check_service( ) -> None: """Checks the network for a unique service name, modifying the ServiceInfo passed in if it is not unique.""" - - # This is kind of funky because of the subtype based tests - # need to make subtypes a first class citizen - service_name = service_type_name(info.name) - if not info.type.endswith(service_name): - raise BadTypeInNameException - - instance_name = info.name[: -len(service_name) - 1] + instance_name = instance_name_from_service_info(info) + if cooperating_responders: + return next_instance_number = 2 - - now = current_time_millis() - next_time = now + next_time = now = current_time_millis() i = 0 while i < 3: - if not cooperating_responders: - # check for a name conflict - while self.cache.current_entry_with_name_and_alias(info.type, info.name): - if not allow_name_change: - raise NonUniqueNameException - - # change the name and look for a conflict - info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) - next_instance_number += 1 - service_type_name(info.name) - next_time = now - i = 0 + # check for a name conflict + while self.cache.current_entry_with_name_and_alias(info.type, info.name): + if not allow_name_change: + raise NonUniqueNameException + + # change the name and look for a conflict + info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) + next_instance_number += 1 + service_type_name(info.name) + next_time = now + i = 0 if now < next_time: self.wait(next_time - now) now = current_time_millis() continue - out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) - self.debug = out - out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) - out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) - self.send(out) + self.send_service_query(info) i += 1 next_time += _CHECK_TIME diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py new file mode 100644 index 000000000..859d26a08 --- /dev/null +++ b/zeroconf/asyncio.py @@ -0,0 +1,137 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" +import asyncio +from typing import Optional + +from . import ( + IPVersion, + InterfaceChoice, + InterfacesType, + NonUniqueNameException, + ServiceInfo, + Zeroconf, + _CHECK_TIME, + _REGISTER_TIME, + _UNREGISTER_TIME, + instance_name_from_service_info, +) + + +class AsyncZeroconf: + """Implementation of Zeroconf Multicast DNS Service Discovery + + Supports registration, unregistration, queries and browsing. + + The async version is currently a wrapper around the sync version + with I/O being done in the executor for backwards compatibility. + """ + + def __init__( + self, + interfaces: InterfacesType = InterfaceChoice.All, + unicast: bool = False, + ip_version: Optional[IPVersion] = None, + apple_p2p: bool = False, + ) -> None: + """Creates an instance of the Zeroconf class, establishing + multicast communications, listening and reaping threads. + + :param interfaces: :class:`InterfaceChoice` or a list of IP addresses + (IPv4 and IPv6) and interface indexes (IPv6 only). + + IPv6 notes for non-POSIX systems: + * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` + on Python versions before 3.8. + + Also listening on loopback (``::1``) doesn't work, use a real address. + :param ip_version: IP versions to support. If `choice` is a list, the default is detected + from it. Otherwise defaults to V4 only for backward compatibility. + :param apple_p2p: use AWDL interface (only macOS) + """ + self.zeroconf = Zeroconf( + interfaces=interfaces, + unicast=unicast, + ip_version=ip_version, + apple_p2p=apple_p2p, + ) + self.loop = asyncio.get_event_loop() + + async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + """Send a broadcasts to announce a service at intervals.""" + for i in range(3): + if i != 0: + await asyncio.sleep(interval / 1000) + await self.loop.run_in_executor(None, self.zeroconf.send_service_broadcast, info, ttl) + + async def async_register_service( + self, + info: ServiceInfo, + cooperating_responders: bool = False, + ) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. The name of the service may be changed if needed to make + it unique on the network. Additionally multiple cooperating responders + can register the same service on the network for resilience + (if you want this behavior set `cooperating_responders` to `True`). + + The service will be broadcast in a task. + """ + await self.async_check_service(info, cooperating_responders) + await self.loop.run_in_executor(None, self.zeroconf.registry.add, info) + asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + + async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: + """Checks the network for a unique service name.""" + instance_name_from_service_info(info) + if cooperating_responders: + return + for i in range(3): + # check for a name conflict + if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): + raise NonUniqueNameException + if i != 0: + await asyncio.sleep(_CHECK_TIME / 1000) + await self.loop.run_in_executor(None, self.zeroconf.send_service_query, info) + + async def async_unregister_service(self, info: ServiceInfo) -> None: + """Unregister a service. + + The service will be broadcast in a task. + """ + await self.loop.run_in_executor(None, self.zeroconf.registry.remove, info) + asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) + + async def async_update_service(self, info: ServiceInfo) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. + + The service will be broadcast in a task. + """ + await self.loop.run_in_executor(None, self.zeroconf.registry.update, info) + asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + + async def async_close(self) -> None: + """Ends the background threads, and prevent this instance from + servicing further queries.""" + await self.loop.run_in_executor(None, self.zeroconf.close) diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py new file mode 100644 index 000000000..9ad43ce47 --- /dev/null +++ b/zeroconf/test_asyncio.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for async.py.""" + +import asyncio +import socket + +import pytest + +from . import ( + BadTypeInNameException, + NonUniqueNameException, + ServiceInfo, + ServiceListener, + ServiceNameAlreadyRegistered, + Zeroconf, + _REGISTER_TIME, + _UNREGISTER_TIME, +) +from .asyncio import AsyncZeroconf + + +@pytest.mark.asyncio +async def test_async_basic_usage() -> None: + """Test we can create and close the instance.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_registration() -> None: + """Test registering services broadcasts the registration by default.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + await aiozc.async_register_service(info) + await asyncio.sleep(_REGISTER_TIME / 1000 * 3) + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + await aiozc.async_update_service(new_info) + await asyncio.sleep(_REGISTER_TIME / 1000 * 3) + + await aiozc.async_unregister_service(new_info) + await asyncio.sleep(_UNREGISTER_TIME / 1000 * 3) + await aiozc.async_close() + + assert calls == [ + ('add', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('update', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('remove', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ] + + +@pytest.mark.asyncio +async def test_async_service_registration_name_conflict() -> None: + """Test registering services throws on name conflict.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + await aiozc.async_register_service(info) + await asyncio.sleep(_REGISTER_TIME / 1000 * 3) + + with pytest.raises(NonUniqueNameException): + await aiozc.async_register_service(info) + + with pytest.raises(ServiceNameAlreadyRegistered): + await aiozc.async_register_service(info, cooperating_responders=True) + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_registration_name_does_not_match_type() -> None: + """Test registering services throws when the name does not match the type.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info.type = "_wrong._tcp.local." + with pytest.raises(BadTypeInNameException): + await aiozc.async_register_service(info) + await aiozc.async_close() From 87ba2a3960576cfcf4207ea74a711b2c0cc584a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 May 2021 16:50:16 -0400 Subject: [PATCH 0150/1433] Separate cache loading from I/O in ServiceInfo (#356) Provides a load_from_cache method on ServiceInfo that does no I/O - When a ServiceBrowser is running for a type there is no need to make queries on the network since the entries will already be in the cache. When discovering many devices making queries that will almost certainly fail for offline devices delays the startup of online devices. - The DNSEntry and ServiceInfo classes were matching on the name instead of the key (lowercase name). These classes now treat dns names the same reguardless of case. https://datatracker.ietf.org/doc/html/rfc6762#section-16 > The simple rules for case-insensitivity in Unicast DNS [RFC1034] > [RFC1035] also apply in Multicast DNS; that is to say, in name > comparisons, the lowercase letters "a" to "z" (0x61 to 0x7A) match > their uppercase equivalents "A" to "Z" (0x41 to 0x5A). Hence, if a > querier issues a query for an address record with the name > "myprinter.local.", then a responder having an address record with > the name "MyPrinter.local." should issue a response. --- zeroconf/__init__.py | 38 +++++++----- zeroconf/test.py | 136 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 16 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 484a7ee23..28249e2ba 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -436,9 +436,9 @@ def __init__(self, name: str, type_: int, class_: int) -> None: self.unique = (class_ & _CLASS_UNIQUE) != 0 def __eq__(self, other: Any) -> bool: - """Equality test on name, type, and class""" + """Equality test on key (lowercase name), type, and class""" return ( - self.name == other.name + self.key == other.key and self.type == other.type and self.class_ == other.class_ and isinstance(other, DNSEntry) @@ -1788,6 +1788,7 @@ def __init__( raise BadTypeInNameException self.type = type_ self.name = name + self.key = name.lower() if addresses is not None: self._addresses = addresses elif parsed_addresses is not None: @@ -1807,6 +1808,7 @@ def __init__( self.server = server else: self.server = name + self.server_key = self.server.lower() self._properties = {} # type: Dict self._set_properties(properties) self.host_ttl = host_ttl @@ -1920,34 +1922,28 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) if record is not None and not record.is_expired(now): if record.type in [_TYPE_A, _TYPE_AAAA]: assert isinstance(record, DNSAddress) - # if record.name == self.name: - if record.name == self.server: + if record.key == self.server_key: if record.address not in self._addresses: self._addresses.append(record.address) elif record.type == _TYPE_SRV: assert isinstance(record, DNSService) - if record.name == self.name: + if record.key == self.key: + self.name = record.name self.server = record.server + self.server_key = record.server.lower() self.port = record.port self.weight = record.weight self.priority = record.priority - # self.address = None self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) elif record.type == _TYPE_TXT: assert isinstance(record, DNSText) - if record.name == self.name: + if record.key == self.key: self._set_text(record.text) - def request(self, zc: 'Zeroconf', timeout: float) -> bool: - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ + def load_from_cache(self, zc: 'Zeroconf') -> bool: + """Populate the service info from the cache.""" now = current_time_millis() - delay = _LISTENER_TIME - next_ = now - last = now + timeout - record_types_for_check_cache = [(_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN)] if self.server is not None: record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) @@ -1959,7 +1955,19 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if self.server is not None and self.text is not None and self._addresses: return True + return False + + def request(self, zc: 'Zeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + if self.load_from_cache(zc): + return True + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + last = now + timeout try: zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) while self.server is None or self.text is None or not self._addresses: diff --git a/zeroconf/test.py b/zeroconf/test.py index 044909a3d..bb3205221 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -243,6 +243,7 @@ def test_suppress_answer(self): # Should not be suppressed, name is different tmp = copy.copy(answer1) + tmp.key = "testname3.local." tmp.name = "testname3.local." response.add_answer(query, tmp) assert len(response.answers) == 2 @@ -1127,7 +1128,7 @@ def test_integration_with_listener_class(self): subtype_name = "My special Subtype" type_ = "_http._tcp.local." subtype = subtype_name + "._sub." + type_ - name = "xxxyyyæøå" + name = "UPPERxxxyyyæøå" registration_name = "%s.%s" % (name, subtype) class MyListener(r.ServiceListener): @@ -1187,6 +1188,10 @@ def update_service(self, zeroconf, type, name): for record in zeroconf_browser.cache.entries_with_name(name): zeroconf_browser.cache.remove(record) + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties == {} + # get service info without answer cache info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None @@ -1199,10 +1204,35 @@ def update_service(self, zeroconf, type, name): assert info.addresses == addresses[:1] # no V6 by default assert info.addresses_by_version(r.IPVersion.All) == addresses + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + + # get service info with only the cache + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + # get service info with only the cache with the lowercase name + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + # Ensure uppercase output is preserved + assert cached_info.name == registration_name + assert cached_info.key == registration_name.lower() + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + info = zeroconf_browser.get_service_info(subtype, registration_name) assert info is not None + assert info.properties is not None assert info.properties[b'prop_none'] is None + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_none'] is None + # test TXT record update sublistener = MySubListener() zeroconf_browser.add_service_listener(registration_name, sublistener) @@ -1226,6 +1256,11 @@ def update_service(self, zeroconf, type, name): assert info is not None assert info.properties[b'prop_blank'] == properties['prop_blank'] + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] + zeroconf_registrar.unregister_service(info_service) service_removed.wait(1) assert service_removed.is_set() @@ -1376,6 +1411,105 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi class TestServiceInfo(unittest.TestCase): + def test_service_info_rejects_non_matching_updates(self): + """Verify records with the wrong name are rejected.""" + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.3") + info.update_record( + zc, + now, + r.DNSAddress( + 'ASH-2.local.', + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address in info.addresses + # Non-matching updates + info.update_record( + zc, + now, + r.DNSText( + "incorrect.name.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + "incorrect.name.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.4") + info.update_record( + zc, + now, + r.DNSAddress( + "incorrect.name.", + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address not in info.addresses + def test_get_info_partial(self): zc = r.Zeroconf(interfaces=['127.0.0.1']) From 8c1c394e9b4aa01e08a2c3e240396b533792be55 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Sat, 22 May 2021 11:27:01 -0500 Subject: [PATCH 0151/1433] Return task objects created by AsyncZeroconf (#360) --- zeroconf/asyncio.py | 23 +++++++------- zeroconf/test_asyncio.py | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 859d26a08..4ba7f312e 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -20,7 +20,7 @@ USA """ import asyncio -from typing import Optional +from typing import Awaitable, Optional from . import ( IPVersion, @@ -86,7 +86,7 @@ async def async_register_service( self, info: ServiceInfo, cooperating_responders: bool = False, - ) -> None: + ) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service. The name of the service may be changed if needed to make @@ -94,11 +94,12 @@ async def async_register_service( can register the same service on the network for resilience (if you want this behavior set `cooperating_responders` to `True`). - The service will be broadcast in a task. + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. """ await self.async_check_service(info, cooperating_responders) await self.loop.run_in_executor(None, self.zeroconf.registry.add, info) - asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: """Checks the network for a unique service name.""" @@ -113,23 +114,25 @@ async def async_check_service(self, info: ServiceInfo, cooperating_responders: b await asyncio.sleep(_CHECK_TIME / 1000) await self.loop.run_in_executor(None, self.zeroconf.send_service_query, info) - async def async_unregister_service(self, info: ServiceInfo) -> None: + async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service. - The service will be broadcast in a task. + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. """ await self.loop.run_in_executor(None, self.zeroconf.registry.remove, info) - asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) + return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) - async def async_update_service(self, info: ServiceInfo) -> None: + async def async_update_service(self, info: ServiceInfo) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service. - The service will be broadcast in a task. + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. """ await self.loop.run_in_executor(None, self.zeroconf.registry.update, info) - asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) async def async_close(self) -> None: """Ends the background threads, and prevent this instance from diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index 9ad43ce47..ed43d2c08 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -143,3 +143,68 @@ async def test_async_service_registration_name_does_not_match_type() -> None: with pytest.raises(BadTypeInNameException): await aiozc.async_register_service(info) await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_tasks() -> None: + """Test awaiting broadcast tasks""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + assert isinstance(task, asyncio.Task) + await task + + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + assert isinstance(task, asyncio.Task) + await task + + task = await aiozc.async_unregister_service(new_info) + assert isinstance(task, asyncio.Task) + await task + await aiozc.async_close() + + assert calls == [ + ('add', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('update', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('remove', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ] From c0674e97aee4f61212389337340fc8ff4472eb25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 May 2021 11:28:51 -0500 Subject: [PATCH 0152/1433] Improve test coverage for name conflicts (#357) --- zeroconf/test.py | 28 ++++++++++++++++++++++++++++ zeroconf/test_asyncio.py | 13 +++++++++++++ 2 files changed, 41 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index bb3205221..64005b9ba 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -893,6 +893,34 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 + def test_name_conflicts(self): + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_homeassistant._tcp.local." + name = "Home" + registration_name = "%s.%s" % (name, type_) + + info = ServiceInfo( + type_, + name=registration_name, + server="random123.local.", + addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], + port=80, + properties={"version": "1.0"}, + ) + zc.register_service(info) + + conflicting_info = ServiceInfo( + type_, + name=registration_name, + server="random456.local.", + addresses=[socket.inet_pton(socket.AF_INET, "4.5.6.7")], + port=80, + properties={"version": "1.0"}, + ) + with pytest.raises(r.NonUniqueNameException): + zc.register_service(conflicting_info) + class TestServiceRegistry(unittest.TestCase): def test_only_register_once(self): diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index ed43d2c08..eb398a1f8 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -117,6 +117,19 @@ async def test_async_service_registration_name_conflict() -> None: with pytest.raises(ServiceNameAlreadyRegistered): await aiozc.async_register_service(info, cooperating_responders=True) + conflicting_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-3.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + with pytest.raises(NonUniqueNameException): + await aiozc.async_register_service(conflicting_info) + await aiozc.async_close() From 7e960b78cac8008beca9c5451c6d465e2674a050 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 22:04:20 -0500 Subject: [PATCH 0153/1433] Small cleanups to asyncio tests (#362) --- zeroconf/test_asyncio.py | 53 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index eb398a1f8..e048a603d 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -16,8 +16,6 @@ ServiceListener, ServiceNameAlreadyRegistered, Zeroconf, - _REGISTER_TIME, - _UNREGISTER_TIME, ) from .asyncio import AsyncZeroconf @@ -33,7 +31,7 @@ async def test_async_basic_usage() -> None: async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc-type._tcp.local." + type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -63,8 +61,8 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - await aiozc.async_register_service(info) - await asyncio.sleep(_REGISTER_TIME / 1000 * 3) + task = await aiozc.async_register_service(info) + await task new_info = ServiceInfo( type_, registration_name, @@ -75,17 +73,16 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: "ash-2.local.", addresses=[socket.inet_aton("10.0.1.3")], ) - await aiozc.async_update_service(new_info) - await asyncio.sleep(_REGISTER_TIME / 1000 * 3) - - await aiozc.async_unregister_service(new_info) - await asyncio.sleep(_UNREGISTER_TIME / 1000 * 3) + task = await aiozc.async_update_service(new_info) + await task + task = await aiozc.async_unregister_service(new_info) + await task await aiozc.async_close() assert calls == [ - ('add', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), - ('update', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), - ('remove', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), ] @@ -93,7 +90,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: async def test_async_service_registration_name_conflict() -> None: """Test registering services throws on name conflict.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-srvc2-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -108,14 +105,16 @@ async def test_async_service_registration_name_conflict() -> None: "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - await aiozc.async_register_service(info) - await asyncio.sleep(_REGISTER_TIME / 1000 * 3) + task = await aiozc.async_register_service(info) + await task with pytest.raises(NonUniqueNameException): - await aiozc.async_register_service(info) + task = await aiozc.async_register_service(info) + await task with pytest.raises(ServiceNameAlreadyRegistered): - await aiozc.async_register_service(info, cooperating_responders=True) + task = await aiozc.async_register_service(info, cooperating_responders=True) + await task conflicting_info = ServiceInfo( type_, @@ -127,8 +126,10 @@ async def test_async_service_registration_name_conflict() -> None: "ash-3.local.", addresses=[socket.inet_aton("10.0.1.3")], ) + with pytest.raises(NonUniqueNameException): - await aiozc.async_register_service(conflicting_info) + task = await aiozc.async_register_service(conflicting_info) + await task await aiozc.async_close() @@ -137,7 +138,7 @@ async def test_async_service_registration_name_conflict() -> None: async def test_async_service_registration_name_does_not_match_type() -> None: """Test registering services throws when the name does not match the type.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-srvc3-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -154,7 +155,8 @@ async def test_async_service_registration_name_does_not_match_type() -> None: ) info.type = "_wrong._tcp.local." with pytest.raises(BadTypeInNameException): - await aiozc.async_register_service(info) + task = await aiozc.async_register_service(info) + await task await aiozc.async_close() @@ -163,7 +165,7 @@ async def test_async_tasks() -> None: """Test awaiting broadcast tasks""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-srvc4-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -214,10 +216,11 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: task = await aiozc.async_unregister_service(new_info) assert isinstance(task, asyncio.Task) await task + await aiozc.async_close() assert calls == [ - ('add', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), - ('update', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), - ('remove', '_test-srvc-type._tcp.local.', 'xxxyyy._test-srvc-type._tcp.local.'), + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), ] From d8c32401ada4f430cd75617324b6d8ecd1dbe1f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 22:26:54 -0500 Subject: [PATCH 0154/1433] Add new cache function get_all_by_details (#363) - When working with IPv6, multiple AAAA records can exist for a given host. get_by_details would only return the latest record in the cache. - Fix a case where the cache list can change during iteration --- zeroconf/__init__.py | 23 +++++++++++------------ zeroconf/test.py | 2 ++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 28249e2ba..98b16d938 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1281,20 +1281,19 @@ def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: def get(self, entry: DNSEntry) -> Optional[DNSRecord]: """Gets an entry by key. Will return None if there is no matching entry.""" - try: - list_ = self.cache[entry.key] - for cached_entry in reversed(list_): - if entry.__eq__(cached_entry): - return cached_entry - return None - except (KeyError, ValueError): - return None + for cached_entry in reversed(self.entries_with_name(entry.key)): + if entry.__eq__(cached_entry): + return cached_entry + return None def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: - """Gets an entry by details. Will return None if there is - no matching entry.""" - entry = DNSEntry(name, type_, class_) - return self.get(entry) + """Gets the first matching entry by details. Returns None if no entries match.""" + return self.get(DNSEntry(name, type_, class_)) + + def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: + """Gets all matching entries by details.""" + match_entry = DNSEntry(name, type_, class_) + return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" diff --git a/zeroconf/test.py b/zeroconf/test.py index 64005b9ba..edc6f55f6 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -610,6 +610,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' + all_dns_text = zeroconf.cache.get_all_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert [dns_text] == all_dns_text # https://tools.ietf.org/html/rfc6762#section-10.2 # Instead of merging this new record additively into the cache in addition From 1b8b2917e7e70e3996e9a96204dd5df3dfb39072 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 23:09:12 -0500 Subject: [PATCH 0155/1433] Small cleanup of ServiceInfo.update_record (#364) - Return as record is not viable (None or expired) - Switch checks to isinstance since its needed by mypy anyways - Prepares for supporting multiple AAAA records (via https://github.com/jstasiak/python-zeroconf/pull/361) --- zeroconf/__init__.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 98b16d938..ae4344a75 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1918,27 +1918,25 @@ def get_name(self) -> str: def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: """Updates service information from a DNS record""" - if record is not None and not record.is_expired(now): - if record.type in [_TYPE_A, _TYPE_AAAA]: - assert isinstance(record, DNSAddress) - if record.key == self.server_key: - if record.address not in self._addresses: - self._addresses.append(record.address) - elif record.type == _TYPE_SRV: - assert isinstance(record, DNSService) - if record.key == self.key: - self.name = record.name - self.server = record.server - self.server_key = record.server.lower() - self.port = record.port - self.weight = record.weight - self.priority = record.priority - self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) - self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) - elif record.type == _TYPE_TXT: - assert isinstance(record, DNSText) - if record.key == self.key: - self._set_text(record.text) + if record is None or record.is_expired(now): + return + if isinstance(record, DNSAddress): + if record.key == self.server_key and record.address not in self._addresses: + self._addresses.append(record.address) + elif isinstance(record, DNSService): + if record.key != self.key: + return + self.name = record.name + self.server = record.server + self.server_key = record.server.lower() + self.port = record.port + self.weight = record.weight + self.priority = record.priority + self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) + self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) + elif isinstance(record, DNSText): + if record.key == self.key: + self._set_text(record.text) def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache.""" From 6d29e6c93bdcf6cf31fcfa133258257704945dfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 23:28:02 -0500 Subject: [PATCH 0156/1433] Remove black python 3.5 exception block (#365) --- pyproject.toml | 2 +- zeroconf/__init__.py | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a5d30b54e..b48e90eeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ [tool.black] line-length = 110 -target_version = ['py35', 'py36', 'py37'] +target_version = ['py35', 'py36', 'py37', 'py38'] skip_string_normalization = true diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ae4344a75..223d1569c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1762,9 +1762,6 @@ class ServiceInfo(RecordUpdateListener): text = b'' - # FIXME(dtantsur): black 19.3b0 produces code that is not valid syntax on - # Python 3.5: https://github.com/python/black/issues/759 - # fmt: off def __init__( self, type_: str, @@ -1795,11 +1792,12 @@ def __init__( else: self._addresses = [] # This results in an ugly error when registering, better check now - invalid = [a for a in self._addresses - if not isinstance(a, bytes) or len(a) not in (4, 16)] + invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] if invalid: - raise TypeError('Addresses must be bytes, got %s. Hint: convert string addresses ' - 'with socket.inet_pton' % invalid) + raise TypeError( + 'Addresses must be bytes, got %s. Hint: convert string addresses ' + 'with socket.inet_pton' % invalid + ) self.port = port self.weight = weight self.priority = priority @@ -1812,7 +1810,6 @@ def __init__( self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl - # fmt: on @property def addresses(self) -> List[bytes]: From bae3a9b97672581e77255c4937b815173c8547b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 23:54:42 -0500 Subject: [PATCH 0157/1433] Ensure ServiceInfo populates all AAAA records (#366) - Use get_all_by_details to ensure all records are loaded into addresses. - Only load A/AAAA records from cache once in load_from_cache if there is a SRV record present - Move duplicate code that checked if the ServiceInfo was complete into its own function --- zeroconf/__init__.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 223d1569c..fac15b0f0 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1929,27 +1929,38 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) self.port = record.port self.weight = record.weight self.priority = record.priority - self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN)) - self.update_record(zc, now, zc.cache.get_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) + self._update_addresses_from_cache(zc, now) elif isinstance(record, DNSText): if record.key == self.key: self._set_text(record.text) + def _update_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: + """Update the address records from the cache.""" + cached_a_record = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) + if cached_a_record: + self.update_record(zc, now, cached_a_record) + for cached_aaaa_record in zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN): + self.update_record(zc, now, cached_aaaa_record) + def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache.""" now = current_time_millis() - record_types_for_check_cache = [(_TYPE_SRV, _CLASS_IN), (_TYPE_TXT, _CLASS_IN)] - if self.server is not None: - record_types_for_check_cache.append((_TYPE_A, _CLASS_IN)) - record_types_for_check_cache.append((_TYPE_AAAA, _CLASS_IN)) - for record_type in record_types_for_check_cache: - cached = zc.cache.get_by_details(self.name, *record_type) - if cached: - self.update_record(zc, now, cached) - - if self.server is not None and self.text is not None and self._addresses: - return True - return False + cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) + if cached_srv_record: + # If there is a srv record, A and AAAA will already + # be called and we do not want to do it twice + self.update_record(zc, now, cached_srv_record) + elif self.server is not None: + self._update_addresses_from_cache(zc, now) + cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) + if cached_txt_record: + self.update_record(zc, now, cached_txt_record) + return self._is_complete + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return not (self.server is None or self.text is None or not self._addresses) def request(self, zc: 'Zeroconf', timeout: float) -> bool: """Returns true if the service could be discovered on the @@ -1964,7 +1975,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: last = now + timeout try: zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) - while self.server is None or self.text is None or not self._addresses: + while not self._is_complete: if last <= now: return False if next_ <= now: From 5a4c1e46510956276de117d86bee9d2ccb602802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 08:45:17 -0500 Subject: [PATCH 0158/1433] Fix empty answers being added in ServiceInfo.request (#367) --- zeroconf/__init__.py | 48 +++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index fac15b0f0..1e1955323 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1014,6 +1014,29 @@ def add_additional_answer(self, record: DNSRecord) -> None: """ self.additionals.append(record) + def add_question_or_one_cache( + self, zc: "Zeroconf", now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached.""" + cached_entry = zc.cache.get_by_details(name, type_, class_) + if not cached_entry: + self.add_question(DNSQuestion(name, type_, class_)) + else: + self.add_answer_at_time(cached_entry, now) + + def add_question_or_all_cache( + self, zc: "Zeroconf", now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached. + This is currently only used for IPv6 addresses. + """ + cached_entries = zc.cache.get_all_by_details(name, type_, class_) + if not cached_entries: + self.add_question(DNSQuestion(name, type_, class_)) + return + for cached_entry in cached_entries: + self.add_answer_at_time(cached_entry, now) + def pack(self, format_: Union[bytes, str], value: Any) -> None: self.data.append(struct.pack(format_, value)) self.size += struct.calcsize(format_) @@ -1974,30 +1997,19 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: next_ = now last = now + timeout try: - zc.add_listener(self, DNSQuestion(self.name, _TYPE_ANY, _CLASS_IN)) + # Do not set a question on the listener to preload from cache + # since we just checked it above in load_from_cache + zc.add_listener(self, None) while not self._is_complete: if last <= now: return False if next_ <= now: out = DNSOutgoing(_FLAGS_QR_QUERY) - cached_entry = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) - if not cached_entry: - out.add_question(DNSQuestion(self.name, _TYPE_SRV, _CLASS_IN)) - out.add_answer_at_time(cached_entry, now) - cached_entry = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) - if not cached_entry: - out.add_question(DNSQuestion(self.name, _TYPE_TXT, _CLASS_IN)) - out.add_answer_at_time(cached_entry, now) - + out.add_question_or_one_cache(zc, now, self.name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(zc, now, self.name, _TYPE_TXT, _CLASS_IN) if self.server is not None: - cached_entry = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) - if not cached_entry: - out.add_question(DNSQuestion(self.server, _TYPE_A, _CLASS_IN)) - out.add_answer_at_time(cached_entry, now) - cached_entry = zc.cache.get_by_details(self.name, _TYPE_AAAA, _CLASS_IN) - if not cached_entry: - out.add_question(DNSQuestion(self.server, _TYPE_AAAA, _CLASS_IN)) - out.add_answer_at_time(cached_entry, now) + out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) zc.send(out) next_ = now + delay delay *= 2 From 4657a773690a34c897c80894a10ac33b6edadf8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 09:03:32 -0500 Subject: [PATCH 0159/1433] Reduce complexity of ServiceBrowser enqueue_callback (#368) - The handler key was by name, however ServiceBrowser can have multiple types which meant the check to see if a state change was an add remove, or update was overly complex. Reduce the complexity by making the key (name, type_) --- zeroconf/__init__.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1e1955323..b24ca3329 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1574,7 +1574,7 @@ def __init__( ) -> None: """Creates a browser for a specific type""" assert handlers or listener, 'You need to specify at least one handler' - self.types = set(type_ if isinstance(type_, list) else [type_]) + self.types = set(type_ if isinstance(type_, list) else [type_]) # type: Set[str] for check_type_ in self.types: if not check_type_.endswith(service_type_name(check_type_, strict=False)): raise BadTypeInNameException @@ -1590,7 +1590,7 @@ def __init__( current_time = current_time_millis() self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} - self._handlers_to_call = OrderedDict() # type: OrderedDict[str, Tuple[str, ServiceStateChange]] + self._handlers_to_call = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] self._service_state_changed = Signal() @@ -1655,20 +1655,16 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> # Code to ensure we only do a single update message # Precedence is; Added, Remove, Update - + key = (name, type_) if ( state_change is ServiceStateChange.Added or ( state_change is ServiceStateChange.Removed - and ( - self._handlers_to_call.get(name) is ServiceStateChange.Updated - or self._handlers_to_call.get(name) is ServiceStateChange.Added - or self._handlers_to_call.get(name) is None - ) + and self._handlers_to_call.get(key) != ServiceStateChange.Added ) - or (state_change is ServiceStateChange.Updated and name not in self._handlers_to_call) + or (state_change is ServiceStateChange.Updated and key not in self._handlers_to_call) ): - self._handlers_to_call[name] = (type_, state_change) + self._handlers_to_call[key] = state_change if record.type == _TYPE_PTR and record.name in self.types: assert isinstance(record, DNSPointer) @@ -1753,12 +1749,12 @@ def run(self) -> None: if len(self._handlers_to_call) > 0 and not self.zc.done: with self.zc._handlers_lock: - (name, service_type_state_change) = self._handlers_to_call.popitem(False) + (name_type, state_change) = self._handlers_to_call.popitem(False) self._service_state_changed.fire( zeroconf=self.zc, - service_type=service_type_state_change[0], - name=name, - state_change=service_type_state_change[1], + service_type=name_type[1], + name=name_type[0], + state_change=state_change, ) From 4819ef8c97ddbbadcd6e7cf1b5fee36f573bde45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 09:22:15 -0500 Subject: [PATCH 0160/1433] Abstract check to see if a record matches a type the ServiceBrowser wants (#369) --- zeroconf/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b24ca3329..5559f9db9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1642,6 +1642,10 @@ def on_change( def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface + def _record_matching_type(self, record: DNSRecord) -> Optional[str]: + """Return the type if the record matches one of the types we are browsing.""" + return next((type_ for type_ in self.types if record.name.endswith(type_)), None) + def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: """Callback invoked by Zeroconf when new information arrives. @@ -1707,14 +1711,14 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> # Iterate through the DNSCache and callback any services that use this address for service in self.zc.cache.entries_with_server(record.name): - for type_ in self.types: - if service.name.endswith(type_): - enqueue_callback(ServiceStateChange.Updated, type_, service.name) + type_ = self._record_matching_type(service) + if type_: + enqueue_callback(ServiceStateChange.Updated, type_, service.name) elif not record.is_expired(now): - for type_ in self.types: - if record.name.endswith(type_): - enqueue_callback(ServiceStateChange.Updated, type_, record.name) + type_ = self._record_matching_type(record) + if type_: + enqueue_callback(ServiceStateChange.Updated, type_, record.name) def cancel(self) -> None: self.done = True From 7f45bef8db444b0436c5f80b4f4b31b2f1d7ec2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 09:34:29 -0500 Subject: [PATCH 0161/1433] Remove Callable quoting (#371) - The current minimum supported cpython is 3.6+ which does not need the quoting --- zeroconf/__init__.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5559f9db9..834313192 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1521,17 +1521,15 @@ def registration_interface(self) -> 'SignalRegistrationInterface': return SignalRegistrationInterface(self._handlers) -# NOTE: Callable quoting needed on Python 3.5.2, see -# https://github.com/jstasiak/python-zeroconf/issues/208 for details. class SignalRegistrationInterface: - def __init__(self, handlers: List['Callable[..., None]']) -> None: + def __init__(self, handlers: List[Callable[..., None]]) -> None: self._handlers = handlers - def register_handler(self, handler: 'Callable[..., None]') -> 'SignalRegistrationInterface': + def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': self._handlers.append(handler) return self - def unregister_handler(self, handler: 'Callable[..., None]') -> 'SignalRegistrationInterface': + def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': self._handlers.remove(handler) return self @@ -1564,9 +1562,7 @@ def __init__( self, zc: 'Zeroconf', type_: Union[str, list], - # NOTE: Callable quoting needed on Python 3.5.2, see - # https://github.com/jstasiak/python-zeroconf/issues/208 for details. - handlers: Optional[Union[ServiceListener, List['Callable[..., None]']]] = None, + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, @@ -1600,9 +1596,7 @@ def __init__( listener = cast(ServiceListener, handlers) handlers = None - # NOTE: Callable quoting needed on Python 3.5.2, see - # https://github.com/jstasiak/python-zeroconf/issues/208 for details. - handlers = cast(List['Callable[..., None]'], handlers or []) + handlers = cast(List[Callable[..., None]], handlers or []) if listener: From 82fb26f14518a8e59f886b8d7b0708a68725bf48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 09:39:59 -0500 Subject: [PATCH 0162/1433] Update changelog for 0.32.0 (unreleased) (#372) --- README.rst | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/README.rst b/README.rst index 956eab05a..56a5aff9c 100644 --- a/README.rst +++ b/README.rst @@ -134,6 +134,68 @@ See examples directory for more. Changelog ========= +0.32.0 (Unreleased) +=================== + +* Remove Callable quoting (#371) @bdraco + +* Abstract check to see if a record matches a type the ServiceBrowser wants (#369) @bdraco + +* Reduce complexity of ServiceBrowser enqueue_callback (#368) @bdraco + +* Fix empty answers being added in ServiceInfo.request (#367) @bdraco + +* Ensure ServiceInfo populates all AAAA records (#366) @bdraco + + Use get_all_by_details to ensure all records are loaded + into addresses. + + Only load A/AAAA records from cache once in load_from_cache + if there is a SRV record present + + Move duplicate code that checked if the ServiceInfo was complete + into its own function + +* Remove black python 3.5 exception block (#365) @bdraco + +* Small cleanup of ServiceInfo.update_record (#364) @bdraco + +* Add new cache function get_all_by_details (#363) @bdraco + When working with IPv6, multiple AAAA records can exist + for a given host. get_by_details would only return the + latest record in the cache. + + Fix a case where the cache list can change during + iteration + +* Small cleanups to asyncio tests (#362) @bdraco + +* Improve test coverage for name conflicts (#357) @bdraco + +* Return task objects created by AsyncZeroconf (#360) @nocarryr + +0.31.0 +====== + +* Separated cache loading from I/O in ServiceInfo and fixed cache lookup (#356), + thanks to J. Nick Koston. + + The ServiceInfo class gained a load_from_cache() method to only fetch information + from Zeroconf cache (if it exists) with no IO performed. Additionally this should + reduce IO in cases where cache lookups were previously incorrectly failing. + +0.30.0 +====== + +* Some nice refactoring work including removal of the Reaper thread, + thanks to J. Nick Koston. + +* Fixed a Windows-specific The requested address is not valid in its context regression, + thanks to Timothee ‘TTimo’ Besset and J. Nick Koston. + +* Provided an asyncio-compatible service registration layer (in the zeroconf.asyncio module), + thanks to J. Nick Koston. + 0.29.0 ====== From 5d4aa2800d1196274cfdd0bf3e631f49ab5b78bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 09:57:34 -0500 Subject: [PATCH 0163/1433] Reduce length of ServiceBrowser thread name with many types (#373) - Before "zeroconf-ServiceBrowser__ssh._tcp.local.-_enphase-envoy._tcp.local.-_hap._udp.local." "-_nut._tcp.local.-_Volumio._tcp.local.-_kizbox._tcp.local.-_home-assistant._tcp.local." "-_viziocast._tcp.local.-_dvl-deviceapi._tcp.local.-_ipp._tcp.local.-_touch-able._tcp.local." "-_hap._tcp.local.-_system-bridge._udp.local.-_dkapi._tcp.local.-_airplay._tcp.local." "-_elg._tcp.local.-_miio._udp.local.-_wled._tcp.local.-_esphomelib._tcp.local." "-_ipps._tcp.local.-_fbx-api._tcp.local.-_xbmc-jsonrpc-h._tcp.local.-_powerview._tcp.local." "-_spotify-connect._tcp.local.-_leap._tcp.local.-_api._udp.local.-_plugwise._tcp.local." "-_googlecast._tcp.local.-_printer._tcp.local.-_axis-video._tcp.local.-_http._tcp.local." "-_mediaremotetv._tcp.local.-_homekit._tcp.local.-_bond._tcp.local.-_daap._tcp.local._243" - After "zeroconf-ServiceBrowser-_miio._udp-_mediaremotetv._tcp-_dvl-deviceapi._tcp-_ipp._tcp" "-_dkapi._tcp-_hap._udp-_xbmc-jsonrpc-h._tcp-_hap._tcp-_googlecast._tcp-_airplay._tcp" "-_viziocast._tcp-_api._udp-_kizbox._tcp-_spotify-connect._tcp-_home-assistant._tcp" "-_bond._tcp-_powerview._tcp-_daap._tcp-_http._tcp-_leap._tcp-_elg._tcp-_homekit._tcp" "-_ipps._tcp-_plugwise._tcp-_ssh._tcp-_esphomelib._tcp-_Volumio._tcp-_fbx-api._tcp" "-_wled._tcp-_touch-able._tcp-_enphase-envoy._tcp-_axis-video._tcp-_printer._tcp" "-_system-bridge._udp-_nut._tcp-244" --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 834313192..815300b91 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1627,8 +1627,8 @@ def on_change( self.service_state_changed.register_handler(h) self.start() - self.name = "zeroconf-ServiceBrowser_%s_%s" % ( - '-'.join(self.types), + self.name = "zeroconf-ServiceBrowser-%s-%s" % ( + '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) From 03f2eb688859a78807305771d04b216e20e72064 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 10:16:19 -0500 Subject: [PATCH 0164/1433] Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) --- zeroconf/__init__.py | 16 ++++++---------- zeroconf/test.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 815300b91..3d3fefc17 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2761,18 +2761,14 @@ def handle_response(self, msg: DNSIncoming) -> None: updated = True if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # Since the cache format is keyed on the lower case record name - # we can avoid iterating everything in the cache and - # only look though entries for the specific name. - # entries_with_name will take care of converting to lowercase - for entry in self.cache.entries_with_name(record.name): - + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): if entry == record: updated = False - - # Check the time first because it is far cheaper - # than the __eq__ - if (record.created - entry.created > 1000) and DNSEntry.__eq__(entry, record): + if record.created - entry.created > 1000 and entry not in msg.answers: self.cache.remove(entry) expired = record.is_expired(now) diff --git a/zeroconf/test.py b/zeroconf/test.py index edc6f55f6..28c7482bd 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -596,6 +596,28 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi return r.DNSIncoming(generated.packet()) + def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + """Mock an incoming message for the case where the packet is split.""" + ttl = 120 + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + ), + 0, + ) + return r.DNSIncoming(generated.packet()) + service_name = 'name._type._tcp.local.' service_type = '_type._tcp.local.' service_server = 'ash-2.local.' @@ -630,6 +652,14 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi time.sleep(1.1) + # The split message only has a SRV and A record. + # This should not evict TXT records from the cache + zeroconf.handle_response(mock_split_incoming_msg(r.ServiceStateChange.Updated)) + time.sleep(1.1) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' + # service removed zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) From 51337425c9be08d59d496c6783d07d5e4e2382d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 11:08:21 -0500 Subject: [PATCH 0165/1433] Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) --- zeroconf/__init__.py | 66 ++++++++++++++++++++++++++++++-------------- zeroconf/test.py | 22 ++++++++++++++- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3d3fefc17..25d62814b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1691,16 +1691,12 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> if record.is_expired(now): return - address_changed = False - for service in zc.cache.entries_with_name(record.name): - if isinstance(service, DNSAddress) and service.address != record.address: - address_changed = True - break - - # Avoid iterating the entire DNSCache if the address has not changed - # as this is an expensive operation when there many hosts - # generating zeroconf traffic. - if not address_changed: + # Only trigger an updated event if the address is new + if record.address in set( + service.address + for service in zc.cache.entries_with_name(record.name) + if isinstance(service, DNSAddress) + ): return # Iterate through the DNSCache and callback any services that use this address @@ -2754,7 +2750,10 @@ def update_record(self, now: float, rec: DNSRecord) -> None: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" - updates = [] # type: List[Tuple[float, DNSRecord, Optional[DNSRecord]]] + updates = [] # type: List[DNSRecord] + address_adds = [] # type: List[DNSAddress] + other_adds = [] # type: List[DNSRecord] + removes = [] # type: List[DNSRecord] now = current_time_millis() for record in msg.answers: @@ -2769,7 +2768,7 @@ def handle_response(self, msg: DNSIncoming) -> None: if entry == record: updated = False if record.created - entry.created > 1000 and entry not in msg.answers: - self.cache.remove(entry) + removes.append(entry) expired = record.is_expired(now) maybe_entry = self.cache.get(record) @@ -2777,22 +2776,47 @@ def handle_response(self, msg: DNSIncoming) -> None: if maybe_entry is not None: maybe_entry.reset_ttl(record) else: - self.cache.add(record) + if isinstance(record, DNSAddress): + address_adds.append(record) + else: + other_adds.append(record) if updated: - updates.append((now, record, None)) + updates.append(record) elif maybe_entry is not None: - updates.append((now, record, maybe_entry)) + updates.append(record) + removes.append(record) - if not updates: + if not updates and not address_adds and not other_adds and not removes: return # Only hold the lock if we have updates with self._handlers_lock: - for update in updates: - now, record, entry_to_remove = update - self.update_record(update[0], update[1]) - if entry_to_remove: - self.cache.remove(entry_to_remove) + for record in updates: + self.update_record(now, record) + # The cache adds must be processed AFTER we trigger + # the updates since we compare existing data + # with the new data and updating the cache + # ahead of update_record will cause listeners + # to miss changes + # + # We must process address adds before non-addresses + # otherwise a fetch of ServiceInfo may miss an address + # because it thinks the cache is complete + # + # The cache is processed under the lock to ensure + # that any ServiceBrowser that is going to call + # zc.get_service_info will see the cached value + # but ONLY after all the record updates have been + # processsed. + for record in address_adds: + self.cache.add(record) + for record in other_adds: + self.cache.add(record) + # Removes are processed last since + # ServiceInfo could generate an un-needed query + # because the data was not yet populated. + for record in removes: + self.cache.remove(record) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if diff --git a/zeroconf/test.py b/zeroconf/test.py index 28c7482bd..a3a3e7ad3 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1333,12 +1333,14 @@ def update_service(self, zeroconf, type, name): class TestServiceBrowser(unittest.TestCase): def test_update_record(self): + enable_ipv6 = socket.has_ipv6 and not os.environ.get('SKIP_IPV6') service_name = 'name._type._tcp.local.' service_type = '_type._tcp.local.' service_server = 'ash-1.local.' service_text = b'path=/~matt1/' service_address = '10.0.1.2' + service_v6_address = "2001:db8::1" service_added_count = 0 service_removed_count = 0 @@ -1362,7 +1364,11 @@ def update_service(self, zc, type_, name) -> None: nonlocal service_updated_count service_updated_count += 1 service_info = zc.get_service_info(type_, name) - assert service_info.addresses[0] == socket.inet_aton(service_address) + assert socket.inet_aton(service_address) in service_info.addresses + if enable_ipv6: + assert socket.inet_pton( + socket.AF_INET6, service_v6_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) assert service_info.text == service_text assert service_info.server == service_server service_updated_event.set() @@ -1387,6 +1393,20 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi 0, ) + # Send the IPv6 address first since we previously + # had a bug where the IPv4 would be missing if the + # IPv6 was seen first + if enable_ipv6: + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_AAAA, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_address), + ), + 0, + ) generated.add_answer_at_time( r.DNSAddress( service_server, From b158b1cff31620d5cf27969e475d788332f4b38c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 11:28:44 -0500 Subject: [PATCH 0166/1433] Ensure duplicate packets do not trigger duplicate updates (#376) - If TXT or SRV records update was already processed and then recieved again, it was possible for a second update to be called back in the ServiceBrowser --- zeroconf/__init__.py | 27 ++++++++++++++++----------- zeroconf/test.py | 9 +++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 25d62814b..e2c291e91 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1664,9 +1664,11 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> ): self._handlers_to_call[key] = state_change - if record.type == _TYPE_PTR and record.name in self.types: - assert isinstance(record, DNSPointer) - expired = record.is_expired(now) + expired = record.is_expired(now) + + if isinstance(record, DNSPointer): + if record.name not in self.types: + return service_key = record.alias.lower() try: old_record = self._services[record.name][service_key] @@ -1685,12 +1687,13 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) if expires < self._next_time[record.name]: self._next_time[record.name] = expires + return - elif record.type == _TYPE_A or record.type == _TYPE_AAAA: - assert isinstance(record, DNSAddress) - if record.is_expired(now): - return + # If its expired or already exists in the cache it cannot be updated. + if expired or self.zc.cache.get(record): + return + if isinstance(record, DNSAddress): # Only trigger an updated event if the address is new if record.address in set( service.address @@ -1704,11 +1707,13 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> type_ = self._record_matching_type(service) if type_: enqueue_callback(ServiceStateChange.Updated, type_, service.name) + break + + return - elif not record.is_expired(now): - type_ = self._record_matching_type(record) - if type_: - enqueue_callback(ServiceStateChange.Updated, type_, record.name) + type_ = self._record_matching_type(record) + if type_: + enqueue_callback(ServiceStateChange.Updated, type_, record.name) def cancel(self) -> None: self.done = True diff --git a/zeroconf/test.py b/zeroconf/test.py index a3a3e7ad3..66ee453a1 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1455,6 +1455,15 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi assert service_updated_count == 2 assert service_removed_count == 0 + # service TXT updated - duplicate update should not trigger another service_updated + service_updated_event.clear() + service_text = b'path=/~matt2/' + zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 2 + assert service_removed_count == 0 + # service A updated service_updated_event.clear() service_address = '10.0.1.3' From 5535ea8c365557681721fdafdcabfc342c75daf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 11:35:33 -0500 Subject: [PATCH 0167/1433] Update changelog with latest merges (#377) --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index 56a5aff9c..3d6615e87 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,18 @@ Changelog 0.32.0 (Unreleased) =================== +* Ensure duplicate packets do not trigger duplicate updates (#376) + + If TXT or SRV records update was already processed and then + recieved again, it was possible for a second update to be + called back in the ServiceBrowser + +* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) + +* Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco + +* Reduce length of ServiceBrowser thread name with many types (#373) @bdraco + * Remove Callable quoting (#371) @bdraco * Abstract check to see if a record matches a type the ServiceBrowser wants (#369) @bdraco From 23442d2e5a0336a64646cb70f2ce389746744ce0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 11:36:40 -0500 Subject: [PATCH 0168/1433] Bump version to 0.31.0 to match released version (#378) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e2c291e91..1484ec66f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -41,7 +41,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.29.0' +__version__ = '0.31.0' __license__ = 'LGPL' From 60c1895e67a6147ab8c6ba7d21d4fe5adec3e590 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 11:58:40 -0500 Subject: [PATCH 0169/1433] Coalesce browser questions scheduled at the same time (#379) - With multiple types, the ServiceBrowser questions can be chatty because it would generate a question packet for each type. If multiple types are due to be requested, try to combine the questions into a single outgoing packet(s) --- zeroconf/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1484ec66f..ed3cfd01f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1733,19 +1733,22 @@ def run(self) -> None: if self.zc.done or self.done: return now = current_time_millis() + out = None for type_ in self.types: if self._next_time[type_] > now: continue - out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) + if not out: + out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) for record in self._services[type_].values(): if not record.is_stale(now): out.add_answer_at_time(record, now) - - self.zc.send(out, addr=self.addr, port=self.port) self._next_time[type_] = now + self._delay[type_] self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + if out: + self.zc.send(out, addr=self.addr, port=self.port) + if len(self._handlers_to_call) > 0 and not self.zc.done: with self.zc._handlers_lock: (name_type, state_change) = self._handlers_to_call.popitem(False) From 3afa5c13f2be956505428c5b01f6ce507845131a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 12:16:09 -0500 Subject: [PATCH 0170/1433] Complete ServiceInfo request as soon as all questions are answered (#380) - Closes a small race condition where there were no questions to ask because the cache was populated in between checks --- zeroconf/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ed3cfd01f..840ed4233 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2008,6 +2008,8 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if self.server is not None: out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) + if not out.questions: + return True zc.send(out) next_ = now + delay delay *= 2 From 2b502bc2e21efa2f840c42ed79f850b276a8c103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 12:18:07 -0500 Subject: [PATCH 0171/1433] Update changelog with latest merges (#381) --- README.rst | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3d6615e87..8a8e54b1c 100644 --- a/README.rst +++ b/README.rst @@ -137,13 +137,20 @@ Changelog 0.32.0 (Unreleased) =================== -* Ensure duplicate packets do not trigger duplicate updates (#376) +* Complete ServiceInfo request as soon as all questions are answered (#380) @bdraco + + Closes a small race condition where there were no questions + to ask because the cache was populated in between checks + +* Coalesce browser questions scheduled at the same time (#379) @bdraco + +* Ensure duplicate packets do not trigger duplicate updates (#376) @bdraco If TXT or SRV records update was already processed and then recieved again, it was possible for a second update to be called back in the ServiceBrowser -* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) +* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) @bdraco * Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco From 69a79b9fd48a24d311520e228c78b2aae52d1dd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 13:27:42 -0500 Subject: [PATCH 0172/1433] Fix multiple unclosed instances in tests (#383) --- zeroconf/test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index 66ee453a1..899cf24f1 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -37,6 +37,15 @@ original_logging_level = logging.NOTSET +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads = frozenset(threading.enumerate()) - threads_before + assert not threads + + def setup_module(): global original_logging_level original_logging_level = log.level @@ -924,6 +933,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): zc.unregister_service(info) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 + zc.close() def test_name_conflicts(self): # instantiate a zeroconf instance @@ -952,6 +962,7 @@ def test_name_conflicts(self): ) with pytest.raises(r.NonUniqueNameException): zc.register_service(conflicting_info) + zc.close() class TestServiceRegistry(unittest.TestCase): @@ -1598,6 +1609,7 @@ def test_service_info_rejects_non_matching_updates(self): ), ) assert new_address not in info.addresses + zc.close() def test_get_info_partial(self): @@ -2188,6 +2200,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): # unregister zc.unregister_service(info) + zc.close() def test_dns_compression_rollback_for_corruption(): From 5057f97b9b724c041d2bee65972fe3637bf04f0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 14:06:52 -0500 Subject: [PATCH 0173/1433] Ensure the cache is checked for name conflict after final service query with asyncio (#382) - The check was not happening after the last query --- zeroconf/asyncio.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 4ba7f312e..a07acb9b7 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -106,13 +106,17 @@ async def async_check_service(self, info: ServiceInfo, cooperating_responders: b instance_name_from_service_info(info) if cooperating_responders: return + self._raise_on_name_conflict(info) for i in range(3): - # check for a name conflict - if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): - raise NonUniqueNameException if i != 0: await asyncio.sleep(_CHECK_TIME / 1000) await self.loop.run_in_executor(None, self.zeroconf.send_service_query, info) + self._raise_on_name_conflict(info) + + def _raise_on_name_conflict(self, info: ServiceInfo) -> None: + """Raise NonUniqueNameException if the ServiceInfo has a conflict.""" + if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): + raise NonUniqueNameException async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service. From 69d9357b3dae7a99d302bf4ad71d4ed45cbe3e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 14:19:35 -0500 Subject: [PATCH 0174/1433] Update changelog with latest commits (#384) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 8a8e54b1c..a2c9b1f8e 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,8 @@ Changelog 0.32.0 (Unreleased) =================== +* Ensure the cache is checked for name conflict after final service query with asyncio (#382) @bdraco + * Complete ServiceInfo request as soon as all questions are answered (#380) @bdraco Closes a small race condition where there were no questions From 62a02d774fd874340fa3043bd3bf260a77ffe3d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 22:17:36 -0500 Subject: [PATCH 0175/1433] Ensure listeners do not miss initial packets if Engine starts too quickly (#387) --- zeroconf/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 840ed4233..beca9f867 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1373,7 +1373,6 @@ def __init__(self, zc: 'Zeroconf') -> None: self.condition = threading.Condition() self.socketpair = socket.socketpair() self._last_cache_cleanup = 0.0 - self.start() self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) def run(self) -> None: @@ -2539,6 +2538,10 @@ def __init__( if self.multi_socket: for s in self._respond_sockets: self.engine.add_reader(self.listener, s) + # Start the engine only after all + # the readers have been added to avoid + # missing any packets that are on the wire + self.engine.start() @property def done(self) -> bool: From 709bd9abae63cf566220693501cd37cf74391ccf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 22:28:51 -0500 Subject: [PATCH 0176/1433] Simplify DNSPointer processing in ServiceBrowser (#386) --- zeroconf/__init__.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index beca9f867..dbb3896c9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1669,23 +1669,19 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> if record.name not in self.types: return service_key = record.alias.lower() - try: - old_record = self._services[record.name][service_key] - except KeyError: - if not expired: - self._services[record.name][service_key] = record - enqueue_callback(ServiceStateChange.Added, record.name, record.alias) + services_by_type = self._services[record.name] + old_record = services_by_type.get(service_key) + if old_record is None: + services_by_type[service_key] = record + enqueue_callback(ServiceStateChange.Added, record.name, record.alias) + elif expired: + del services_by_type[service_key] + enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: - if not expired: - old_record.reset_ttl(record) - else: - del self._services[record.name][service_key] - enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) - return - - expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - if expires < self._next_time[record.name]: - self._next_time[record.name] = expires + old_record.reset_ttl(record) + expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + if expires < self._next_time[record.name]: + self._next_time[record.name] = expires return # If its expired or already exists in the cache it cannot be updated. From ba8d8e3e658c71e0d603db3f4c5bdfe8e508710a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 22:47:09 -0500 Subject: [PATCH 0177/1433] Fix flapping test: test_update_record (#388) --- zeroconf/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 899cf24f1..de95a0cfb 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1898,7 +1898,7 @@ def _mock_get_expiration_time(self, percent): zeroconf.handle_response( mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) ) - service_add_event.wait(wait_time) + service_add_event.wait(wait_time) assert called_with_refresh_time_check is True assert service_added_count == 3 assert service_removed_count == 0 From 8f4d2e858a5efadeb33120322c1169f3ce7d6e0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 22:56:14 -0500 Subject: [PATCH 0178/1433] Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dbb3896c9..4bb565bcb 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2083,11 +2083,11 @@ def find( # wait for responses time.sleep(timeout) + browser.cancel() + # close down anything we opened if zc is None: local_zc.close() - else: - browser.cancel() return tuple(sorted(listener.found_services)) From 33a3a6ae42ef8c4ea0f606ad2a02df3f6bc13752 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 May 2021 22:59:31 -0500 Subject: [PATCH 0179/1433] Update changelog for 0.32.0 (Unreleased) (#390) --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index a2c9b1f8e..ffdeb0c05 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,23 @@ Changelog 0.32.0 (Unreleased) =================== +* Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco + + There was a short window where the ServiceBrowser thread + could be left running after Zeroconf is closed because + the .join() was never waited for when a new Zeroconf + object was created + +* Simplify DNSPointer processing in ServiceBrowser (#386) @bdraco + +* Breaking change: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco + + When manually creating a zeroconf.Engine object, it is no longer started automatically. + It must manually be started by calling .start() on the created object. + + The Engine thread is now started after all the listeners have been added to avoid a + race condition where packets could be missed at startup. + * Ensure the cache is checked for name conflict after final service query with asyncio (#382) @bdraco * Complete ServiceInfo request as soon as all questions are answered (#380) @bdraco From d67d5f41effff4c01735de0ae64ed25a5dbe7567 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 May 2021 10:27:00 -0500 Subject: [PATCH 0180/1433] Fix IPv6 setup under MacOS when binding to "" (#392) - Setting IP_MULTICAST_TTL and IP_MULTICAST_LOOP does not work under MacOS when the bind address is "" --- zeroconf/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4bb565bcb..e90e90fa4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2235,8 +2235,12 @@ def new_socket( if ip_version != IPVersion.V6Only: # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and # IP_MULTICAST_LOOP socket options as an unsigned char. - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + try: + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + except socket.error as e: + if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS + raise if ip_version != IPVersion.V4Only: # However, char doesn't work here (at least on Linux) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) From ec2fafd904cd2d341a3815fcf6d34508dcddda5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 May 2021 10:40:59 -0500 Subject: [PATCH 0181/1433] Enable IPv6 in the CI (#393) --- .github/workflows/ci.yml | 2 -- zeroconf/test.py | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86cc95a7f..9c6f98f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,5 @@ jobs: pip install . - name: Run tests run: make ci - env: - SKIP_IPV6: 1 - name: Report coverage to Codecov uses: codecov/codecov-action@v1 diff --git a/zeroconf/test.py b/zeroconf/test.py index de95a0cfb..b5e96500d 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -16,9 +16,12 @@ import time import unittest import unittest.mock +from functools import lru_cache from threading import Event from typing import Dict, Optional, cast # noqa # used in type hints +import ifaddr + import pytest import zeroconf as r @@ -57,6 +60,28 @@ def teardown_module(): log.setLevel(original_logging_level) +@lru_cache(maxsize=None) +def has_working_ipv6(): + """Return True if if the system can bind an IPv6 address.""" + if not socket.has_ipv6: + return False + + try: + sock = socket.socket(socket.AF_INET6) + sock.bind(('::1', 0)) + except Exception: + return False + finally: + if sock: + sock.close() + + for iface in ifaddr.get_adapters(): + for addr in iface.ips: + if addr.is_IPv6 and iface.index is not None: + return True + return False + + class TestDunder(unittest.TestCase): def test_dns_text_repr(self): # There was an issue on Python 3 that prevented DNSText's repr @@ -550,7 +575,7 @@ def test_close_multiple_times(self): rv.close() rv.close() - @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) @@ -558,7 +583,7 @@ def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) rv.close() - @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) @@ -1092,7 +1117,7 @@ def test_integration_with_listener(self): finally: zeroconf_registrar.close() - @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_v6_records(self): @@ -1124,7 +1149,7 @@ def test_integration_with_listener_v6_records(self): finally: zeroconf_registrar.close() - @unittest.skipIf(not socket.has_ipv6, 'Requires IPv6') + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_ipv6(self): @@ -1240,7 +1265,7 @@ def update_service(self, zeroconf, type, name): desc = {'path': '/~paulsm/'} # type: Dict desc.update(properties) addresses = [socket.inet_aton("10.0.1.2")] - if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): + if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) info_service = ServiceInfo( subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses @@ -1344,7 +1369,7 @@ def update_service(self, zeroconf, type, name): class TestServiceBrowser(unittest.TestCase): def test_update_record(self): - enable_ipv6 = socket.has_ipv6 and not os.environ.get('SKIP_IPV6') + enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') service_name = 'name._type._tcp.local.' service_type = '_type._tcp.local.' @@ -2110,7 +2135,7 @@ def test_multiple_addresses(): ) assert info.addresses == [address, address] - if socket.has_ipv6 and not os.environ.get('SKIP_IPV6'): + if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) infos = [ From acf174db93ee60f1a80d501eb691d9cb434a90b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 May 2021 10:50:08 -0500 Subject: [PATCH 0182/1433] Add test coverage for multiple AAAA records (#391) --- zeroconf/test.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index b5e96500d..c1c0b775d 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1266,6 +1266,7 @@ def update_service(self, zeroconf, type, name): desc.update(properties) addresses = [socket.inet_aton("10.0.1.2")] if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): + addresses.append(socket.inet_pton(socket.AF_INET6, "6001:db8::1")) addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) info_service = ServiceInfo( subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses @@ -1377,6 +1378,7 @@ def test_update_record(self): service_text = b'path=/~matt1/' service_address = '10.0.1.2' service_v6_address = "2001:db8::1" + service_v6_second_address = "6001:db8::1" service_added_count = 0 service_removed_count = 0 @@ -1405,6 +1407,9 @@ def update_service(self, zc, type_, name) -> None: assert socket.inet_pton( socket.AF_INET6, service_v6_address ) in service_info.addresses_by_version(r.IPVersion.V6Only) + assert socket.inet_pton( + socket.AF_INET6, service_v6_second_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) assert service_info.text == service_text assert service_info.server == service_server service_updated_event.set() @@ -1443,6 +1448,16 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ), 0, ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_AAAA, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_second_address), + ), + 0, + ) generated.add_answer_at_time( r.DNSAddress( service_server, From a6010a94b626a9a1585cc47417c08516020729d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 May 2021 10:52:12 -0500 Subject: [PATCH 0183/1433] Update changelog with latest changes (#394) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index ffdeb0c05..40b9ef4d7 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,8 @@ Changelog 0.32.0 (Unreleased) =================== +* Fix IPv6 setup under MacOS when binding to "" (#392) @bdraco + * Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco There was a short window where the ServiceBrowser thread From dd6383589b161e828def0ed029519a645e434512 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 07:46:15 -0500 Subject: [PATCH 0184/1433] Remove unreachable code in ServiceInfo (#400) - self.server is never None --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e90e90fa4..3d4e18563 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1966,7 +1966,7 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: # If there is a srv record, A and AAAA will already # be called and we do not want to do it twice self.update_record(zc, now, cached_srv_record) - elif self.server is not None: + else: self._update_addresses_from_cache(zc, now) cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: @@ -1976,7 +1976,7 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: @property def _is_complete(self) -> bool: """The ServiceInfo has all expected properties.""" - return not (self.server is None or self.text is None or not self._addresses) + return not (self.text is None or not self._addresses) def request(self, zc: 'Zeroconf', timeout: float) -> bool: """Returns true if the service could be discovered on the From 4ae27beba29c6e9ac1782f40eadda584b4722af7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 08:08:15 -0500 Subject: [PATCH 0185/1433] Remove unreachable code in ServiceInfo (part 2) (#402) - self.server is never None --- zeroconf/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3d4e18563..266c5f684 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2000,9 +2000,8 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question_or_one_cache(zc, now, self.name, _TYPE_SRV, _CLASS_IN) out.add_question_or_one_cache(zc, now, self.name, _TYPE_TXT, _CLASS_IN) - if self.server is not None: - out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) + out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) if not out.questions: return True zc.send(out) From bddf69c0839eda966376987a8c4a1fbe3d865529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 08:21:18 -0500 Subject: [PATCH 0186/1433] Seperate query generation in ServiceInfo (#401) --- zeroconf/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 266c5f684..60b3e3333 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1997,11 +1997,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if last <= now: return False if next_ <= now: - out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question_or_one_cache(zc, now, self.name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(zc, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) + out = self.generate_request_query(zc, now) if not out.questions: return True zc.send(out) @@ -2015,6 +2011,15 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: return True + def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: + """Generate the request query.""" + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.add_question_or_one_cache(zc, now, self.name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(zc, now, self.name, _TYPE_TXT, _CLASS_IN) + out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) + return out + def __eq__(self, other: object) -> bool: """Tests equality of service name""" return isinstance(other, ServiceInfo) and other.name == self.name From e753078f0345fa28ffceb8de69542c8549d2994c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 08:41:31 -0500 Subject: [PATCH 0187/1433] Seperate query generation for Zeroconf (#403) - Will be used to send the query in asyncio --- zeroconf/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 60b3e3333..ae487be6f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2636,16 +2636,24 @@ def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None: """Send a broadcast to announce a service.""" + self.send(self.generate_service_broadcast(info, ttl)) + + def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> DNSOutgoing: + """Generate a broadcast to announce a service.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) self._add_broadcast_answer(out, info, ttl) - self.send(out) + return out def send_service_query(self, info: ServiceInfo) -> None: """Send a query to lookup a service.""" + self.send(self.generate_service_query(info)) + + def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: + """Generate a query to lookup a service.""" out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) - self.send(out) + return out def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None: """Add answers to broadcast a service.""" From 1e7b46c36f6e0735b44d3edd9740891a2dc0c761 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 10:34:48 -0500 Subject: [PATCH 0188/1433] Use a dedicated thread for sending outgoing packets with asyncio (#404) - Sends now go into a queue and are processed by the thread FIFO - Avoids overwhelming the executor when registering multiple services in parallel --- zeroconf/asyncio.py | 57 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index a07acb9b7..ac8de3c25 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -20,9 +20,12 @@ USA """ import asyncio +import queue +import threading from typing import Awaitable, Optional from . import ( + DNSOutgoing, IPVersion, InterfaceChoice, InterfacesType, @@ -30,12 +33,48 @@ ServiceInfo, Zeroconf, _CHECK_TIME, + _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME, instance_name_from_service_info, ) +class _AsyncSender(threading.Thread): + """A thread to handle sending DNSOutgoing for asyncio.""" + + def __init__(self, zc: 'Zeroconf'): + """Create the sender thread.""" + super().__init__() + self.zc = zc + self.queue = self._get_queue() + self.start() + self.name = "AsyncZeroconfSender" + + def _get_queue(self) -> queue.Queue: + """Create the best available queue type.""" + if hasattr(queue, "SimpleQueue"): + return queue.SimpleQueue() # type: ignore + return queue.Queue() + + def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + """Queue a send to be processed by the thread.""" + self.queue.put((out, addr, port)) + + def close(self) -> None: + """Close the instance.""" + self.queue.put(None) + self.join() + + def run(self) -> None: + """Runner that processes sends FIFO.""" + while True: + event = self.queue.get() + if event is None: + return + self.zc.send(*event) + + class AsyncZeroconf: """Implementation of Zeroconf Multicast DNS Service Discovery @@ -73,6 +112,7 @@ def __init__( ip_version=ip_version, apple_p2p=apple_p2p, ) + self.sender = _AsyncSender(self.zeroconf) self.loop = asyncio.get_event_loop() async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: @@ -80,7 +120,7 @@ async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: for i in range(3): if i != 0: await asyncio.sleep(interval / 1000) - await self.loop.run_in_executor(None, self.zeroconf.send_service_broadcast, info, ttl) + self.sender.send(self.zeroconf.generate_service_broadcast(info, ttl)) async def async_register_service( self, @@ -98,7 +138,7 @@ async def async_register_service( and therefore can be awaited if necessary. """ await self.async_check_service(info, cooperating_responders) - await self.loop.run_in_executor(None, self.zeroconf.registry.add, info) + self.zeroconf.registry.add(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: @@ -110,7 +150,7 @@ async def async_check_service(self, info: ServiceInfo, cooperating_responders: b for i in range(3): if i != 0: await asyncio.sleep(_CHECK_TIME / 1000) - await self.loop.run_in_executor(None, self.zeroconf.send_service_query, info) + self.sender.send(self.zeroconf.generate_service_query(info)) self._raise_on_name_conflict(info) def _raise_on_name_conflict(self, info: ServiceInfo) -> None: @@ -124,7 +164,7 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ - await self.loop.run_in_executor(None, self.zeroconf.registry.remove, info) + self.zeroconf.registry.remove(info) return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) async def async_update_service(self, info: ServiceInfo) -> Awaitable: @@ -135,10 +175,15 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ - await self.loop.run_in_executor(None, self.zeroconf.registry.update, info) + self.zeroconf.registry.update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + def _close(self) -> None: + """Shutdown zeroconf and the sender.""" + self.sender.close() + self.zeroconf.close() + async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" - await self.loop.run_in_executor(None, self.zeroconf.close) + await self.loop.run_in_executor(None, self._close) From 2da6198b2e60a598580637e80b3bd579c1f845a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 10:43:20 -0500 Subject: [PATCH 0189/1433] Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) - Uses the same pattern as ZeroconfServiceTypes.find --- zeroconf/asyncio.py | 3 ++- zeroconf/test_asyncio.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index ac8de3c25..460bde3a2 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -90,6 +90,7 @@ def __init__( unicast: bool = False, ip_version: Optional[IPVersion] = None, apple_p2p: bool = False, + zc: Optional['Zeroconf'] = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -106,7 +107,7 @@ def __init__( from it. Otherwise defaults to V4 only for backward compatibility. :param apple_p2p: use AWDL interface (only macOS) """ - self.zeroconf = Zeroconf( + self.zeroconf = zc or Zeroconf( interfaces=interfaces, unicast=unicast, ip_version=ip_version, diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index e048a603d..a7bc2037c 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -27,6 +27,15 @@ async def test_async_basic_usage() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_async_with_sync_passed_in() -> None: + """Test we can create and close the instance when passing in a sync Zeroconf.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(zc=zc) + assert aiozc.zeroconf is zc + await aiozc.async_close() + + @pytest.mark.asyncio async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" From ff31f386273fbe9fd0b466bbe5f724c815745215 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 11:00:39 -0500 Subject: [PATCH 0190/1433] Remove unreachable code in ServiceInfo.get_name (#407) --- zeroconf/__init__.py | 4 +--- zeroconf/test.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ae487be6f..5121503cb 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1925,9 +1925,7 @@ def _set_text(self, text: bytes) -> None: def get_name(self) -> str: """Name accessor""" - if self.type is not None and self.name.endswith("." + self.type): - return self.name[: len(self.name) - len(self.type) - 1] - return self.name + return self.name[: len(self.name) - len(self.type) - 1] def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: """Updates service information from a DNS record""" diff --git a/zeroconf/test.py b/zeroconf/test.py index c1c0b775d..bd2468885 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1551,6 +1551,18 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi class TestServiceInfo(unittest.TestCase): + def test_get_name(self): + """Verify the name accessor can strip the type.""" + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + assert info.get_name() == "name" + def test_service_info_rejects_non_matching_updates(self): """Verify records with the wrong name are rejected.""" From 745087b234dd5ff65b4b041a7221d58030a69cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 14:15:07 -0500 Subject: [PATCH 0191/1433] Add support for registering notify listeners (#409) - Notify listeners will be used by AsyncZeroconf to set asyncio.Event objects when new data is received - Registering a notify listener: notify_listener = YourNotifyListener() Use zeroconf.add_notify_listener(notify_listener) - Unregistering a notify listener: Use zeroconf.remove_notify_listener(notify_listener) - Notify listeners must inherit from the NotifyListener class --- zeroconf/__init__.py | 19 +++++++++++++++++++ zeroconf/test.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5121503cb..249a4709b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1549,6 +1549,14 @@ def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() +class NotifyListener: + """Receive notifications Zeroconf.notify_all is called.""" + + def notify_all(self) -> None: + """Called when Zeroconf.notify_all is called.""" + raise NotImplementedError() + + class ServiceBrowser(RecordUpdateListener, threading.Thread): """Used to browse for a service of a specific type. @@ -2521,6 +2529,7 @@ def __init__( self.multi_socket = unicast or interfaces is not InterfaceChoice.Default self.listeners = [] # type: List[RecordUpdateListener] + self._notify_listeners = [] # type: List[NotifyListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] self.registry = ServiceRegistry() @@ -2559,6 +2568,8 @@ def notify_all(self) -> None: """Notifies all waiting threads""" with self.condition: self.condition.notify_all() + for listener in self._notify_listeners: + listener.notify_all() def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular @@ -2569,6 +2580,14 @@ def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Option return info return None + def add_notify_listener(self, listener: NotifyListener) -> None: + """Adds a listener to receive notify_all events.""" + self._notify_listeners.append(listener) + + def remove_notify_listener(self, listener: NotifyListener) -> None: + """Removes a listener from the set that is currently listening.""" + self._notify_listeners.remove(listener) + def add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when diff --git a/zeroconf/test.py b/zeroconf/test.py index bd2468885..dcfa7c28b 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2421,3 +2421,41 @@ def test_add_multicast_member_socket_errors(errno, expected_result): fileno_mock = unittest.mock.PropertyMock(return_value=10) socket_mock = unittest.mock.Mock(setsockopt=setsockopt_mock, fileno=fileno_mock) assert r.add_multicast_member(socket_mock, "0.0.0.0") == expected_result + + +def test_notify_listeners(): + """Test adding and removing notify listeners.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + notify_called = 0 + + class TestNotifyListener(r.NotifyListener): + def notify_all(self): + nonlocal notify_called + notify_called += 1 + + with pytest.raises(NotImplementedError): + r.NotifyListener().notify_all() + + notify_listener = TestNotifyListener() + + zc.add_notify_listener(notify_listener) + + def on_service_state_change(zeroconf, service_type, state_change, name): + """Dummy service callback.""" + + # start a browser + browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) + browser.cancel() + + assert notify_called + zc.remove_notify_listener(notify_listener) + + notify_called = 0 + # start a browser + browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) + browser.cancel() + + assert not notify_called + + zc.close() From 53306e1b99d9133590d47081994ee77cef468828 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 14:33:43 -0500 Subject: [PATCH 0192/1433] Add async_wait function to AsyncZeroconf (#410) --- zeroconf/asyncio.py | 32 ++++++++++++++++++++++++++++++-- zeroconf/test_asyncio.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 460bde3a2..164668ebb 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -20,6 +20,7 @@ USA """ import asyncio +import contextlib import queue import threading from typing import Awaitable, Optional @@ -30,6 +31,7 @@ InterfaceChoice, InterfacesType, NonUniqueNameException, + NotifyListener, ServiceInfo, Zeroconf, _CHECK_TIME, @@ -75,6 +77,24 @@ def run(self) -> None: self.zc.send(*event) +class AsyncNotifyListener(NotifyListener): + """A NotifyListener that async code can use to wait for events.""" + + def __init__(self) -> None: + """Create an event for async listeners to wait for.""" + self.event = asyncio.Event() + self.loop = asyncio.get_event_loop() + + def notify_all(self) -> None: + """Schedule an async_notify_all.""" + self.loop.call_soon_threadsafe(self.async_notify_all) + + def async_notify_all(self) -> None: + """Notify all async listeners.""" + self.event.set() + self.event.clear() + + class AsyncZeroconf: """Implementation of Zeroconf Multicast DNS Service Discovery @@ -90,7 +110,7 @@ def __init__( unicast: bool = False, ip_version: Optional[IPVersion] = None, apple_p2p: bool = False, - zc: Optional['Zeroconf'] = None, + zc: Optional[Zeroconf] = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -113,8 +133,10 @@ def __init__( ip_version=ip_version, apple_p2p=apple_p2p, ) - self.sender = _AsyncSender(self.zeroconf) self.loop = asyncio.get_event_loop() + self.async_notify = AsyncNotifyListener() + self.zeroconf.add_notify_listener(self.async_notify) + self.sender = _AsyncSender(self.zeroconf) async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: """Send a broadcasts to announce a service at intervals.""" @@ -182,9 +204,15 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: def _close(self) -> None: """Shutdown zeroconf and the sender.""" self.sender.close() + self.zeroconf.remove_notify_listener(self.async_notify) self.zeroconf.close() async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" await self.loop.run_in_executor(None, self._close) + + async def async_wait(self, timeout: float) -> None: + """Calling task waits for a given number of milliseconds or until notified.""" + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.async_notify.event.wait(), timeout / 1000) diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index a7bc2037c..b3d661a3c 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -16,6 +16,7 @@ ServiceListener, ServiceNameAlreadyRegistered, Zeroconf, + current_time_millis, ) from .asyncio import AsyncZeroconf @@ -233,3 +234,39 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ('update', type_, registration_name), ('remove', type_, registration_name), ] + + +@pytest.mark.asyncio +async def test_async_wait_unblocks_on_update() -> None: + """Test async_wait will unblock on update.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc4-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + + # Should unblock due to update from the + # registration + now = current_time_millis() + await aiozc.async_wait(50000) + assert current_time_millis() - now < 3000 + await task + + now = current_time_millis() + await aiozc.async_wait(50) + assert current_time_millis() - now < 1000 + + await aiozc.async_close() From 0fa049c2e0f5e9f18830583a8df2736630c891e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 19:24:35 -0500 Subject: [PATCH 0193/1433] Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) --- zeroconf/asyncio.py | 48 ++++++++++++++++++ zeroconf/test_asyncio.py | 106 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 164668ebb..faddb67a2 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -35,9 +35,11 @@ ServiceInfo, Zeroconf, _CHECK_TIME, + _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME, + current_time_millis, instance_name_from_service_info, ) @@ -95,6 +97,41 @@ def async_notify_all(self) -> None: self.event.clear() +class AsyncServiceInfo(ServiceInfo): + """An async version of ServiceInfo.""" + + async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + if self.load_from_cache(aiozc.zeroconf): + return True + + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + last = now + timeout + try: + aiozc.zeroconf.add_listener(self, None) + while not self._is_complete: + if last <= now: + return False + if next_ <= now: + out = self.generate_request_query(aiozc.zeroconf, now) + if not out.questions: + return self.load_from_cache(aiozc.zeroconf) + aiozc.sender.send(out) + next_ = now + delay + delay *= 2 + + await aiozc.async_wait((min(next_, last) - now) / 1000) + now = current_time_millis() + finally: + aiozc.zeroconf.remove_listener(self) + + return True + + class AsyncZeroconf: """Implementation of Zeroconf Multicast DNS Service Discovery @@ -212,6 +249,17 @@ async def async_close(self) -> None: servicing further queries.""" await self.loop.run_in_executor(None, self._close) + async def async_get_service_info( + self, type_: str, name: str, timeout: int = 3000 + ) -> Optional[AsyncServiceInfo]: + """Returns network's service information for a particular + name and type, or None if no service matches by the timeout, + which defaults to 3 seconds.""" + info = AsyncServiceInfo(type_, name) + if await info.async_request(self, timeout): + return info + return None + async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" with contextlib.suppress(asyncio.TimeoutError): diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index b3d661a3c..eebed6006 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -6,6 +6,7 @@ import asyncio import socket +import unittest.mock import pytest @@ -16,9 +17,10 @@ ServiceListener, ServiceNameAlreadyRegistered, Zeroconf, + _LISTENER_TIME, current_time_millis, ) -from .asyncio import AsyncZeroconf +from .asyncio import AsyncServiceInfo, AsyncZeroconf @pytest.mark.asyncio @@ -270,3 +272,105 @@ async def test_async_wait_unblocks_on_update() -> None: assert current_time_millis() - now < 1000 await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_info_async_request() -> None: + """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "abc" + registration_name = "%s.%s" % (name, type_) + registration_name2 = "%s.%s" % (name2, type_) + + # Start a tasks BEFORE the registration that will keep trying + # and see the registration a bit later + get_service_info_task1 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + await asyncio.sleep(_LISTENER_TIME / 1000 / 2) + get_service_info_task2 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-1.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info2 = ServiceInfo( + type_, + registration_name2, + 80, + 0, + 0, + desc, + "ash-5.local.", + addresses=[socket.inet_aton("10.0.1.5")], + ) + tasks = [] + tasks.append(await aiozc.async_register_service(info)) + tasks.append(await aiozc.async_register_service(info2)) + await asyncio.gather(*tasks) + + aiosinfo = await get_service_info_task1 + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + aiosinfo = await get_service_info_task2 + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3"), socket.inet_pton(socket.AF_INET6, "6001:db8::1")], + ) + + task = await aiozc.async_update_service(new_info) + await task + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + + aiosinfos = await asyncio.gather( + aiozc.async_get_service_info(type_, registration_name), + aiozc.async_get_service_info(type_, registration_name2), + ) + assert aiosinfos[0] is not None + assert aiosinfos[0].addresses == [socket.inet_aton("10.0.1.3")] + assert aiosinfos[1] is not None + assert aiosinfos[1].addresses == [socket.inet_aton("10.0.1.5")] + + aiosinfo = AsyncServiceInfo(type_, registration_name) + zc_cache = aiozc.zeroconf.cache + for name in zc_cache.names(): + for record in zc_cache.entries_with_name(name): + zc_cache.remove(record) + # Generating the race condition is almost impossible + # without patching since its a TOCTOU race + with unittest.mock.patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): + await aiosinfo.async_request(aiozc, 3000) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + + task = await aiozc.async_unregister_service(new_info) + await task + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is None + + await aiozc.async_close() From bb83edfbca339fb6ec20b821d79b171220f5e675 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 19:27:05 -0500 Subject: [PATCH 0194/1433] Update changelog for 0.32.0 (#411) --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index 40b9ef4d7..6a7e04f54 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,14 @@ Changelog 0.32.0 (Unreleased) =================== +* Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco + +* Add support for registering notify listeners (#409) @bdraco + +* Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco + +* Use a dedicated thread for sending outgoing packets with asyncio (#404) @bdraco + * Fix IPv6 setup under MacOS when binding to "" (#392) @bdraco * Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco From 71cfbcb85bdd5948f1b96a871b10e9e35ab76c3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 May 2021 14:54:53 -0500 Subject: [PATCH 0195/1433] Add async_register_service/async_unregister_service example (#414) --- examples/async_registration.py | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 examples/async_registration.py diff --git a/examples/async_registration.py b/examples/async_registration.py new file mode 100644 index 000000000..53d14ce1a --- /dev/null +++ b/examples/async_registration.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Example of announcing 250 services (in this case, a fake HTTP server).""" + +import argparse +import asyncio +import logging +import socket +import time +from typing import List + +from zeroconf import IPVersion +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf + + +async def register_services(infos: List[AsyncServiceInfo]) -> None: + tasks = [aiozc.async_register_service(info) for info in infos] + background_tasks = await asyncio.gather(*tasks) + await asyncio.gather(*background_tasks) + + +async def unregister_services(infos: List[AsyncServiceInfo]) -> None: + tasks = [aiozc.async_unregister_service(info) for info in infos] + background_tasks = await asyncio.gather(*tasks) + await asyncio.gather(*background_tasks) + + +async def close_aiozc(aiozc: AsyncZeroconf) -> None: + await aiozc.async_close() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IPVersion.All + elif args.v6_only: + ip_version = IPVersion.V6Only + else: + ip_version = IPVersion.V4Only + + infos = [] + for i in range(250): + infos.append( + AsyncServiceInfo( + "_http._tcp.local.", + f"Paul's Test Web Site {i}._http._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=80, + properties={'path': '/~paulsm/'}, + server=f"zcdemohost-{i}.local.", + ) + ) + + print("Registration of 250 services, press Ctrl-C to exit...") + aiozc = AsyncZeroconf(ip_version=ip_version) + loop = asyncio.get_event_loop() + loop.run_until_complete(register_services(infos)) + print("Registration complete.") + try: + while True: + time.sleep(0.1) + except KeyboardInterrupt: + pass + finally: + print("Unregistering...") + loop.run_until_complete(unregister_services(infos)) + print("Unregistration complete.") + loop.run_until_complete(close_aiozc(aiozc)) From 7f08826c03b7997758ff0236834bf6f1a091c558 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 May 2021 15:17:14 -0500 Subject: [PATCH 0196/1433] Add async_request example with browse (#415) --- examples/async_service_info_request.py | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 examples/async_service_info_request.py diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py new file mode 100644 index 000000000..c0f953c23 --- /dev/null +++ b/examples/async_service_info_request.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Example of perodic dump of homekit services. + +This example is useful when a user wants an ondemand +list of HomeKit devices on the network. + +""" + +import argparse +import asyncio +import logging +from typing import cast + + +from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf + + +HAP_TYPE = "_hap._tcp.local." + + +async def async_watch_services(aiozc: AsyncZeroconf) -> None: + zeroconf = aiozc.zeroconf + while True: + await asyncio.sleep(5) + infos = [] + for name in zeroconf.cache.names(): + if not name.endswith(HAP_TYPE): + continue + infos.append(AsyncServiceInfo(HAP_TYPE, name)) + tasks = [info.async_request(aiozc, 3000) for info in infos] + await asyncio.gather(*tasks) + for info in infos: + print("Info for %s" % (info.name)) + if info: + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + print(" Addresses: %s" % ", ".join(addresses)) + print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + print(" Server: %s" % (info.server,)) + if info.properties: + print(" Properties are:") + for key, value in info.properties.items(): + print(" %s: %s" % (key, value)) + else: + print(" No properties") + else: + print(" No info") + print('\n') + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IPVersion.All + elif args.v6_only: + ip_version = IPVersion.V6Only + else: + ip_version = IPVersion.V4Only + + aiozc = AsyncZeroconf(ip_version=ip_version) + + def on_service_state_change( + zeroconf: Zeroconf, service_type: str, state_change: ServiceStateChange, name: str + ) -> None: + """Dummy handler.""" + + print(f"Services with {HAP_TYPE} will be shown every 5s, press Ctrl-C to exit...") + # ServiceBrowser currently is only offered in sync context. + # ServiceInfo has an AsyncServiceInfo counterpart that can be used + # to fetch service info in parallel + browser = ServiceBrowser(aiozc.zeroconf, [HAP_TYPE], handlers=[on_service_state_change]) + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(async_watch_services(aiozc)) + except KeyboardInterrupt: + pass + finally: + browser.cancel() + loop.run_until_complete(aiozc.async_close()) From 58cfcf0c902b5e27937f118bf4f7a855db635301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Jun 2021 13:51:54 -1000 Subject: [PATCH 0197/1433] Seperate query generation for ServiceBrowser (#420) --- zeroconf/__init__.py | 71 +++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 249a4709b..735294135 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1723,44 +1723,61 @@ def cancel(self) -> None: self.zc.remove_listener(self) self.join() + def generate_ready_queries(self) -> Optional[DNSOutgoing]: + """Generate the service browser query for any type that is due.""" + out = None + now = current_time_millis() + + if min(self._next_time.values()) > now: + return out + + for type_, due in self._next_time.items(): + if due > now: + continue + + if out is None: + out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) + out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) + + for record in self._services[type_].values(): + if not record.is_stale(now): + out.add_answer_at_time(record, now) + + self._next_time[type_] = now + self._delay[type_] + self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + return out + def run(self) -> None: questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] self.zc.add_listener(self, questions) while True: - now = current_time_millis() - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - if len(self._handlers_to_call) == 0 and next_time > now: - self.zc.wait(next_time - now) + if not self._handlers_to_call: + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + if next_time > now: + self.zc.wait(next_time - now) + if self.zc.done or self.done: return - now = current_time_millis() - out = None - for type_ in self.types: - if self._next_time[type_] > now: - continue - if not out: - out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) - out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) - for record in self._services[type_].values(): - if not record.is_stale(now): - out.add_answer_at_time(record, now) - self._next_time[type_] = now + self._delay[type_] - self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + out = self.generate_ready_queries() if out: self.zc.send(out, addr=self.addr, port=self.port) - if len(self._handlers_to_call) > 0 and not self.zc.done: - with self.zc._handlers_lock: - (name_type, state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) + if not self._handlers_to_call: + continue + + with self.zc._handlers_lock: + (name_type, state_change) = self._handlers_to_call.popitem(False) + + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) class ServiceInfo(RecordUpdateListener): From 8bca0305deae0db8ced7e213be3aaee975985c56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Jun 2021 14:13:46 -1000 Subject: [PATCH 0198/1433] Seperate logic for consuming records in ServiceInfo (#421) --- zeroconf/__init__.py | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 735294135..095219375 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1953,13 +1953,26 @@ def get_name(self) -> str: return self.name[: len(self.name) - len(self.type) - 1] def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: - """Updates service information from a DNS record""" + """Updates service information from a DNS record.""" if record is None or record.is_expired(now): return + + self._process_record(record, now) + + # Only update addresses if the DNSService (.server) has changed + if not isinstance(record, DNSService): + return + + for record in self._get_address_records_from_cache(zc): + self._process_record(record, now) + + def _process_record(self, record: DNSRecord, now: float) -> None: if isinstance(record, DNSAddress): if record.key == self.server_key and record.address not in self._addresses: self._addresses.append(record.address) - elif isinstance(record, DNSService): + return + + if isinstance(record, DNSService): if record.key != self.key: return self.name = record.name @@ -1968,32 +1981,37 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) self.port = record.port self.weight = record.weight self.priority = record.priority - self._update_addresses_from_cache(zc, now) - elif isinstance(record, DNSText): + return + + if isinstance(record, DNSText): if record.key == self.key: self._set_text(record.text) - def _update_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: - """Update the address records from the cache.""" + def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: + """Get the address records from the cache.""" + address_records = [] cached_a_record = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) if cached_a_record: - self.update_record(zc, now, cached_a_record) - for cached_aaaa_record in zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN): - self.update_record(zc, now, cached_aaaa_record) + address_records.append(cached_a_record) + address_records.extend(zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) + return address_records def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache.""" now = current_time_millis() + record_updates = [] cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: # If there is a srv record, A and AAAA will already # be called and we do not want to do it twice - self.update_record(zc, now, cached_srv_record) + record_updates.append(cached_srv_record) else: - self._update_addresses_from_cache(zc, now) + record_updates.extend(self._get_address_records_from_cache(zc)) cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: - self.update_record(zc, now, cached_txt_record) + record_updates.append(cached_txt_record) + for record in record_updates: + self.update_record(zc, now, record) return self._is_complete @property From 41de419453c0679c5a04ec248339783afbeb0e4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Jun 2021 12:32:16 -1000 Subject: [PATCH 0199/1433] A methods to generate DNSRecords from ServiceInfo (#422) --- zeroconf/__init__.py | 174 ++++++++++++++++--------------------------- 1 file changed, 66 insertions(+), 108 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 095219375..0c06ab901 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1987,6 +1987,54 @@ def _process_record(self, record: DNSRecord, now: float) -> None: if record.key == self.key: self._set_text(record.text) + def dns_addresses( + self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All + ) -> List[DNSAddress]: + """Return matching DNSAddress from ServiceInfo.""" + return [ + DNSAddress( + self.server, + _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + address, + ) + for address in self.addresses_by_version(version) + ] + + def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: + """Return DNSPointer from ServiceInfo.""" + return DNSPointer( + self.type, + _TYPE_PTR, + _CLASS_IN, + override_ttl if override_ttl is not None else self.other_ttl, + self.name, + ) + + def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: + """Return DNSService from ServiceInfo.""" + return DNSService( + self.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + self.priority, + self.weight, + cast(int, self.port), + self.server, + ) + + def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: + """Return DNSText from ServiceInfo.""" + return DNSText( + self.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.other_ttl, + self.text, + ) + def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: """Get the address records from the cache.""" address_records = [] @@ -2704,36 +2752,18 @@ def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: """Generate a query to lookup a service.""" out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) - out.add_authorative_answer(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, info.other_ttl, info.name)) + out.add_authorative_answer(info.dns_pointer()) return out def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None: """Add answers to broadcast a service.""" other_ttl = info.other_ttl if override_ttl is None else override_ttl host_ttl = info.host_ttl if override_ttl is None else override_ttl - out.add_answer_at_time(DNSPointer(info.type, _TYPE_PTR, _CLASS_IN, other_ttl, info.name), 0) - out.add_answer_at_time( - DNSService( - info.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - host_ttl, - info.priority, - info.weight, - cast(int, info.port), - info.server, - ), - 0, - ) - - out.add_answer_at_time( - DNSText(info.name, _TYPE_TXT, _CLASS_IN | _CLASS_UNIQUE, other_ttl, info.text), 0 - ) - for address in info.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer_at_time( - DNSAddress(info.server, type_, _CLASS_IN | _CLASS_UNIQUE, host_ttl, address), 0 - ) + out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) + out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) + out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) + for dns_address in info.dns_addresses(override_ttl=host_ttl, version=IPVersion.All): + out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service.""" @@ -2926,108 +2956,36 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None for service in self.registry.get_infos_type(question.name): if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - out.add_answer( - msg, - DNSPointer(service.type, _TYPE_PTR, _CLASS_IN, service.other_ttl, service.name), - ) - + out.add_answer(msg, service.dns_pointer()) # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer( - DNSService( - service.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.priority, - service.weight, - cast(int, service.port), - service.server, - ) - ) - out.add_additional_answer( - DNSText( - service.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, - service.text, - ) - ) - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_additional_answer( - DNSAddress( - service.server, - type_, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - address, - ) - ) + out.add_additional_answer(service.dns_service()) + out.add_additional_answer(service.dns_text()) + for dns_address in service.dns_addresses(version=IPVersion.All): + out.add_additional_answer(dns_address) + else: if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): for service in self.registry.get_infos_server(name_to_find): - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_answer( - msg, - DNSAddress( - question.name, - type_, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - address, - ), - ) + for dns_address in service.dns_addresses(version=IPVersion.All): + out.add_answer(msg, dns_address) service = self.registry.get_info_name(name_to_find) # type: ignore if service is None: continue if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer( - msg, - DNSService( - question.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - service.priority, - service.weight, - cast(int, service.port), - service.server, - ), - ) + out.add_answer(msg, service.dns_service()) if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer( - msg, - DNSText( - question.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - service.other_ttl, - service.text, - ), - ) + out.add_answer(msg, service.dns_text()) if question.type == _TYPE_SRV: - for address in service.addresses_by_version(IPVersion.All): - type_ = _TYPE_AAAA if _is_v6_address(address) else _TYPE_A - out.add_additional_answer( - DNSAddress( - service.server, - type_, - _CLASS_IN | _CLASS_UNIQUE, - service.host_ttl, - address, - ) - ) + for dns_address in service.dns_addresses(version=IPVersion.All): + out.add_additional_answer(dns_address) if out is not None and out.answers: out.id = msg.id From fc97e5c3ad35da789373a1898c00efe0f13a3b5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Jun 2021 14:19:58 -1000 Subject: [PATCH 0200/1433] Remove unused argument from ServiceInfo.dns_addresses (#423) - This should always return all addresses since its _CLASS_UNIQUE --- zeroconf/__init__.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0c06ab901..b0f9bedc3 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1987,9 +1987,7 @@ def _process_record(self, record: DNSRecord, now: float) -> None: if record.key == self.key: self._set_text(record.text) - def dns_addresses( - self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All - ) -> List[DNSAddress]: + def dns_addresses(self, override_ttl: Optional[int] = None) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" return [ DNSAddress( @@ -1999,7 +1997,7 @@ def dns_addresses( override_ttl if override_ttl is not None else self.host_ttl, address, ) - for address in self.addresses_by_version(version) + for address in self._addresses ] def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: @@ -2762,7 +2760,7 @@ def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_tt out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) - for dns_address in info.dns_addresses(override_ttl=host_ttl, version=IPVersion.All): + for dns_address in info.dns_addresses(override_ttl=host_ttl): out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: @@ -2961,7 +2959,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None # https://tools.ietf.org/html/rfc6763#section-12.1. out.add_additional_answer(service.dns_service()) out.add_additional_answer(service.dns_text()) - for dns_address in service.dns_addresses(version=IPVersion.All): + for dns_address in service.dns_addresses(): out.add_additional_answer(dns_address) else: @@ -2972,7 +2970,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(version=IPVersion.All): + for dns_address in service.dns_addresses(): out.add_answer(msg, dns_address) service = self.registry.get_info_name(name_to_find) # type: ignore @@ -2984,7 +2982,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None if question.type in (_TYPE_TXT, _TYPE_ANY): out.add_answer(msg, service.dns_text()) if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(version=IPVersion.All): + for dns_address in service.dns_addresses(): out.add_additional_answer(dns_address) if out is not None and out.answers: From 47e266eb66be36b355f1738cd4d2f7369712b7b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Jun 2021 14:38:10 -1000 Subject: [PATCH 0201/1433] Avoid checking the registry when answering requests for _services._dns-sd._udp.local. (#425) - _services._dns-sd._udp.local. is a special case and should never be in the registry --- zeroconf/__init__.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b0f9bedc3..37761b76f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2951,6 +2951,8 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None stype, ), ) + continue + for service in self.registry.get_infos_type(question.name): if out is None: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) @@ -2962,28 +2964,29 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None for dns_address in service.dns_addresses(): out.add_additional_answer(dns_address) - else: - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(name_to_find) # type: ignore - if service is None: - continue + continue - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: + if out is None: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) + out.add_answer(msg, dns_address) + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + continue + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, service.dns_service()) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, service.dns_text()) + if question.type == _TYPE_SRV: + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) if out is not None and out.answers: out.id = msg.id From e68e337cd482e06a422b2d2e2e6ae12ce1673ce5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Jun 2021 14:56:50 -1000 Subject: [PATCH 0202/1433] Remove is_type_unique as it is unused (#426) --- zeroconf/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 37761b76f..dc07b9360 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -953,10 +953,6 @@ class State(enum.Enum): init = 0 finished = 1 - @staticmethod - def is_type_unique(type_: int) -> bool: - return type_ == _TYPE_TXT or type_ == _TYPE_SRV or type_ == _TYPE_A or type_ == _TYPE_AAAA - def add_question(self, record: DNSQuestion) -> None: """Adds a question""" self.questions.append(record) From e7b2bb5e351f04f4f1e14ef5a20ed2111f8097c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 08:44:34 -1000 Subject: [PATCH 0203/1433] Seperate non-thread specific code from ServiceBrowser into _ServiceBrowserBase (#428) --- zeroconf/__init__.py | 59 ++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dc07b9360..234106fb6 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1553,13 +1553,8 @@ def notify_all(self) -> None: raise NotImplementedError() -class ServiceBrowser(RecordUpdateListener, threading.Thread): - - """Used to browse for a service of a specific type. - - The listener object will have its add_service() and - remove_service() methods called when this browser - discovers changes in the services availability.""" +class _ServiceBrowserBase(RecordUpdateListener): + """Base class for ServiceBrowser.""" def __init__( self, @@ -1577,7 +1572,6 @@ def __init__( for check_type_ in self.types: if not check_type_.endswith(service_type_name(check_type_, strict=False)): raise BadTypeInNameException - threading.Thread.__init__(self) self.daemon = True self.zc = zc self.addr = addr @@ -1629,12 +1623,6 @@ def on_change( for h in handlers: self.service_state_changed.register_handler(h) - self.start() - self.name = "zeroconf-ServiceBrowser-%s-%s" % ( - '-'.join([type_[:-7] for type_ in self.types]), - getattr(self, 'native_id', self.ident), - ) - @property def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface @@ -1715,9 +1703,14 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> enqueue_callback(ServiceStateChange.Updated, type_, record.name) def cancel(self) -> None: + """Cancel the browser.""" self.done = True self.zc.remove_listener(self) - self.join() + + def run(self) -> None: + """Run the browser.""" + questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] + self.zc.add_listener(self, questions) def generate_ready_queries(self) -> Optional[DNSOutgoing]: """Generate the service browser query for any type that is due.""" @@ -1743,10 +1736,40 @@ def generate_ready_queries(self) -> Optional[DNSOutgoing]: self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) return out - def run(self) -> None: - questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] - self.zc.add_listener(self, questions) +class ServiceBrowser(_ServiceBrowserBase, threading.Thread): + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__( + self, + zc: 'Zeroconf', + type_: Union[str, list], + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[ServiceListener] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + threading.Thread.__init__(self) + super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + self.start() + self.name = "zeroconf-ServiceBrowser-%s-%s" % ( + '-'.join([type_[:-7] for type_ in self.types]), + getattr(self, 'native_id', self.ident), + ) + + def cancel(self) -> None: + """Cancel the browser.""" + super().cancel() + self.join() + + def run(self) -> None: + """Run the browser thread.""" + super().run() while True: if not self._handlers_to_call: # Wait for the type has the smallest next time From 415a7b762030e9d236bef71f39156686a0b277f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 09:51:41 -1000 Subject: [PATCH 0204/1433] Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) --- zeroconf/asyncio.py | 89 +++++++++++++++++++++++++++++++++++++++- zeroconf/test_asyncio.py | 66 ++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index faddb67a2..200b652d4 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -23,7 +23,7 @@ import contextlib import queue import threading -from typing import Awaitable, Optional +from typing import Awaitable, Callable, Dict, List, Optional, Union from . import ( DNSOutgoing, @@ -34,10 +34,12 @@ NotifyListener, ServiceInfo, Zeroconf, + _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, + _ServiceBrowserBase, _UNREGISTER_TIME, current_time_millis, instance_name_from_service_info, @@ -97,6 +99,17 @@ def async_notify_all(self) -> None: self.event.clear() +class AsyncServiceListener: + def add_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def remove_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def update_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + class AsyncServiceInfo(ServiceInfo): """An async version of ServiceInfo.""" @@ -132,6 +145,59 @@ async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: return True +class AsyncServiceBrowser(_ServiceBrowserBase): + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__( + self, + aiozc: 'AsyncZeroconf', + type_: Union[str, list], + handlers: Optional[Union[AsyncServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[AsyncServiceListener] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + self.aiozc = aiozc + super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore + self._browser_task = asyncio.ensure_future(self.async_run()) + + def cancel(self) -> None: + """Cancel the browser.""" + super().cancel() + self._browser_task.cancel() + + async def async_run(self) -> None: + """Run the browser task.""" + self.run() + while True: + if not self._handlers_to_call: + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + if next_time > now: + await self.aiozc.async_wait(next_time - now) + + out = self.generate_ready_queries() + if out: + self.aiozc.sender.send(out, addr=self.addr, port=self.port) + + if not self._handlers_to_call: + continue + + (name_type, state_change) = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.aiozc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) + + class AsyncZeroconf: """Implementation of Zeroconf Multicast DNS Service Discovery @@ -173,6 +239,7 @@ def __init__( self.loop = asyncio.get_event_loop() self.async_notify = AsyncNotifyListener() self.zeroconf.add_notify_listener(self.async_notify) + self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} self.sender = _AsyncSender(self.zeroconf) async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: @@ -247,6 +314,7 @@ def _close(self) -> None: async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" + await self.async_remove_all_service_listeners() await self.loop.run_in_executor(None, self._close) async def async_get_service_info( @@ -264,3 +332,22 @@ async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(self.async_notify.event.wait(), timeout / 1000) + + async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: + """Adds a listener for a particular service type. This object + will then have its add_service and remove_service methods called when + services of that type become available and unavailable.""" + await self.async_remove_service_listener(listener) + self.async_browsers[listener] = AsyncServiceBrowser(self, type_, listener) + + async def async_remove_service_listener(self, listener: AsyncServiceListener) -> None: + """Removes a listener from the set that is currently listening.""" + if listener in self.async_browsers: + self.async_browsers[listener].cancel() + del self.async_browsers[listener] + + async def async_remove_all_service_listeners(self) -> None: + """Removes a listener from the set that is currently listening.""" + await asyncio.gather( + *[self.async_remove_service_listener(listener) for listener in list(self.async_browsers)] + ) diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index eebed6006..8126b69c2 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -20,7 +20,7 @@ _LISTENER_TIME, current_time_millis, ) -from .asyncio import AsyncServiceInfo, AsyncZeroconf +from .asyncio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf @pytest.mark.asyncio @@ -374,3 +374,67 @@ async def test_service_info_async_request() -> None: assert aiosinfo is None await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_browser() -> None: + """Test AsyncServiceBrowser.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + with pytest.raises(NotImplementedError): + AsyncServiceListener().add_service(aiozc, "_type", "name._type") + + with pytest.raises(NotImplementedError): + AsyncServiceListener().remove_service(aiozc, "_type", "name._type") + + with pytest.raises(NotImplementedError): + AsyncServiceListener().update_service(aiozc, "_type", "name._type") + + class MyListener(AsyncServiceListener): + def add_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + await aiozc.async_add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + await task + task = await aiozc.async_unregister_service(new_info) + await task + await aiozc.async_close() + + assert calls[0] == ('add', type_, registration_name) From e5a0c9a45df93a668f3611ddf5c41a1800cb4556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 10:18:00 -1000 Subject: [PATCH 0205/1433] Fix warning when generating sphinx docs (#432) - `docstring of zeroconf.ServiceInfo:5: WARNING: Unknown target name: "type".` --- zeroconf/__init__.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 234106fb6..fc90a3c27 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1804,19 +1804,19 @@ class ServiceInfo(RecordUpdateListener): Constructor parameters are as follows: - * type_: fully qualified service type name - * name: fully qualified service name - * port: port that the service runs on - * weight: weight of the service - * priority: priority of the service - * properties: dictionary of properties (or a bytes object holding the contents of the `text` field). + * `type_`: fully qualified service type name + * `name`: fully qualified service name + * `port`: port that the service runs on + * `weight`: weight of the service + * `priority`: priority of the service + * `properties`: dictionary of properties (or a bytes object holding the contents of the `text` field). converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to value-less attributes. - * server: fully qualified name for service host (defaults to name) - * host_ttl: ttl used for A/SRV records - * other_ttl: ttl used for PTR/TXT records - * addresses and parsed_addresses: List of IP addresses (either as bytes, network byte order, or in parsed - form as text; at most one of those parameters can be provided) + * `server`: fully qualified name for service host (defaults to name) + * `host_ttl`: ttl used for A/SRV records + * `other_ttl`: ttl used for PTR/TXT records + * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, + or in parsed form as text; at most one of those parameters can be provided) """ From 5460caef83b5cdb9c5d637741ed95dea6b328f08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 10:20:58 -1000 Subject: [PATCH 0206/1433] Add zeroconf.asyncio to the docs (#434) --- docs/api.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index 5bd2508f4..1704db5af 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,3 +5,8 @@ python-zeroconf API reference :members: :undoc-members: :show-inheritance: + +.. automodule:: zeroconf.asyncio + :members: + :undoc-members: + :show-inheritance: From 6737e13d8e6227b96d5cc0e776c62889b7dc4fd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 10:25:24 -1000 Subject: [PATCH 0207/1433] Update changelog for latest changes (#435) --- README.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.rst b/README.rst index 6a7e04f54..10472ba69 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,33 @@ Changelog 0.32.0 (Unreleased) =================== +* Add zeroconf.asyncio to the docs (#434) @bdraco + +* Fix warning when generating sphinx docs (#432) @bdraco + +* Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco + +* Seperate non-thread specific code from ServiceBrowser into _ServiceBrowserBase (#428) @bdraco + +* Remove is_type_unique as it is unused (#426) + +* Avoid checking the registry when answering requests for _services._dns-sd._udp.local. (#425) @bdraco + + _services._dns-sd._udp.local. is a special case and should never + be in the registry + +* Remove unused argument from ServiceInfo.dns_addresses (#423) @bdraco + +* Add methods to generate DNSRecords from ServiceInfo (#422) @bdraco + +* Seperate logic for consuming records in ServiceInfo (#421) @bdraco + +* Seperate query generation for ServiceBrowser (#420) @bdraco + +* Add async_request example with browse (#415) @bdraco + +* Add async_register_service/async_unregister_service example (#414) @bdraco + * Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco * Add support for registering notify listeners (#409) @bdraco From 1d3f986e00e18682c209cecbdea2481f4ca987b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 10:52:48 -1000 Subject: [PATCH 0208/1433] Cleanup unnecessary else after returns (#436) --- pyproject.toml | 31 +++++++++++++++++++++++++++++++ zeroconf/__init__.py | 28 ++++++++++++---------------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b48e90eeb..55b560914 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,34 @@ line-length = 110 target_version = ['py35', 'py36', 'py37', 'py38'] skip_string_normalization = true + +[tool.pylint.BASIC] +class-const-naming-style = "any" +good-names = [ + "e", + "er", + "h", + "i", + "id", + "ip", + "os", + "n", + "rr", + "rs", + "s", + "t", + "wr", + "zc", + "_GLOBAL_DONE", +] + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "fixme", + "format", + "missing-class-docstring", + "missing-function-docstring", + "too-few-public-methods", + "too-many-arguments", + "too-many-instance-attributes" +] diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index fc90a3c27..41c424cd3 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -678,8 +678,7 @@ def __repr__(self) -> str: """String representation""" if len(self.text) > 10: return self.to_string(self.text[:7]) + "..." - else: - return self.to_string(self.text) + return self.to_string(self.text) class DNSService(DNSRecord): @@ -1182,14 +1181,13 @@ def packet(self) -> bytes: does not fit in a single packet, but this exists for backward compatibility.""" packets = self.packets() - if len(packets) > 0: - if len(packets[0]) > _MAX_MSG_ABSOLUTE: - QuietLogger.log_warning_once( - "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] - ) - return packets[0] - else: + if len(packets) == 0: return b'' + if len(packets[0]) > _MAX_MSG_ABSOLUTE: + QuietLogger.log_warning_once( + "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] + ) + return packets[0] def packets(self) -> List[bytes]: """Returns a list of bytestrings containing the packets' bytes @@ -1904,10 +1902,9 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version.""" if version == IPVersion.V4Only: return [addr for addr in self._addresses if not _is_v6_address(addr)] - elif version == IPVersion.V6Only: + if version == IPVersion.V6Only: return list(filter(_is_v6_address, self._addresses)) - else: - return self._addresses + return self._addresses def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """List addresses in their parsed string form.""" @@ -2394,18 +2391,17 @@ def add_multicast_member( interface, ) return False - elif _errno == errno.EADDRNOTAVAIL: + if _errno == errno.EADDRNOTAVAIL: log.info( 'Address not available when adding %s to multicast ' 'group, it is expected to happen on some systems', interface, ) return False - elif _errno in err_einval: + if _errno in err_einval: log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) return False - else: - raise + raise return True From 8412eb791dd5ad1c287c1d7cc24c5db75a5291b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 10:56:10 -1000 Subject: [PATCH 0209/1433] Cleanup unused variables (#437) --- zeroconf/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 41c424cd3..398f019ff 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -789,7 +789,7 @@ def read_header(self) -> None: def read_questions(self) -> None: """Reads questions section of packet""" - for i in range(self.num_questions): + for _ in range(self.num_questions): name = self.read_name() type_, class_ = self.unpack(b'!HH') @@ -820,7 +820,7 @@ def read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" n = self.num_answers + self.num_authorities + self.num_additionals - for i in range(n): + for _ in range(n): domain = self.read_name() type_, class_, ttl, length = self.unpack(b'!HHiH') @@ -1381,7 +1381,7 @@ def run(self) -> None: try: rs.append(self.socketpair[0]) - rr, wr, er = select.select(rs, [], [], self.timeout) + rr, _wr, _er = select.select(rs, [], [], self.timeout) if self.zc.done: return From 4bcb698bda0ec7266d5e454b5e81a07eb64be32a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 11:13:05 -1000 Subject: [PATCH 0210/1433] Disable pylint too-many-branches for functions that need refactoring (#439) --- zeroconf/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 398f019ff..c9d6b1c5a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -232,7 +232,7 @@ def _encode_address(address: str) -> bytes: return socket.inet_pton(address_family, address) -def service_type_name(type_: str, *, strict: bool = True) -> str: +def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -2867,7 +2867,7 @@ def update_record(self, now: float, rec: DNSRecord) -> None: listener.update_record(self, now, rec) self.notify_all() - def handle_response(self, msg: DNSIncoming) -> None: + def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many-branches """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" updates = [] # type: List[DNSRecord] @@ -2938,7 +2938,9 @@ def handle_response(self, msg: DNSIncoming) -> None: for record in removes: self.cache.remove(record) - def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: + def handle_query( + self, msg: DNSIncoming, addr: Optional[str], port: int + ) -> None: # pylint: disable=too-many-branches """Deal with incoming query packets. Provides a response if possible.""" out = None From 594da709273c2e0a53fee2f9ad7fcec607ad0868 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 11:16:59 -1000 Subject: [PATCH 0211/1433] Remove unused now argument from ServiceInfo._process_record (#440) --- zeroconf/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index c9d6b1c5a..0779d8780 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1973,16 +1973,16 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) if record is None or record.is_expired(now): return - self._process_record(record, now) + self._process_record(record) # Only update addresses if the DNSService (.server) has changed if not isinstance(record, DNSService): return for record in self._get_address_records_from_cache(zc): - self._process_record(record, now) + self._process_record(record) - def _process_record(self, record: DNSRecord, now: float) -> None: + def _process_record(self, record: DNSRecord) -> None: if isinstance(record, DNSAddress): if record.key == self.server_key and record.address not in self._addresses: self._addresses.append(record.address) From a70370a0f653df911cc6f641522cec0fcc8471a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 11:19:54 -1000 Subject: [PATCH 0212/1433] Convert unnecessary use of a comprehension to a list (#441) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0779d8780..33129863d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2699,7 +2699,7 @@ def remove_service_listener(self, listener: ServiceListener) -> None: def remove_all_service_listeners(self) -> None: """Removes a listener from the set that is currently listening.""" - for listener in [k for k in self.browsers]: + for listener in list(self.browsers): self.remove_service_listener(listener) def register_service( From 41be4f4db0501adb9fbaa6b353fbcb36a45e6e21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 11:26:06 -1000 Subject: [PATCH 0213/1433] Merge _TYPE_CNAME and _TYPE_PTR comparison in DNSIncoming.read_others (#442) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 33129863d..a22d84b18 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -827,7 +827,7 @@ def read_others(self) -> None: rec = None # type: Optional[DNSRecord] if type_ == _TYPE_A: rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) - elif type_ == _TYPE_CNAME or type_ == _TYPE_PTR: + elif type_ in (_TYPE_CNAME, _TYPE_PTR): rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) elif type_ == _TYPE_TXT: rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) From 6002c9c88a9a49814f86070c07925f798a61461a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 11:38:05 -1000 Subject: [PATCH 0214/1433] Disable broad except checks in places we still catch broad exceptions (#443) --- zeroconf/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a22d84b18..a0301f4fe 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -592,7 +592,7 @@ def __repr__(self) -> str: socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address ) ) - except Exception: # TODO stop catching all Exceptions + except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions return self.to_string(str(self.address)) @@ -1446,7 +1446,7 @@ def __init__(self, zc: 'Zeroconf') -> None: def handle_read(self, socket_: socket.socket) -> None: try: data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) - except Exception: + except Exception: # pylint: disable=broad-except self.log_exception_warning('Error reading from socket %d', socket_.fileno()) return @@ -2857,7 +2857,7 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: try: self.listeners.remove(listener) self.notify_all() - except Exception as e: # TODO stop catching all Exceptions + except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions log.exception('Unknown error, possibly benign: %r', e) def update_record(self, now: float, rec: DNSRecord) -> None: @@ -3030,7 +3030,7 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P else: real_addr = addr bytes_sent = s.sendto(packet, 0, (real_addr, port)) - except Exception as exc: # TODO stop catching all Exceptions + except Exception as exc: # pylint: disable=broad-except # TODO stop catching all Exceptions if ( isinstance(exc, OSError) and exc.errno == errno.ENETUNREACH From 424c00257083f1d091a52ff0c966b306eea70efb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:11:05 -1000 Subject: [PATCH 0215/1433] Remove unneeded-not in new_socket (#445) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a0301f4fe..9c154a896 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2333,7 +2333,7 @@ def new_socket( try: s.setsockopt(socket.SOL_SOCKET, reuseport, 1) except OSError as err: - if not err.errno == errno.ENOPROTOOPT: + if err.errno != errno.ENOPROTOOPT: raise if port == _MDNS_PORT: From 929ba12d046496782491d96160e6cb8d0d04cfe5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:14:09 -1000 Subject: [PATCH 0216/1433] Fix redefining argument with the local name 'record' in ServiceInfo.update_record (#448) --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9c154a896..4dabdf569 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1979,8 +1979,8 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) if not isinstance(record, DNSService): return - for record in self._get_address_records_from_cache(zc): - self._process_record(record) + for cached_record in self._get_address_records_from_cache(zc): + self._process_record(cached_record) def _process_record(self, record: DNSRecord) -> None: if isinstance(record, DNSAddress): From ffc6cbb94d7401a70ebd6f747ed6c5e56e528bb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:18:10 -1000 Subject: [PATCH 0217/1433] Add missing update_service method to ZeroconfServiceTypes (#449) --- zeroconf/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4dabdf569..29aca513f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2157,13 +2157,18 @@ class ZeroconfServiceTypes(ServiceListener): """ def __init__(self) -> None: + """Keep track of found services in a set.""" self.found_services = set() # type: Set[str] def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + """Service added.""" self.found_services.add(name) + def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + """Service updated.""" + def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - pass + """Service removed.""" @classmethod def find( From 18851ed4c0f605996798472e1a68dded16d41ff6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:26:23 -1000 Subject: [PATCH 0218/1433] Extract _get_queue from _AsyncSender (#444) --- zeroconf/asyncio.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 200b652d4..89dff47ef 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -46,6 +46,13 @@ ) +def _get_best_available_queue() -> queue.Queue: + """Create the best available queue type.""" + if hasattr(queue, "SimpleQueue"): + return queue.SimpleQueue() # type: ignore + return queue.Queue() + + class _AsyncSender(threading.Thread): """A thread to handle sending DNSOutgoing for asyncio.""" @@ -53,16 +60,10 @@ def __init__(self, zc: 'Zeroconf'): """Create the sender thread.""" super().__init__() self.zc = zc - self.queue = self._get_queue() + self.queue = _get_best_available_queue() self.start() self.name = "AsyncZeroconfSender" - def _get_queue(self) -> queue.Queue: - """Create the best available queue type.""" - if hasattr(queue, "SimpleQueue"): - return queue.SimpleQueue() # type: ignore - return queue.Queue() - def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Queue a send to be processed by the thread.""" self.queue.put((out, addr, port)) From 7e03f836dd7a4ee938bfff21cd150e863f608b5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:35:15 -1000 Subject: [PATCH 0219/1433] Mark methods used by asyncio without self use (#447) --- zeroconf/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 29aca513f..27e19941b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2766,14 +2766,16 @@ def send_service_query(self, info: ServiceInfo) -> None: """Send a query to lookup a service.""" self.send(self.generate_service_query(info)) - def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: + def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use """Generate a query to lookup a service.""" out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) out.add_authorative_answer(info.dns_pointer()) return out - def _add_broadcast_answer(self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int]) -> None: + def _add_broadcast_answer( # pylint: disable=no-self-use + self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int] + ) -> None: """Add answers to broadcast a service.""" other_ttl = info.other_ttl if override_ttl is None else override_ttl host_ttl = info.host_ttl if override_ttl is None else override_ttl From ef0cf8e393a8ffdccb3cd2094a8764f707f518c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:47:12 -1000 Subject: [PATCH 0220/1433] Disable no-member check for WSAEINVAL false positive (#454) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 27e19941b..988a6ca30 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2377,7 +2377,7 @@ def add_multicast_member( err_einval = {errno.EINVAL} if sys.platform == 'win32': # No WSAEINVAL definition in typeshed - err_einval |= {cast(Any, errno).WSAEINVAL} + err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: From f26a92bc2abe61f5a2b5acd76991f81d07452201 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:52:30 -1000 Subject: [PATCH 0221/1433] Use unique name in test_async_service_browser test (#450) --- zeroconf/test_asyncio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index 8126b69c2..b436932a6 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -380,7 +380,7 @@ async def test_service_info_async_request() -> None: async def test_async_service_browser() -> None: """Test AsyncServiceBrowser.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test1-srvc-type._tcp.local." + type_ = "_test9-srvc-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) From 7544cdf956c4eeb4b688729432ba87278f606b7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 12:55:27 -1000 Subject: [PATCH 0222/1433] Disable pylint no-self-use check on abstract methods (#451) --- zeroconf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 988a6ca30..a1a9905a4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -505,7 +505,7 @@ def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException @@ -552,7 +552,7 @@ def reset_ttl(self, other: 'DNSRecord') -> None: self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException From 5fce89db2707b163231aec216e4c4fc310527e4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 13:02:31 -1000 Subject: [PATCH 0223/1433] Mark functions with too many branches in need of refactoring (#455) --- zeroconf/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a1a9905a4..00dd4666d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2295,7 +2295,7 @@ def normalize_interface_choice( return result -def new_socket( +def new_socket( # pylint: disable=too-many-branches bind_addr: Union[Tuple[str], Tuple[str, int, int]], port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, @@ -2945,9 +2945,9 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many for record in removes: self.cache.remove(record) - def handle_query( + def handle_query( # pylint: disable=too-many-branches self, msg: DNSIncoming, addr: Optional[str], port: int - ) -> None: # pylint: disable=too-many-branches + ) -> None: """Deal with incoming query packets. Provides a response if possible.""" out = None From 69c4cf69bbc34474e70eac3ad0fe905be7ab4eb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 13:21:36 -1000 Subject: [PATCH 0224/1433] Disable protected-access on the ServiceBrowser usage of _handlers_lock (#452) - This will be fixed in https://github.com/jstasiak/python-zeroconf/pull/419 --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 00dd4666d..a3af82f05 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1786,7 +1786,7 @@ def run(self) -> None: if not self._handlers_to_call: continue - with self.zc._handlers_lock: + with self.zc._handlers_lock: # pylint: disable=protected-access (name_type, state_change) = self._handlers_to_call.popitem(False) self._service_state_changed.fire( From 9510808cfd334b0b2f6381da8214225c4cfbf6a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 13:42:35 -1000 Subject: [PATCH 0225/1433] Trap OSError directly in Zeroconf.send instead of checking isinstance (#453) - Fixes: Instance of 'Exception' has no 'errno' member (no-member) --- zeroconf/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a3af82f05..0356b5476 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -3037,17 +3037,16 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P else: real_addr = addr bytes_sent = s.sendto(packet, 0, (real_addr, port)) - except Exception as exc: # pylint: disable=broad-except # TODO stop catching all Exceptions - if ( - isinstance(exc, OSError) - and exc.errno == errno.ENETUNREACH - and s.family == socket.AF_INET6 - ): + except OSError as exc: + if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: # with IPv6 we don't have a reliable way to determine if an interface actually has # IPV6 support, so we have to try and ignore errors. continue # on send errors, log the exception and keep going self.log_exception_warning('Error sending through socket %d', s.fileno()) + except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions + # on send errors, log the exception and keep going + self.log_exception_warning('Error sending through socket %d', s.fileno()) else: if bytes_sent != len(packet): self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) From 6fafdee241571d68937e29ee0a2b1bd5ef0038d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 14:30:34 -1000 Subject: [PATCH 0226/1433] Enable pylint (#438) --- Makefile | 5 ++++- pyproject.toml | 4 +++- requirements-dev.txt | 1 + zeroconf/asyncio.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index fed4d9b17..37d4bff6e 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.ver LINT_TARGETS:=flake8 ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) - LINT_TARGETS:=$(LINT_TARGETS) mypy black_check + LINT_TARGETS:=$(LINT_TARGETS) mypy black_check pylint endif @@ -28,6 +28,9 @@ lint: $(LINT_TARGETS) flake8: flake8 --max-line-length=$(MAX_LINE_LENGTH) setup.py examples zeroconf +pylint: + pylint zeroconf/__init__.py zeroconf/asyncio.py + .PHONY: black_check black_check: black --check setup.py examples zeroconf diff --git a/pyproject.toml b/pyproject.toml index 55b560914..cd79b3e20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,13 @@ good-names = [ [tool.pylint."MESSAGES CONTROL"] disable = [ + "duplicate-code", "fixme", "format", "missing-class-docstring", "missing-function-docstring", "too-few-public-methods", "too-many-arguments", - "too-many-instance-attributes" + "too-many-instance-attributes", + "too-many-public-methods" ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 30b906e44..325ce32a5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,6 +9,7 @@ ifaddr mypy;implementation_name=="cpython" # 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152 pep8-naming!=0.6.0,!=0.11.0 +pylint pytest pytest-asyncio pytest-cov diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 89dff47ef..69f7e8e39 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -49,7 +49,7 @@ def _get_best_available_queue() -> queue.Queue: """Create the best available queue type.""" if hasattr(queue, "SimpleQueue"): - return queue.SimpleQueue() # type: ignore + return queue.SimpleQueue() # type: ignore # pylint: disable=all return queue.Queue() From 5e24da08bc463bf79b27eb3768ec01755804f403 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 14:45:31 -1000 Subject: [PATCH 0227/1433] Reduce branching in Zeroconf.handle_query (#460) --- zeroconf/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0356b5476..60dc6cc75 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2950,21 +2950,19 @@ def handle_query( # pylint: disable=too-many-branches ) -> None: """Deal with incoming query packets. Provides a response if possible.""" - out = None - # Support unicast client responses # if port != _MDNS_PORT: out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) for question in msg.questions: out.add_question(question) + else: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) for question in msg.questions: if question.type == _TYPE_PTR: if question.name == "_services._dns-sd._udp.local.": for stype in self.registry.get_types(): - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer( msg, DNSPointer( @@ -2978,8 +2976,6 @@ def handle_query( # pylint: disable=too-many-branches continue for service in self.registry.get_infos_type(question.name): - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) out.add_answer(msg, service.dns_pointer()) # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. @@ -2990,9 +2986,6 @@ def handle_query( # pylint: disable=too-many-branches continue - if out is None: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - name_to_find = question.name.lower() # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): From ceb0def1b43f2e55bb17e33d13d4efdaa055221c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 14:57:24 -1000 Subject: [PATCH 0228/1433] Reduce branching in Zeroconf.handle_response (#459) --- zeroconf/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 60dc6cc75..ca284849d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,6 +23,7 @@ import enum import errno import ipaddress +import itertools import logging import platform import re @@ -2935,9 +2936,7 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many # zc.get_service_info will see the cached value # but ONLY after all the record updates have been # processsed. - for record in address_adds: - self.cache.add(record) - for record in other_adds: + for record in itertools.chain(address_adds, other_adds): self.cache.add(record) # Removes are processed last since # ServiceInfo could generate an un-needed query From 558cec3687ac7e7f494ab7aa4ce574c1e784b81f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 15:27:42 -1000 Subject: [PATCH 0229/1433] Use constant for service type enumeration (#461) --- zeroconf/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ca284849d..378e0be82 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -183,6 +183,9 @@ _TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' _NONTCP_PROTOCOL_LOCAL_TRAILER = '._udp.local.' +# https://datatracker.ietf.org/doc/html/rfc6763#section-9 +_SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local." + try: _IPPROTO_IPV6 = socket.IPPROTO_IPV6 except AttributeError: @@ -2191,7 +2194,7 @@ def find( """ local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) listener = cls() - browser = ServiceBrowser(local_zc, '_services._dns-sd._udp.local.', listener=listener) + browser = ServiceBrowser(local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener) # wait for responses time.sleep(timeout) @@ -2960,12 +2963,12 @@ def handle_query( # pylint: disable=too-many-branches for question in msg.questions: if question.type == _TYPE_PTR: - if question.name == "_services._dns-sd._udp.local.": + if question.name == _SERVICE_TYPE_ENUMERATION_NAME: for stype in self.registry.get_types(): out.add_answer( msg, DNSPointer( - "_services._dns-sd._udp.local.", + _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, From 4c4b529c841f015108a7489bd8f3b92a5e57e827 Mon Sep 17 00:00:00 2001 From: Stepan Henek Date: Sun, 6 Jun 2021 04:08:06 +0200 Subject: [PATCH 0230/1433] Support for context managers in Zeroconf and AsyncZeroconf (#284) Co-authored-by: J. Nick Koston --- zeroconf/__init__.py | 16 ++++++++++++++-- zeroconf/asyncio.py | 15 ++++++++++++++- zeroconf/test.py | 9 +++++++++ zeroconf/test_asyncio.py | 24 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 378e0be82..3458ae798 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,7 +35,8 @@ import time import warnings from collections import OrderedDict -from typing import Dict, Iterable, List, Optional, Union, cast +from types import TracebackType # noqa # used in type hints +from typing import Dict, Iterable, List, Optional, Type, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -3064,8 +3065,19 @@ def close(self) -> None: for s in self._respond_sockets: self.engine.del_reader(s) self.engine.join() - # shutdown the rest self.notify_all() for s in self._respond_sockets: s.close() + + def __enter__(self) -> 'Zeroconf': + return self + + def __exit__( # pylint: disable=useless-return + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + self.close() + return None diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 69f7e8e39..65771e8a2 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -23,7 +23,8 @@ import contextlib import queue import threading -from typing import Awaitable, Callable, Dict, List, Optional, Union +from types import TracebackType # noqa # used in type hints +from typing import Awaitable, Callable, Dict, List, Optional, Type, Union from . import ( DNSOutgoing, @@ -352,3 +353,15 @@ async def async_remove_all_service_listeners(self) -> None: await asyncio.gather( *[self.async_remove_service_listener(listener) for listener in list(self.async_browsers)] ) + + async def __aenter__(self) -> 'AsyncZeroconf': + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + await self.async_close() + return None diff --git a/zeroconf/test.py b/zeroconf/test.py index dcfa7c28b..37a3a2464 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -564,6 +564,15 @@ def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) rv.close() + def test_launch_and_close_context_manager(self): + with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: + assert rv.done is False + assert rv.done is True + + with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: + assert rv.done is False + assert rv.done is True + def test_launch_and_close_unicast(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) rv.close() diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index b436932a6..e6bcf5a28 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -438,3 +438,27 @@ def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: await aiozc.async_close() assert calls[0] == ('add', type_, registration_name) + + +@pytest.mark.asyncio +async def test_async_context_manager() -> None: + """Test using an async context manager.""" + type_ = "_test10-sr-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + async with AsyncZeroconf(interfaces=['127.0.0.1']) as aiozc: + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None From c1ed987ede34b0049e6466e673b1629d7cd0cd6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 16:08:27 -1000 Subject: [PATCH 0231/1433] Break apart Zeroconf.handle_query to reduce branching (#462) --- zeroconf/__init__.py | 100 ++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3458ae798..cb0100089 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2948,9 +2948,59 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many for record in removes: self.cache.remove(record) - def handle_query( # pylint: disable=too-many-branches - self, msg: DNSIncoming, addr: Optional[str], port: int - ) -> None: + def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: + """Provide an answer to a service type enumeration query. + + https://datatracker.ietf.org/doc/html/rfc6763#section-9 + """ + for stype in self.registry.get_types(): + out.add_answer( + msg, + DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + ), + ) + + def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a PTR query.""" + for service in self.registry.get_infos_type(question.name): + out.add_answer(msg, service.dns_pointer()) + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer(service.dns_service()) + out.add_additional_answer(service.dns_text()) + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a query any query other then PTR. + + Add answer(s) for A, AAAA, SRV, or TXT queries. + """ + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): + for dns_address in service.dns_addresses(): + out.add_answer(msg, dns_address) + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + return + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, service.dns_service()) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, service.dns_text()) + if question.type == _TYPE_SRV: + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if possible.""" # Support unicast client responses @@ -2965,48 +3015,12 @@ def handle_query( # pylint: disable=too-many-branches for question in msg.questions: if question.type == _TYPE_PTR: if question.name == _SERVICE_TYPE_ENUMERATION_NAME: - for stype in self.registry.get_types(): - out.add_answer( - msg, - DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - continue - - for service in self.registry.get_infos_type(question.name): - out.add_answer(msg, service.dns_pointer()) - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer(service.dns_service()) - out.add_additional_answer(service.dns_text()) - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - continue - - name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(name_to_find) # type: ignore - if service is None: + self._answer_service_type_enumeration_query(msg, out) + else: + self._answer_ptr_query(msg, out, question) continue - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) + self._answer_non_ptr_query(msg, out, question) if out is not None and out.answers: out.id = msg.id From c3365e1fd060cebc63cc42443260bd785077c246 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 16:50:32 -1000 Subject: [PATCH 0232/1433] Clear cache between ServiceTypesQuery tests (#466) - Ensures the test relies on the ZeroconfServiceTypes.find making the correct calls instead of the cache from the previous call --- zeroconf/test.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 37a3a2464..11131964c 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -82,6 +82,12 @@ def has_working_ipv6(): return False +def _clear_cache(zc): + for name in zc.cache.names(): + for record in zc.cache.entries_with_name(name): + zc.cache.remove(record) + + class TestDunder(unittest.TestCase): def test_dns_text_repr(self): # There was an issue on Python 3 that prevented DNSText's repr @@ -1120,6 +1126,7 @@ def test_integration_with_listener(self): try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types + _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types @@ -1152,6 +1159,7 @@ def test_integration_with_listener_v6_records(self): try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types + _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types @@ -1183,6 +1191,7 @@ def test_integration_with_listener_ipv6(self): try: service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) assert type_ in service_types + _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types @@ -1214,6 +1223,7 @@ def test_integration_with_subtype_and_listener(self): try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert discovery_type in service_types + _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert discovery_type in service_types @@ -1290,9 +1300,7 @@ def update_service(self, zeroconf, type, name): time.sleep(3) # clear the answer cache to force query - for name in zeroconf_browser.cache.names(): - for record in zeroconf_browser.cache.entries_with_name(name): - zeroconf_browser.cache.remove(record) + _clear_cache(zeroconf_browser) cached_info = ServiceInfo(type_, registration_name) cached_info.load_from_cache(zeroconf_browser) From 7a5040247cbaad6bed3fc1204820dfc31ed9b0ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 17:04:15 -1000 Subject: [PATCH 0233/1433] Ensure PTR questions asked in uppercase are answered (#465) --- zeroconf/__init__.py | 4 ++-- zeroconf/test.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cb0100089..7ef3a69af 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2967,7 +2967,7 @@ def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgo def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name): + for service in self.registry.get_infos_type(question.name.lower()): out.add_answer(msg, service.dns_pointer()) # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. @@ -3014,7 +3014,7 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None for question in msg.questions: if question.type == _TYPE_PTR: - if question.name == _SERVICE_TYPE_ENUMERATION_NAME: + if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: self._answer_service_type_enumeration_query(msg, out) else: self._answer_ptr_query(msg, out, question) diff --git a/zeroconf/test.py b/zeroconf/test.py index 11131964c..d471db3e4 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1004,6 +1004,37 @@ def test_name_conflicts(self): zc.register_service(conflicting_info) zc.close() + def test_register_and_lookup_type_by_uppercase_name(self): + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_mylowertype._tcp.local." + name = "Home" + registration_name = "%s.%s" % (name, type_) + + info = ServiceInfo( + type_, + name=registration_name, + server="random123.local.", + addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], + port=80, + properties={"version": "1.0"}, + ) + zc.register_service(info) + _clear_cache(zc) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zc) + assert info.addresses == [] + + out = r.DNSOutgoing(r._FLAGS_QR_QUERY) + out.add_question(r.DNSQuestion(type_.upper(), r._TYPE_PTR, r._CLASS_IN)) + zc.send(out) + time.sleep(0.5) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zc) + assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] + assert info.properties == {b"version": b"1.0"} + zc.close() + class TestServiceRegistry(unittest.TestCase): def test_only_register_once(self): From 8a9ae29b6f6643f3625938ac44df66dcc556de46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 22:10:12 -1000 Subject: [PATCH 0234/1433] Reduce branching in Zeroconf.handle_response (#467) - Adds `add_records` and `remove_records` to `DNSCache` to permit multiple records to be added or removed in one call - This change is not enough to remove the too-many-branches pylint disable, however when combined with #419 it should no longer be needed --- zeroconf/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 7ef3a69af..179e42215 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1284,12 +1284,22 @@ def add(self, entry: DNSRecord) -> None: if isinstance(entry, DNSService): self.service_cache.setdefault(entry.server, []).append(entry) + def add_records(self, entries: Iterable[DNSRecord]) -> None: + """Add multiple records.""" + for entry in entries: + self.add(entry) + def remove(self, entry: DNSRecord) -> None: """Removes an entry.""" if isinstance(entry, DNSService): DNSCache.remove_key(self.service_cache, entry.server, entry) DNSCache.remove_key(self.cache, entry.key, entry) + def remove_records(self, entries: Iterable[DNSRecord]) -> None: + """Remove multiple records.""" + for entry in entries: + self.remove(entry) + @staticmethod def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: """Forgiving remove of a cache key.""" @@ -2940,13 +2950,11 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many # zc.get_service_info will see the cached value # but ONLY after all the record updates have been # processsed. - for record in itertools.chain(address_adds, other_adds): - self.cache.add(record) + self.cache.add_records(itertools.chain(address_adds, other_adds)) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. - for record in removes: - self.cache.remove(record) + self.cache.remove_records(removes) def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: """Provide an answer to a service type enumeration query. From 1eaeef2d6f07efba67e91699529f8361226233ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 22:34:36 -1000 Subject: [PATCH 0235/1433] Fix flakey test_update_record (#470) --- zeroconf/test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index d471db3e4..2eb9c7708 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1980,9 +1980,6 @@ def mock_incoming_msg( zeroconf.handle_response( mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120) ) - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) - ) called_with_refresh_time_check = False @@ -1995,6 +1992,12 @@ def _mock_get_expiration_time(self, percent): # Set an expire time that will force a refresh with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + zeroconf.handle_response( + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120) + ) + # Add the last record after updating the first one + # to ensure the service_add_event only gets set + # after the update zeroconf.handle_response( mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) ) From 00af5adc4be76afd23135d37653119f45c57a531 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 23:09:02 -1000 Subject: [PATCH 0236/1433] Reduce branching in service_type_name (#472) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 179e42215..d2be47e16 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -280,7 +280,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis :return: fully qualified service name (eg: _http._tcp.local.) """ - if type_.endswith(_TCP_PROTOCOL_LOCAL_TRAILER) or type_.endswith(_NONTCP_PROTOCOL_LOCAL_TRAILER): + if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] has_protocol = True From d0f5a60275ccf810407055c63ca9080fa6654443 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 23:10:04 -1000 Subject: [PATCH 0237/1433] Add test coverage to ensure ServiceInfo rejects expired records (#468) --- zeroconf/test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/zeroconf/test.py b/zeroconf/test.py index 2eb9c7708..11979af77 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1711,6 +1711,46 @@ def test_service_info_rejects_non_matching_updates(self): assert new_address not in info.addresses zc.close() + def test_service_info_rejects_expired_records(self): + """Verify records that are expired are rejected.""" + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + # Expired record + expired_record = r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ) + expired_record.created = 1000 + expired_record._expiration_time = 1000 + info.update_record(zc, now, expired_record) + assert info.properties[b"ci"] == b"2" + zc.close() + def test_get_info_partial(self): zc = r.Zeroconf(interfaces=['127.0.0.1']) From b8534130ec31a6be191fcc60615ab2fd02fd8d7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 23:18:34 -1000 Subject: [PATCH 0238/1433] Narrow exception catch in DNSAddress.__repr__ to only expected exceptions (#473) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d2be47e16..dcd477fa5 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -597,7 +597,7 @@ def __repr__(self) -> str: socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address ) ) - except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions + except (ValueError, OSError): return self.to_string(str(self.address)) From ed53f6283265eb8fb506d4af8fb31bd4eaa7292b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Jun 2021 23:55:49 -1000 Subject: [PATCH 0239/1433] Add support for updating multiple records at once to ServiceInfo (#474) - Adds `update_records` method to `ServiceInfo` --- zeroconf/__init__.py | 32 ++++++++++++++++++++++---------- zeroconf/test.py | 2 ++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dcd477fa5..9a5e125e8 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1984,20 +1984,33 @@ def get_name(self) -> str: return self.name[: len(self.name) - len(self.type) - 1] def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: - """Updates service information from a DNS record.""" - if record is None or record.is_expired(now): - return + """Updates service information from a DNS record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + """ + if record is not None: + self.update_records(zc, now, [record]) - self._process_record(record) + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Updates service information from a DNS record.""" + update_addresses = False + for record in records: + if isinstance(record, DNSService): + update_addresses = True + self._process_record(record, now) # Only update addresses if the DNSService (.server) has changed - if not isinstance(record, DNSService): + if not update_addresses: return - for cached_record in self._get_address_records_from_cache(zc): - self._process_record(cached_record) + for record in self._get_address_records_from_cache(zc): + self._process_record(record, now) + + def _process_record(self, record: DNSRecord, now: float) -> None: + if record.is_expired(now): + return - def _process_record(self, record: DNSRecord) -> None: if isinstance(record, DNSAddress): if record.key == self.server_key and record.address not in self._addresses: self._addresses.append(record.address) @@ -2087,8 +2100,7 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: record_updates.append(cached_txt_record) - for record in record_updates: - self.update_record(zc, now, record) + self.update_records(zc, now, record_updates) return self._is_complete @property diff --git a/zeroconf/test.py b/zeroconf/test.py index 11979af77..02195240f 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1625,6 +1625,8 @@ def test_service_info_rejects_non_matching_updates(self): info = ServiceInfo( service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] ) + # Verify backwards compatiblity with calling with None + info.update_record(zc, now, None) # Matching updates info.update_record( zc, From b0c0cdc6779dc095cf03ebd92652af69800b7bca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 12:59:34 -1000 Subject: [PATCH 0240/1433] Fix AsyncServiceInfo.async_request not waiting long enough (#480) - The call to async_wait should have been in milliseconds, but the time was being passed in seconds which resulted in waiting 1000x shorter --- zeroconf/asyncio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 65771e8a2..52b8f188c 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -139,7 +139,7 @@ async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: next_ = now + delay delay *= 2 - await aiozc.async_wait((min(next_, last) - now) / 1000) + await aiozc.async_wait(min(next_, last) - now) now = current_time_millis() finally: aiozc.zeroconf.remove_listener(self) From 849e9bc792c6cc77b879b4761195192bea1720ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 13:05:29 -1000 Subject: [PATCH 0241/1433] Provide a helper function to convert milliseconds to seconds (#481) --- zeroconf/__init__.py | 9 +++++++-- zeroconf/asyncio.py | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9a5e125e8..9bb488b5f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -227,6 +227,11 @@ def current_time_millis() -> float: return time.time() * 1000 +def millis_to_seconds(millis: float) -> float: + """Convert milliseconds to seconds.""" + return millis / 1000.0 + + def _is_v6_address(addr: bytes) -> bool: return len(addr) == 16 @@ -539,7 +544,7 @@ def get_expiration_time(self, percent: int) -> float: # TODO: Switch to just int here def get_remaining_ttl(self, now: float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" - return max(0, (self._expiration_time - now) / 1000.0) + return max(0, millis_to_seconds(self._expiration_time - now)) def is_expired(self, now: float) -> bool: """Returns true if this record has expired.""" @@ -2690,7 +2695,7 @@ def wait(self, timeout: float) -> None: """Calling thread waits for a given number of milliseconds or until notified.""" with self.condition: - self.condition.wait(timeout / 1000.0) + self.condition.wait(millis_to_seconds(timeout)) def notify_all(self) -> None: """Notifies all waiting threads""" diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 52b8f188c..1f159c924 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -44,6 +44,7 @@ _UNREGISTER_TIME, current_time_millis, instance_name_from_service_info, + millis_to_seconds, ) @@ -248,7 +249,7 @@ async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: """Send a broadcasts to announce a service at intervals.""" for i in range(3): if i != 0: - await asyncio.sleep(interval / 1000) + await asyncio.sleep(millis_to_seconds(interval)) self.sender.send(self.zeroconf.generate_service_broadcast(info, ttl)) async def async_register_service( @@ -278,7 +279,7 @@ async def async_check_service(self, info: ServiceInfo, cooperating_responders: b self._raise_on_name_conflict(info) for i in range(3): if i != 0: - await asyncio.sleep(_CHECK_TIME / 1000) + await asyncio.sleep(millis_to_seconds(_CHECK_TIME)) self.sender.send(self.zeroconf.generate_service_query(info)) self._raise_on_name_conflict(info) @@ -333,7 +334,7 @@ async def async_get_service_info( async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.async_notify.event.wait(), timeout / 1000) + await asyncio.wait_for(self.async_notify.event.wait(), millis_to_seconds(timeout)) async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: """Adds a listener for a particular service type. This object From 8da00caf31e007153e10a8038a0a484edea03c2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 13:25:11 -1000 Subject: [PATCH 0242/1433] ServiceBrowser must recheck for handlers to call when holding condition (#477) --- zeroconf/__init__.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9bb488b5f..a842e806a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1785,17 +1785,33 @@ def cancel(self) -> None: super().cancel() self.join() + def _wait_for_next_event(self) -> None: + """Wait for the next handler or time to send queries.""" + # If there are handlers to call + # we want to process them right away + if self._handlers_to_call: + return + + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + + if next_time <= now: + return + + with self.zc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + self.zc.condition.wait(millis_to_seconds(next_time - now)) + def run(self) -> None: """Run the browser thread.""" super().run() while True: - if not self._handlers_to_call: - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - now = current_time_millis() - if next_time > now: - self.zc.wait(next_time - now) - + self._wait_for_next_event() if self.zc.done or self.done: return From 393910b67ac667a660ee9351cc8f94310937f654 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 13:30:10 -1000 Subject: [PATCH 0243/1433] Switch from using an asyncio.Event to asyncio.Condition for waiting (#482) --- zeroconf/asyncio.py | 56 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 1f159c924..0227949ea 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -55,6 +55,35 @@ def _get_best_available_queue() -> queue.Queue: return queue.Queue() +# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed +async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: + """Wait for a condition or timeout.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + + def _handle_timeout() -> None: + if not future.done(): + future.set_result(None) + + timer_handle = loop.call_later(timeout, _handle_timeout) + condition_wait = loop.create_task(condition.wait()) + + def _handle_wait_complete(_: asyncio.Task) -> None: + if not future.done(): + future.set_result(None) + + condition_wait.add_done_callback(_handle_wait_complete) + + try: + await future + finally: + timer_handle.cancel() + if not condition_wait.done(): + condition_wait.cancel() + with contextlib.suppress(asyncio.CancelledError): + await condition_wait + + class _AsyncSender(threading.Thread): """A thread to handle sending DNSOutgoing for asyncio.""" @@ -87,19 +116,19 @@ def run(self) -> None: class AsyncNotifyListener(NotifyListener): """A NotifyListener that async code can use to wait for events.""" - def __init__(self) -> None: + def __init__(self, aiozc: 'AsyncZeroconf') -> None: """Create an event for async listeners to wait for.""" - self.event = asyncio.Event() + self.aiozc = aiozc self.loop = asyncio.get_event_loop() def notify_all(self) -> None: """Schedule an async_notify_all.""" - self.loop.call_soon_threadsafe(self.async_notify_all) + self.loop.call_soon_threadsafe(asyncio.ensure_future, self._async_notify_all()) - def async_notify_all(self) -> None: + async def _async_notify_all(self) -> None: """Notify all async listeners.""" - self.event.set() - self.event.clear() + async with self.aiozc.condition: + self.aiozc.condition.notify_all() class AsyncServiceListener: @@ -169,10 +198,12 @@ def __init__( super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore self._browser_task = asyncio.ensure_future(self.async_run()) - def cancel(self) -> None: + async def async_cancel(self) -> None: """Cancel the browser.""" - super().cancel() + self.cancel() self._browser_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._browser_task async def async_run(self) -> None: """Run the browser task.""" @@ -240,10 +271,11 @@ def __init__( apple_p2p=apple_p2p, ) self.loop = asyncio.get_event_loop() - self.async_notify = AsyncNotifyListener() + self.async_notify = AsyncNotifyListener(self) self.zeroconf.add_notify_listener(self.async_notify) self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} self.sender = _AsyncSender(self.zeroconf) + self.condition = asyncio.Condition() async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: """Send a broadcasts to announce a service at intervals.""" @@ -333,8 +365,8 @@ async def async_get_service_info( async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.async_notify.event.wait(), millis_to_seconds(timeout)) + async with self.condition: + await wait_condition_or_timeout(self.condition, millis_to_seconds(timeout)) async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: """Adds a listener for a particular service type. This object @@ -346,7 +378,7 @@ async def async_add_service_listener(self, type_: str, listener: AsyncServiceLis async def async_remove_service_listener(self, listener: AsyncServiceListener) -> None: """Removes a listener from the set that is currently listening.""" if listener in self.async_browsers: - self.async_browsers[listener].cancel() + await self.async_browsers[listener].async_cancel() del self.async_browsers[listener] async def async_remove_all_service_listeners(self) -> None: From 9c06ce15db31ebffe3a556896393d48cb786b5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 14:04:36 -1000 Subject: [PATCH 0244/1433] Relocate ServiceBrowser wait time calculation to seperate function (#484) - Eliminate the need to duplicate code between the ServiceBrowser and AsyncServiceBrowser to calculate the wait time. --- zeroconf/__init__.py | 49 +++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a842e806a..86b828d36 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1754,6 +1754,22 @@ def generate_ready_queries(self) -> Optional[DNSOutgoing]: self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) return out + def _seconds_to_wait(self) -> Optional[float]: + """Returns the number of seconds to wait for the next event.""" + # If there are handlers to call + # we want to process them right away + if self._handlers_to_call: + return None + + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + + if next_time <= now: + return None + + return millis_to_seconds(next_time - now) + class ServiceBrowser(_ServiceBrowserBase, threading.Thread): """Used to browse for a service of a specific type. @@ -1785,33 +1801,20 @@ def cancel(self) -> None: super().cancel() self.join() - def _wait_for_next_event(self) -> None: - """Wait for the next handler or time to send queries.""" - # If there are handlers to call - # we want to process them right away - if self._handlers_to_call: - return - - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - now = current_time_millis() - - if next_time <= now: - return - - with self.zc.condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - self.zc.condition.wait(millis_to_seconds(next_time - now)) - def run(self) -> None: """Run the browser thread.""" super().run() while True: - self._wait_for_next_event() + timeout = self._seconds_to_wait() + if timeout: + with self.zc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + self.zc.condition.wait(timeout) + if self.zc.done or self.done: return From 960693628006e23fd13fcaefef915ca0c84401b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 14:14:17 -1000 Subject: [PATCH 0245/1433] AsyncServiceBrowser must recheck for handlers to call when holding condition (#483) - There was a short race condition window where the AsyncServiceBrowser could add to _handlers_to_call in the Engine thread, have the condition notify_all called, but since the AsyncServiceBrowser was not yet holding the condition it would not know to stop waiting and process the handlers to call. --- zeroconf/asyncio.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 0227949ea..611197474 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -209,12 +209,15 @@ async def async_run(self) -> None: """Run the browser task.""" self.run() while True: - if not self._handlers_to_call: - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - now = current_time_millis() - if next_time > now: - await self.aiozc.async_wait(next_time - now) + timeout = self._seconds_to_wait() + if timeout: + async with self.aiozc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + await wait_condition_or_timeout(self.aiozc.condition, timeout) out = self.generate_ready_queries() if out: From 0a69aa0d37e13cb2c65ceb5cc3ab0fd7e9d34b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 14:18:46 -1000 Subject: [PATCH 0246/1433] RecordUpdateListener now uses update_records instead of update_record (#419) --- zeroconf/__init__.py | 159 ++++++++++++++++++++++++++------------- zeroconf/test.py | 54 +++++++++++++ zeroconf/test_asyncio.py | 7 +- 3 files changed, 167 insertions(+), 53 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 86b828d36..b010c9231 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,8 +35,9 @@ import time import warnings from collections import OrderedDict +from contextlib import contextmanager from types import TracebackType # noqa # used in type hints -from typing import Dict, Iterable, List, Optional, Type, Union, cast +from typing import Dict, Generator, Iterable, List, Optional, Type, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -1424,8 +1425,8 @@ def run(self) -> None: now = current_time_millis() if now - self._last_cache_cleanup >= self.cache_cleanup_interval_ms: self._last_cache_cleanup = now - for record in self.zc.cache.expire(now): - self.zc.update_record(now, record) + with self.zc.update_records(now, list(self.zc.cache.expire(now))): + pass self.socketpair[0].close() self.socketpair[1].close() @@ -1548,8 +1549,37 @@ def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistratio class RecordUpdateListener: - def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: - raise NotImplementedError() + def update_record( # pylint: disable=no-self-use + self, zc: 'Zeroconf', now: float, record: DNSRecord + ) -> None: + """Update a single record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + """ + raise RuntimeError("update_record is deprecated and will be removed in a future version.") + + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Update multiple records in one shot. + + All records that are received in a single packet are passed + to update_records. + + This implementation is a compatiblity shim to ensure older code + that uses RecordUpdateListener as a base class will continue to + get calls to update_record. This method will raise + NotImplementedError in a future version. + + At this point the cache will not have the new records + """ + for record in records: + self.update_record(zc, now, record) + + def update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + """ class ServiceListener: @@ -1601,6 +1631,7 @@ def __init__( current_time = current_time_millis() self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} + self._pending_handlers = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] self._handlers_to_call = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] self._service_state_changed = Signal() @@ -1649,30 +1680,32 @@ def _record_matching_type(self, record: DNSRecord) -> Optional[str]: """Return the type if the record matches one of the types we are browsing.""" return next((type_ for type_ in self.types if record.name.endswith(type_)), None) - def update_record(self, zc: 'Zeroconf', now: float, record: DNSRecord) -> None: - """Callback invoked by Zeroconf when new information arrives. - - Updates information required by browser in the Zeroconf cache. - - Ensures that there is are no unecessary duplicates in the list - - """ - - def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> None: - - # Code to ensure we only do a single update message - # Precedence is; Added, Remove, Update - key = (name, type_) - if ( - state_change is ServiceStateChange.Added - or ( - state_change is ServiceStateChange.Removed - and self._handlers_to_call.get(key) != ServiceStateChange.Added - ) - or (state_change is ServiceStateChange.Updated and key not in self._handlers_to_call) - ): - self._handlers_to_call[key] = state_change + def _enqueue_callback( + self, + state_change: ServiceStateChange, + type_: str, + name: str, + ) -> None: + # Code to ensure we only do a single update message + # Precedence is; Added, Remove, Update + key = (name, type_) + if ( + state_change is ServiceStateChange.Added + or ( + state_change is ServiceStateChange.Removed + and self._pending_handlers.get(key) != ServiceStateChange.Added + ) + or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) + ): + self._pending_handlers[key] = state_change + def _process_record_update( + self, + zc: 'Zeroconf', + now: float, + record: DNSRecord, + ) -> None: + """Process a single record update from a batch of updates.""" expired = record.is_expired(now) if isinstance(record, DNSPointer): @@ -1683,10 +1716,10 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> old_record = services_by_type.get(service_key) if old_record is None: services_by_type[service_key] = record - enqueue_callback(ServiceStateChange.Added, record.name, record.alias) + self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) elif expired: del services_by_type[service_key] - enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) + self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: old_record.reset_ttl(record) expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) @@ -1711,14 +1744,32 @@ def enqueue_callback(state_change: ServiceStateChange, type_: str, name: str) -> for service in self.zc.cache.entries_with_server(record.name): type_ = self._record_matching_type(service) if type_: - enqueue_callback(ServiceStateChange.Updated, type_, service.name) + self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) break return type_ = self._record_matching_type(record) if type_: - enqueue_callback(ServiceStateChange.Updated, type_, record.name) + self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) + + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Callback invoked by Zeroconf when new information arrives. + + Updates information required by browser in the Zeroconf cache. + + Ensures that there is are no unecessary duplicates in the list. + """ + for record in records: + self._process_record_update(zc, now, record) + + def update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + """ + self._handlers_to_call.update(self._pending_handlers) + self._pending_handlers.clear() def cancel(self) -> None: """Cancel the browser.""" @@ -1825,9 +1876,7 @@ def run(self) -> None: if not self._handlers_to_call: continue - with self.zc._handlers_lock: # pylint: disable=protected-access - (name_type, state_change) = self._handlers_to_call.popitem(False) - + (name_type, state_change) = self._handlers_to_call.popitem(False) self._service_state_changed.fire( zeroconf=self.zc, service_type=name_type[1], @@ -2689,11 +2738,6 @@ def __init__( self.condition = threading.Condition() - # Ensure we create the lock before - # we add the listener as we could get - # a message before the lock is created. - self._handlers_lock = threading.Lock() # ensure we process a full message in one go - self.engine = Engine(self) self.listener = Listener(self) if not unicast: @@ -2902,12 +2946,17 @@ def add_listener( answer the question(s).""" now = current_time_millis() self.listeners.append(listener) + records = [] if question is not None: questions = [question] if isinstance(question, DNSQuestion) else question for single_question in questions: for record in self.cache.entries_with_name(single_question.name): if single_question.answered_by(record) and not record.is_expired(now): - listener.update_record(self, now, record) + records.append(record) + + if records: + listener.update_records(self, now, records) + listener.update_records_complete() self.notify_all() def remove_listener(self, listener: RecordUpdateListener) -> None: @@ -2918,14 +2967,23 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions log.exception('Unknown error, possibly benign: %r', e) - def update_record(self, now: float, rec: DNSRecord) -> None: + @contextmanager + def update_records(self, now: float, rec: List[DNSRecord]) -> Generator: """Used to notify listeners of new information that has updated - a record.""" - for listener in self.listeners: - listener.update_record(self, now, rec) - self.notify_all() + a record. + + This method must be called before the cache is updated. + """ + try: + for listener in self.listeners: + listener.update_records(self, now, rec) + yield + finally: + for listener in self.listeners: + listener.update_records_complete() + self.notify_all() - def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many-branches + def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" updates = [] # type: List[DNSRecord] @@ -2967,10 +3025,7 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many if not updates and not address_adds and not other_adds and not removes: return - # Only hold the lock if we have updates - with self._handlers_lock: - for record in updates: - self.update_record(now, record) + with self.update_records(now, updates): # The cache adds must be processed AFTER we trigger # the updates since we compare existing data # with the new data and updating the cache @@ -2981,7 +3036,7 @@ def handle_response(self, msg: DNSIncoming) -> None: # pylint: disable=too-many # otherwise a fetch of ServiceInfo may miss an address # because it thinks the cache is complete # - # The cache is processed under the lock to ensure + # The cache is processed under the context manager to ensure # that any ServiceBrowser that is going to call # zc.get_service_info will see the cached value # but ONLY after all the record updates have been diff --git a/zeroconf/test.py b/zeroconf/test.py index 02195240f..27e03b538 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2552,3 +2552,57 @@ def on_service_state_change(zeroconf, service_type, state_change, name): assert not notify_called zc.close() + + +def test_legacy_record_update_listener(): + """Test a RecordUpdateListener that does not implement update_records.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + with pytest.raises(RuntimeError): + r.RecordUpdateListener().update_record( + zc, 0, r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + ) + + updates = [] + + class LegacyRecordUpdateListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: + nonlocal updates + updates.append(record) + + zc.add_listener(LegacyRecordUpdateListener(), None) + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + zc.register_service(info_service) + + zc.wait(1) + + browser.cancel() + + assert len(updates) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + + zc.close() diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index e6bcf5a28..ddf3fbee6 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -435,9 +435,14 @@ def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: await task task = await aiozc.async_unregister_service(new_info) await task + await aiozc.async_wait(1) await aiozc.async_close() - assert calls[0] == ('add', type_, registration_name) + assert calls == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] @pytest.mark.asyncio From 49db96dae466a602662f4fde1537f62a8c8d3110 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 14:38:38 -1000 Subject: [PATCH 0247/1433] Enable test_integration_with_listener_class test on PyPy (#485) --- zeroconf/test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 27e03b538..64175e4fb 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -9,7 +9,6 @@ import itertools import logging import os -import platform import socket import struct import threading @@ -1263,7 +1262,6 @@ def test_integration_with_subtype_and_listener(self): class ListenerTest(unittest.TestCase): - @pytest.mark.skipif(platform.python_implementation() == 'PyPy', reason="Flaky on PyPy") def test_integration_with_listener_class(self): service_added = Event() From 275765a4fd3b477b79163c04f6411709e14506b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 15:28:44 -1000 Subject: [PATCH 0248/1433] Move threading daemon property into ServiceBrowser class (#486) --- zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b010c9231..6bd797d1b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1620,7 +1620,6 @@ def __init__( for check_type_ in self.types: if not check_type_.endswith(service_type_name(check_type_, strict=False)): raise BadTypeInNameException - self.daemon = True self.zc = zc self.addr = addr self.port = port @@ -1841,6 +1840,7 @@ def __init__( ) -> None: threading.Thread.__init__(self) super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + self.daemon = True self.start() self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), From ef9334f1279d029752186bc6f4a1ebff6229bf5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 16:34:14 -1000 Subject: [PATCH 0249/1433] Add AsyncServiceBrowser example (#487) --- examples/async_browser.py | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 examples/async_browser.py diff --git a/examples/async_browser.py b/examples/async_browser.py new file mode 100644 index 000000000..b2a1916d6 --- /dev/null +++ b/examples/async_browser.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +""" Example of browsing for a service. + +The default is HTTP and HAP; use --find to search for all available services in the network +""" + +import argparse +import asyncio +import logging +from typing import cast + +from zeroconf import IPVersion, ServiceStateChange +from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf + + +def async_on_service_state_change( + zeroconf: AsyncZeroconf, service_type: str, name: str, state_change: ServiceStateChange +) -> None: + print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) + if state_change is not ServiceStateChange.Added: + return + asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) + + +async def async_display_service_info(zeroconf: AsyncZeroconf, service_type: str, name: str) -> None: + info = await zeroconf.async_get_service_info(service_type, name) + print("Info from zeroconf.get_service_info: %r" % (info)) + if info: + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + print(" Name: %s" % name) + print(" Addresses: %s" % ", ".join(addresses)) + print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + print(" Server: %s" % (info.server,)) + if info.properties: + print(" Properties are:") + for key, value in info.properties.items(): + print(" %s: %s" % (key, value)) + else: + print(" No properties") + else: + print(" No info") + print('\n') + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IPVersion.All + elif args.v6_only: + ip_version = IPVersion.V6Only + else: + ip_version = IPVersion.V4Only + + aiozc = AsyncZeroconf(ip_version=ip_version) + + services = ["_http._tcp.local.", "_hap._tcp.local."] + print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) + aiobrowser = AsyncServiceBrowser(aiozc, services, handlers=[async_on_service_state_change]) + + loop = asyncio.get_event_loop() + try: + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + loop.run_until_complete(aiobrowser.async_cancel()) + loop.run_until_complete(aiozc.async_close()) From 69880ae6ca4d4f0a7d476b0271b89adea92b9389 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 16:42:57 -1000 Subject: [PATCH 0250/1433] Lint before testing in the CI (#488) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 37d4bff6e..b766fcf7a 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ env: cp requirements-dev.txt ./env/requirements.built .PHONY: ci -ci: test_coverage lint +ci: lint test_coverage .PHONY: lint lint: $(LINT_TARGETS) From f0c02a02c1a2d7c914c62479bad4957b06471661 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 17:29:31 -1000 Subject: [PATCH 0251/1433] Remove unused __ne__ code from Python 2 era (#492) --- zeroconf/__init__.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6bd797d1b..9560e6490 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -455,10 +455,6 @@ def __eq__(self, other: Any) -> bool: and isinstance(other, DNSEntry) ) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - @staticmethod def get_class_(class_: int) -> str: """Class accessor""" @@ -520,10 +516,6 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" @@ -591,10 +583,6 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address ) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" try: @@ -630,10 +618,6 @@ def __eq__(self, other: Any) -> bool: and self.os == other.os ) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" return self.to_string(self.cpu + " " + self.os) @@ -655,10 +639,6 @@ def __eq__(self, other: Any) -> bool: """Tests equality on alias""" return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" return self.to_string(self.alias) @@ -681,10 +661,6 @@ def __eq__(self, other: Any) -> bool: """Tests equality on text""" return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" if len(self.text) > 10: @@ -731,10 +707,6 @@ def __eq__(self, other: Any) -> bool: and DNSEntry.__eq__(self, other) ) - def __ne__(self, other: Any) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" return self.to_string("%s:%s" % (self.server, self.port)) @@ -2227,10 +2199,6 @@ def __eq__(self, other: object) -> bool: """Tests equality of service name""" return isinstance(other, ServiceInfo) and other.name == self.name - def __ne__(self, other: object) -> bool: - """Non-equality test""" - return not self.__eq__(other) - def __repr__(self) -> str: """String representation""" return '%s(%s)' % ( From 20f8b3d6fb8d117b0c3c794c4075a00e117e3f31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 17:29:41 -1000 Subject: [PATCH 0252/1433] Update internal version check to match docs (3.6+) (#491) --- zeroconf/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9560e6490..5efdf979b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -60,11 +60,12 @@ "IPVersion", ] -if sys.version_info <= (3, 4): +if sys.version_info <= (3, 6): raise ImportError( ''' -Python version > 3.4 required for python-zeroconf. +Python version > 3.6 required for python-zeroconf. If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 +If you need support for Python 3.5 please use version 0.28.0 ''' ) From 38e4b42b847e700db52bc51973210efc485d8c23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 20:00:23 -1000 Subject: [PATCH 0253/1433] Make a base class for DNSIncoming and DNSOutgoing (#497) --- zeroconf/__init__.py | 32 ++++++++++++++++++++------------ zeroconf/test.py | 2 ++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5efdf979b..dcf00dc9c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -713,18 +713,34 @@ def __repr__(self) -> str: return self.to_string("%s:%s" % (self.server, self.port)) -class DNSIncoming(QuietLogger): +class DNSMessage: + """A base class for DNS messages.""" + + def __init__(self, flags: int) -> None: + """Construct a DNS message.""" + self.flags = flags + + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + +class DNSIncoming(DNSMessage, QuietLogger): """Object representation of an incoming DNS packet""" def __init__(self, data: bytes) -> None: """Constructor from string holding bytes of packet""" + super().__init__(0) self.offset = 0 self.data = data self.questions = [] # type: List[DNSQuestion] self.answers = [] # type: List[DNSRecord] self.id = 0 - self.flags = 0 # type: int self.num_questions = 0 self.num_answers = 0 self.num_authorities = 0 @@ -846,14 +862,6 @@ def read_others(self) -> None: if rec is not None: self.answers.append(rec) - def is_query(self) -> bool: - """Returns true if this is a query""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def is_response(self) -> bool: - """Returns true if this is a response""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - def read_utf(self, offset: int, length: int) -> str: """Reads a UTF-8 string of a given length from the packet""" return str(self.data[offset : offset + length], 'utf-8', 'replace') @@ -892,15 +900,15 @@ def read_name(self) -> str: return result -class DNSOutgoing: +class DNSOutgoing(DNSMessage): """Object representation of an outgoing packet""" def __init__(self, flags: int, multicast: bool = True) -> None: + super().__init__(flags) self.finished = False self.id = 0 self.multicast = multicast - self.flags = flags self.packets_data = [] # type: List[bytes] # these 3 are per-packet -- see also reset_for_next_packet() diff --git a/zeroconf/test.py b/zeroconf/test.py index 64175e4fb..31a4d851c 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -936,6 +936,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): # query query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + assert query.is_query() is True query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, r._TYPE_SRV, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) @@ -1463,6 +1464,7 @@ def update_service(self, zc, type_, name) -> None: def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + assert generated.is_response() is True if service_state_change == r.ServiceStateChange.Removed: ttl = 0 From e2908c6c89802ba7a0ea51ac351da40bce3f1cb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 21:28:02 -1000 Subject: [PATCH 0254/1433] Ensure packets are properly seperated when exceeding maximum size (#498) - Ensure that questions that exceed the max packet size are moved to the next packet. This fixes DNSQuestions being sent in multiple packets in violation of: https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - Ensure only one resource record is sent when a record exceeds _MAX_MSG_TYPICAL https://datatracker.ietf.org/doc/html/rfc6762#section-17 --- zeroconf/__init__.py | 124 ++++++++++++++++++++++++++++++------------- zeroconf/test.py | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 37 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dcf00dc9c..bd5c17f24 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -915,6 +915,7 @@ def __init__(self, flags: int, multicast: bool = True) -> None: self.names = {} # type: Dict[str, int] self.data = [] # type: List[bytes] self.size = 12 + self.allow_long = True self.state = self.State.init @@ -927,6 +928,7 @@ def reset_for_next_packet(self) -> None: self.names = {} self.data = [] self.size = 12 + self.allow_long = True def __repr__(self) -> str: return '' % ', '.join( @@ -1118,13 +1120,15 @@ def write_name(self, name: str) -> None: # this is the end of a name self.write_byte(0) - def write_question(self, question: DNSQuestion) -> None: + def write_question(self, question: DNSQuestion) -> bool: """Writes a question to the packet""" + start_data_length, start_size = len(self.data), self.size self.write_name(question.name) self.write_short(question.type) self.write_short(question.class_) + return self._check_data_limit_or_rollback(start_data_length, start_size) - def write_record(self, record: DNSRecord, now: float, allow_long: bool = False) -> bool: + def write_record(self, record: DNSRecord, now: float) -> bool: """Writes a record (answer, authoritative answer, additional) to the packet. Returns True on success, or False if we did not (either because the packet was already finished or because the record does @@ -1152,19 +1156,26 @@ def write_record(self, record: DNSRecord, now: float, allow_long: bool = False) # Here we replace the 0 length short we wrote # before with the actual length self.replace_short(index, length) - len_limit = _MAX_MSG_ABSOLUTE if allow_long else _MAX_MSG_TYPICAL + return self._check_data_limit_or_rollback(start_data_length, start_size) - # if we go over, then rollback and quit - if self.size > len_limit: - while len(self.data) > start_data_length: - self.data.pop() - self.size = start_size + def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: + """Check data limit, if we go over, then rollback and return False.""" + len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL + self.allow_long = False - rollback_names = [name for name, idx in self.names.items() if idx >= start_size] - for name in rollback_names: - del self.names[name] - return False - return True + if self.size <= len_limit: + return True + + log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) + + while len(self.data) > start_data_length: + self.data.pop() + self.size = start_size + + rollback_names = [name for name, idx in self.names.items() if idx >= start_size] + for name in rollback_names: + del self.names[name] + return False def packet(self) -> bytes: """Returns a bytestring containing the first packet's bytes. @@ -1181,6 +1192,38 @@ def packet(self) -> bytes: ) return packets[0] + def _write_questions_from_offset(self, questions_offset: int) -> int: + questions_written = 0 + for question in self.questions[questions_offset:]: + if not self.write_question(question): + break + questions_written += 1 + return questions_written + + def _write_answers_from_offset(self, answer_offset: int) -> int: + answers_written = 0 + for answer, time_ in self.answers[answer_offset:]: + if not self.write_record(answer, time_): + break + answers_written += 1 + return answers_written + + def _write_authorities_from_offset(self, authority_offset: int) -> int: + authorities_written = 0 + for authority in self.authorities[authority_offset:]: + if not self.write_record(authority, 0): + break + authorities_written += 1 + return authorities_written + + def _write_additionals_from_offset(self, additional_offset: int) -> int: + additionals_written = 0 + for additional in self.additionals[additional_offset:]: + if not self.write_record(additional, 0): + break + additionals_written += 1 + return additionals_written + def packets(self) -> List[bytes]: """Returns a list of bytestrings containing the packets' bytes @@ -1194,6 +1237,7 @@ def packets(self) -> List[bytes]: if self.state == self.State.finished: return self.packets_data + questions_offset = 0 answer_offset = 0 authority_offset = 0 additional_offset = 0 @@ -1203,32 +1247,31 @@ def packets(self) -> List[bytes]: while ( first_time + or questions_offset < len(self.questions) or answer_offset < len(self.answers) or authority_offset < len(self.authorities) or additional_offset < len(self.additionals) ): first_time = False - log.debug("offsets = %d, %d, %d", answer_offset, authority_offset, additional_offset) - log.debug("lengths = %d, %d, %d", len(self.answers), len(self.authorities), len(self.additionals)) - - additionals_written = 0 - authorities_written = 0 - answers_written = 0 - questions_written = 0 - for question in self.questions: - self.write_question(question) - questions_written += 1 - allow_long = True # at most one answer is allowed to be a long packet - for answer, time_ in self.answers[answer_offset:]: - if self.write_record(answer, time_, allow_long): - answers_written += 1 - allow_long = False - for authority in self.authorities[authority_offset:]: - if self.write_record(authority, 0): - authorities_written += 1 - for additional in self.additionals[additional_offset:]: - if self.write_record(additional, 0): - additionals_written += 1 + log.debug( + "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + log.debug( + "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", + len(self.questions), + len(self.answers), + len(self.authorities), + len(self.additionals), + ) + + questions_written = self._write_questions_from_offset(questions_offset) + answers_written = self._write_answers_from_offset(answer_offset) + authorities_written = self._write_authorities_from_offset(authority_offset) + additionals_written = self._write_additionals_from_offset(additional_offset) self.insert_short_at_start(additionals_written) self.insert_short_at_start(authorities_written) @@ -1242,12 +1285,19 @@ def packets(self) -> List[bytes]: self.packets_data.append(b''.join(self.data)) self.reset_for_next_packet() + questions_offset += questions_written answer_offset += answers_written authority_offset += authorities_written additional_offset += additionals_written - log.debug("now offsets = %d, %d, %d", answer_offset, authority_offset, additional_offset) - if (answers_written + authorities_written + additionals_written) == 0 and ( - len(self.answers) + len(self.authorities) + len(self.additionals) + log.debug( + "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( + len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) ) > 0: log.warning("packets() made no progress adding records; returning") break diff --git a/zeroconf/test.py b/zeroconf/test.py index 31a4d851c..b0791a340 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -313,6 +313,118 @@ def test_dns_hinfo(self): generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) self.assertRaises(r.NamePartTooLongException, generated.packet) + def test_many_questions(self): + """Test many questions get seperated into multiple packets.""" + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + questions = [] + for i in range(100): + question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 100 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) < r._MAX_MSG_TYPICAL + assert len(packets[1]) < r._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 85 + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 15 + + def test_only_one_answer_can_by_large(self): + """Test that only the first answer in each packet can be large. + + https://datatracker.ietf.org/doc/html/rfc6762#section-17 + """ + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + query = r.DNSIncoming(r.DNSOutgoing(r._FLAGS_QR_QUERY).packet()) + for i in range(3): + generated.add_answer( + query, + r.DNSText( + "zoom._hap._tcp.local.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + 1200, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, + ), + ) + generated.add_answer( + query, + r.DNSService( + "testname1.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + ) + assert len(generated.answers) == 4 + + packets = generated.packets() + assert len(packets) == 4 + assert len(packets[0]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[0]) > r._MAX_MSG_TYPICAL + + assert len(packets[1]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[1]) > r._MAX_MSG_TYPICAL + + assert len(packets[2]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[2]) > r._MAX_MSG_TYPICAL + + assert len(packets[3]) <= r._MAX_MSG_TYPICAL + + for packet in packets: + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + def test_questions_do_not_end_up_every_packet(self): + """Test that questions are not sent again when multiple packets are needed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + Sometimes a Multicast DNS querier will already have too many answers + to fit in the Known-Answer Section of its query packets.... It MUST + immediately follow the packet with another query packet containing no + questions and as many more Known-Answer records as will fit. + """ + + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + for i in range(35): + question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + answer = r.DNSService( + f"testname{i}.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + f"foo{i}.local.", + ) + generated.add_answer_at_time(answer, 0) + + assert len(generated.questions) == 35 + assert len(generated.answers) == 35 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) <= r._MAX_MSG_TYPICAL + assert len(packets[1]) <= r._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 35 + assert len(parsed1.answers) == 33 + + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert len(parsed2.answers) == 2 + class PacketForm(unittest.TestCase): def test_transaction_id(self): From f04a2eb43745eba7c43c9c56179ed1fceb992bd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jun 2021 21:45:57 -1000 Subject: [PATCH 0255/1433] Set the TC bit for query packets where the known answers span multiple packets (#494) --- zeroconf/__init__.py | 45 ++++++++++++++++++++--------- zeroconf/test.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index bd5c17f24..c362d16be 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1224,6 +1224,17 @@ def _write_additionals_from_offset(self, additional_offset: int) -> int: additionals_written += 1 return additionals_written + def _has_more_to_add( + self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int + ) -> bool: + """Check if all questions, answers, authority, and additionals have been written to the packet.""" + return ( + questions_offset < len(self.questions) + or answer_offset < len(self.answers) + or authority_offset < len(self.authorities) + or additional_offset < len(self.additionals) + ) + def packets(self) -> List[bytes]: """Returns a list of bytestrings containing the packets' bytes @@ -1241,16 +1252,11 @@ def packets(self) -> List[bytes]: answer_offset = 0 authority_offset = 0 additional_offset = 0 - # we have to at least write out the question first_time = True - while ( - first_time - or questions_offset < len(self.questions) - or answer_offset < len(self.answers) - or authority_offset < len(self.authorities) - or additional_offset < len(self.additionals) + while first_time or self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset ): first_time = False log.debug( @@ -1277,13 +1283,6 @@ def packets(self) -> List[bytes]: self.insert_short_at_start(authorities_written) self.insert_short_at_start(answers_written) self.insert_short_at_start(questions_written) - self.insert_short_at_start(self.flags) - if self.multicast: - self.insert_short_at_start(0) - else: - self.insert_short_at_start(self.id) - self.packets_data.append(b''.join(self.data)) - self.reset_for_next_packet() questions_offset += questions_written answer_offset += answers_written @@ -1296,6 +1295,24 @@ def packets(self) -> List[bytes]: authority_offset, additional_offset, ) + + if self.is_query() and self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset + ): + # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + log.debug("Setting TC flag") + self.insert_short_at_start(self.flags | _FLAGS_TC) + else: + self.insert_short_at_start(self.flags) + + if self.multicast: + self.insert_short_at_start(0) + else: + self.insert_short_at_start(self.id) + + self.packets_data.append(b''.join(self.data)) + self.reset_for_next_packet() + if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) ) > 0: diff --git a/zeroconf/test.py b/zeroconf/test.py index b0791a340..060b80a06 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2613,6 +2613,73 @@ def test_dns_compression_rollback_for_corruption(): assert incoming.valid is True +def test_tc_bit_in_query_packet(): + """Verify the TC bit is set when known answers exceed the packet size.""" + out = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + type_ = "_hap._tcp.local." + out.add_question(r.DNSQuestion(type_, r._TYPE_PTR, r._CLASS_IN)) + + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert first_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert second_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.valid is True + + +def test_tc_bit_not_set_in_answer_packet(): + """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" + out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert first_packet.flags & r._FLAGS_TC == 0 + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert second_packet.flags & r._FLAGS_TC == 0 + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.valid is True + + @pytest.mark.parametrize( "errno,expected_result", [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], From 9b480bc1abb2c2702f60796f2edae76ce03ca5d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jun 2021 08:27:36 -1000 Subject: [PATCH 0256/1433] Update changelog, move breaking changes to the top of the list (#501) --- README.rst | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 10472ba69..10b25f143 100644 --- a/README.rst +++ b/README.rst @@ -137,6 +137,152 @@ Changelog 0.32.0 (Unreleased) =================== +* Breaking change: Update internal version check to match docs (3.6+) (#491) @bdraco + + Python version eariler then 3.6 were likely broken with zeroconf + already, however the version is now explictly checked. + +* Breaking change: RecordUpdateListener now uses update_records instead of update_record (#419) @bdraco + + This allows the listener to receive all the records that have + been updated in a single transaction such as a packet or + cache expiry. + + update_record has been deprecated in favor of update_records + A compatibility shim exists to ensure classes that use + RecordUpdateListener as a base class continue to have + update_record called, however they should be updated + as soon as possible. + + A new method update_records_complete is now called on each + listener when all listeners have completed processing updates + and the cache has been updated. This allows ServiceBrowsers + to delay calling handlers until they are sure the cache + has been updated as its a common pattern to call for + ServiceInfo when a ServiceBrowser handler fires. + +* Breaking change: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco + + When manually creating a zeroconf.Engine object, it is no longer started automatically. + It must manually be started by calling .start() on the created object. + + The Engine thread is now started after all the listeners have been added to avoid a + race condition where packets could be missed at startup. + +* Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco + +* Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco + + Ensure that questions that exceed the max packet size are + moved to the next packet. This fixes DNSQuestions being + sent in multiple packets in violation of: + datatracker.ietf.org/doc/html/rfc6762#section-7.2 + + Ensure only one resource record is sent when a record + exceeds _MAX_MSG_TYPICAL + datatracker.ietf.org/doc/html/rfc6762#section-17 + +* Make a base class for DNSIncoming and DNSOutgoing (#497) @bdraco + +* Remove unused __ne__ code from Python 2 era (#492) @bdraco + +* Lint before testing in the CI (#488) @bdraco + +* Add AsyncServiceBrowser example (#487) @bdraco + +* Move threading daemon property into ServiceBrowser class (#486) @bdraco + +* Enable test_integration_with_listener_class test on PyPy (#485) @bdraco + +* AsyncServiceBrowser must recheck for handlers to call when holding condition (#483) + + There was a short race condition window where the AsyncServiceBrowser + could add to _handlers_to_call in the Engine thread, have the + condition notify_all called, but since the AsyncServiceBrowser was + not yet holding the condition it would not know to stop waiting + and process the handlers to call. + +* Relocate ServiceBrowser wait time calculation to seperate function (#484) @bdraco + + Eliminate the need to duplicate code between the ServiceBrowser + and AsyncServiceBrowser to calculate the wait time. + +* Switch from using an asyncio.Event to asyncio.Condition for waiting (#482) @bdraco + +* ServiceBrowser must recheck for handlers to call when holding condition (#477) @bdraco + + There was a short race condition window where the ServiceBrowser + could add to _handlers_to_call in the Engine thread, have the + condition notify_all called, but since the ServiceBrowser was + not yet holding the condition it would not know to stop waiting + and process the handlers to call. + +* Provide a helper function to convert milliseconds to seconds (#481) @bdraco + +* Fix AsyncServiceInfo.async_request not waiting long enough (#480) @bdraco + +* Add support for updating multiple records at once to ServiceInfo (#474) @bdraco + +* Narrow exception catch in DNSAddress.__repr__ to only expected exceptions (#473) @bdraco + +* Add test coverage to ensure ServiceInfo rejects expired records (#468) @bdraco + +* Reduce branching in service_type_name (#472) @bdraco + +* Fix flakey test_update_record (#470) @bdraco + +* Reduce branching in Zeroconf.handle_response (#467) @bdraco + +* Ensure PTR questions asked in uppercase are answered (#465) @bdraco + +* Clear cache between ServiceTypesQuery tests (#466) @bdraco + +* Break apart Zeroconf.handle_query to reduce branching (#462) @bdraco + +* Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek + +* Use constant for service type enumeration (#461) @bdraco + +* Reduce branching in Zeroconf.handle_response (#459) @bdraco + +* Reduce branching in Zeroconf.handle_query (#460) @bdraco + +* Enable pylint (#438) @bdraco + +* Trap OSError directly in Zeroconf.send instead of checking isinstance (#453) @bdraco + +* Disable protected-access on the ServiceBrowser usage of _handlers_lock (#452) @bdraco + +* Mark functions with too many branches in need of refactoring (#455) @bdraco + +* Disable pylint no-self-use check on abstract methods (#451) @bdraco + +* Use unique name in test_async_service_browser test (#450) @bdraco + +* Disable no-member check for WSAEINVAL false positive (#454) @bdraco + +* Mark methods used by asyncio without self use (#447) @bdraco + +* Extract _get_queue from zeroconf.asyncio._AsyncSender (#444) @bdraco + +* Fix redefining argument with the local name 'record' in ServiceInfo.update_record (#448) @bdraco + +* Remove unneeded-not in new_socket (#445) @bdraco + +* Disable broad except checks in places we still catch broad exceptions (#443) @bdraco + +* Merge _TYPE_CNAME and _TYPE_PTR comparison in DNSIncoming.read_others (#442) @bdraco + +* Convert unnecessary use of a comprehension to a list (#441) @bdraco + +* Remove unused now argument from ServiceInfo._process_record (#440) @bdraco + +* Disable pylint too-many-branches for functions that need refactoring (#439) @bdraco + +* Cleanup unused variables (#437) @bdraco + +* Cleanup unnecessary else after returns (#436) @bdraco + * Add zeroconf.asyncio to the docs (#434) @bdraco * Fix warning when generating sphinx docs (#432) @bdraco @@ -183,14 +329,6 @@ Changelog * Simplify DNSPointer processing in ServiceBrowser (#386) @bdraco -* Breaking change: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco - - When manually creating a zeroconf.Engine object, it is no longer started automatically. - It must manually be started by calling .start() on the created object. - - The Engine thread is now started after all the listeners have been added to avoid a - race condition where packets could be missed at startup. - * Ensure the cache is checked for name conflict after final service query with asyncio (#382) @bdraco * Complete ServiceInfo request as soon as all questions are answered (#380) @bdraco From bfca3b46fd9a395f387bd90b68c523a3ca84bde4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 09:19:24 -1000 Subject: [PATCH 0257/1433] Rename zeroconf.asyncio to zeroconf.aio (#503) - The asyncio name could shadow system asyncio in some cases. If zeroconf is in sys.path, this would result in loading zeroconf.asyncio when system asyncio was intended. - An `zeroconf.asyncio` shim module has been added that imports `zeroconf.aio` that was available in 0.31 to provide backwards compatibility in 0.32. This module will be removed in 0.33 to fix the underlying problem detailed in #502 --- Makefile | 6 +- docs/api.rst | 2 +- examples/async_browser.py | 2 +- examples/async_registration.py | 2 +- examples/async_service_info_request.py | 2 +- zeroconf/aio.py | 403 +++++++++++++++++++++ zeroconf/asyncio.py | 386 +------------------- zeroconf/test_aio.py | 469 +++++++++++++++++++++++++ zeroconf/test_asyncio.py | 456 +----------------------- 9 files changed, 890 insertions(+), 838 deletions(-) create mode 100644 zeroconf/aio.py create mode 100644 zeroconf/test_aio.py diff --git a/Makefile b/Makefile index b766fcf7a..66bc85f16 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ flake8: flake8 --max-line-length=$(MAX_LINE_LENGTH) setup.py examples zeroconf pylint: - pylint zeroconf/__init__.py zeroconf/asyncio.py + pylint zeroconf/__init__.py zeroconf/aio.py zeroconf/asyncio.py .PHONY: black_check black_check: @@ -39,10 +39,10 @@ mypy: mypy examples/*.py zeroconf/*.py test: - pytest -v zeroconf/test.py zeroconf/test_asyncio.py + pytest -v zeroconf/test.py zeroconf/test_aio.py zeroconf/test_asyncio.py test_coverage: - pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py zeroconf/test_asyncio.py + pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py zeroconf/test_aio.py zeroconf/test_asyncio.py autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/docs/api.rst b/docs/api.rst index 1704db5af..20c53727f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,7 +6,7 @@ python-zeroconf API reference :undoc-members: :show-inheritance: -.. automodule:: zeroconf.asyncio +.. automodule:: zeroconf.aio :members: :undoc-members: :show-inheritance: diff --git a/examples/async_browser.py b/examples/async_browser.py index b2a1916d6..4a3861cb6 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -11,7 +11,7 @@ from typing import cast from zeroconf import IPVersion, ServiceStateChange -from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf +from zeroconf.aio import AsyncServiceBrowser, AsyncZeroconf def async_on_service_state_change( diff --git a/examples/async_registration.py b/examples/async_registration.py index 53d14ce1a..7e02ea7c0 100644 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -9,7 +9,7 @@ from typing import List from zeroconf import IPVersion -from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf async def register_services(infos: List[AsyncServiceInfo]) -> None: diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index c0f953c23..838545cee 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -13,7 +13,7 @@ from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf -from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf HAP_TYPE = "_hap._tcp.local." diff --git a/zeroconf/aio.py b/zeroconf/aio.py new file mode 100644 index 000000000..611197474 --- /dev/null +++ b/zeroconf/aio.py @@ -0,0 +1,403 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" +import asyncio +import contextlib +import queue +import threading +from types import TracebackType # noqa # used in type hints +from typing import Awaitable, Callable, Dict, List, Optional, Type, Union + +from . import ( + DNSOutgoing, + IPVersion, + InterfaceChoice, + InterfacesType, + NonUniqueNameException, + NotifyListener, + ServiceInfo, + Zeroconf, + _BROWSER_TIME, + _CHECK_TIME, + _LISTENER_TIME, + _MDNS_PORT, + _REGISTER_TIME, + _ServiceBrowserBase, + _UNREGISTER_TIME, + current_time_millis, + instance_name_from_service_info, + millis_to_seconds, +) + + +def _get_best_available_queue() -> queue.Queue: + """Create the best available queue type.""" + if hasattr(queue, "SimpleQueue"): + return queue.SimpleQueue() # type: ignore # pylint: disable=all + return queue.Queue() + + +# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed +async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: + """Wait for a condition or timeout.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + + def _handle_timeout() -> None: + if not future.done(): + future.set_result(None) + + timer_handle = loop.call_later(timeout, _handle_timeout) + condition_wait = loop.create_task(condition.wait()) + + def _handle_wait_complete(_: asyncio.Task) -> None: + if not future.done(): + future.set_result(None) + + condition_wait.add_done_callback(_handle_wait_complete) + + try: + await future + finally: + timer_handle.cancel() + if not condition_wait.done(): + condition_wait.cancel() + with contextlib.suppress(asyncio.CancelledError): + await condition_wait + + +class _AsyncSender(threading.Thread): + """A thread to handle sending DNSOutgoing for asyncio.""" + + def __init__(self, zc: 'Zeroconf'): + """Create the sender thread.""" + super().__init__() + self.zc = zc + self.queue = _get_best_available_queue() + self.start() + self.name = "AsyncZeroconfSender" + + def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + """Queue a send to be processed by the thread.""" + self.queue.put((out, addr, port)) + + def close(self) -> None: + """Close the instance.""" + self.queue.put(None) + self.join() + + def run(self) -> None: + """Runner that processes sends FIFO.""" + while True: + event = self.queue.get() + if event is None: + return + self.zc.send(*event) + + +class AsyncNotifyListener(NotifyListener): + """A NotifyListener that async code can use to wait for events.""" + + def __init__(self, aiozc: 'AsyncZeroconf') -> None: + """Create an event for async listeners to wait for.""" + self.aiozc = aiozc + self.loop = asyncio.get_event_loop() + + def notify_all(self) -> None: + """Schedule an async_notify_all.""" + self.loop.call_soon_threadsafe(asyncio.ensure_future, self._async_notify_all()) + + async def _async_notify_all(self) -> None: + """Notify all async listeners.""" + async with self.aiozc.condition: + self.aiozc.condition.notify_all() + + +class AsyncServiceListener: + def add_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def remove_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def update_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + +class AsyncServiceInfo(ServiceInfo): + """An async version of ServiceInfo.""" + + async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + if self.load_from_cache(aiozc.zeroconf): + return True + + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + last = now + timeout + try: + aiozc.zeroconf.add_listener(self, None) + while not self._is_complete: + if last <= now: + return False + if next_ <= now: + out = self.generate_request_query(aiozc.zeroconf, now) + if not out.questions: + return self.load_from_cache(aiozc.zeroconf) + aiozc.sender.send(out) + next_ = now + delay + delay *= 2 + + await aiozc.async_wait(min(next_, last) - now) + now = current_time_millis() + finally: + aiozc.zeroconf.remove_listener(self) + + return True + + +class AsyncServiceBrowser(_ServiceBrowserBase): + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__( + self, + aiozc: 'AsyncZeroconf', + type_: Union[str, list], + handlers: Optional[Union[AsyncServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[AsyncServiceListener] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + self.aiozc = aiozc + super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore + self._browser_task = asyncio.ensure_future(self.async_run()) + + async def async_cancel(self) -> None: + """Cancel the browser.""" + self.cancel() + self._browser_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._browser_task + + async def async_run(self) -> None: + """Run the browser task.""" + self.run() + while True: + timeout = self._seconds_to_wait() + if timeout: + async with self.aiozc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + await wait_condition_or_timeout(self.aiozc.condition, timeout) + + out = self.generate_ready_queries() + if out: + self.aiozc.sender.send(out, addr=self.addr, port=self.port) + + if not self._handlers_to_call: + continue + + (name_type, state_change) = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.aiozc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) + + +class AsyncZeroconf: + """Implementation of Zeroconf Multicast DNS Service Discovery + + Supports registration, unregistration, queries and browsing. + + The async version is currently a wrapper around the sync version + with I/O being done in the executor for backwards compatibility. + """ + + def __init__( + self, + interfaces: InterfacesType = InterfaceChoice.All, + unicast: bool = False, + ip_version: Optional[IPVersion] = None, + apple_p2p: bool = False, + zc: Optional[Zeroconf] = None, + ) -> None: + """Creates an instance of the Zeroconf class, establishing + multicast communications, listening and reaping threads. + + :param interfaces: :class:`InterfaceChoice` or a list of IP addresses + (IPv4 and IPv6) and interface indexes (IPv6 only). + + IPv6 notes for non-POSIX systems: + * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` + on Python versions before 3.8. + + Also listening on loopback (``::1``) doesn't work, use a real address. + :param ip_version: IP versions to support. If `choice` is a list, the default is detected + from it. Otherwise defaults to V4 only for backward compatibility. + :param apple_p2p: use AWDL interface (only macOS) + """ + self.zeroconf = zc or Zeroconf( + interfaces=interfaces, + unicast=unicast, + ip_version=ip_version, + apple_p2p=apple_p2p, + ) + self.loop = asyncio.get_event_loop() + self.async_notify = AsyncNotifyListener(self) + self.zeroconf.add_notify_listener(self.async_notify) + self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} + self.sender = _AsyncSender(self.zeroconf) + self.condition = asyncio.Condition() + + async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + """Send a broadcasts to announce a service at intervals.""" + for i in range(3): + if i != 0: + await asyncio.sleep(millis_to_seconds(interval)) + self.sender.send(self.zeroconf.generate_service_broadcast(info, ttl)) + + async def async_register_service( + self, + info: ServiceInfo, + cooperating_responders: bool = False, + ) -> Awaitable: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. The name of the service may be changed if needed to make + it unique on the network. Additionally multiple cooperating responders + can register the same service on the network for resilience + (if you want this behavior set `cooperating_responders` to `True`). + + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. + """ + await self.async_check_service(info, cooperating_responders) + self.zeroconf.registry.add(info) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + + async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: + """Checks the network for a unique service name.""" + instance_name_from_service_info(info) + if cooperating_responders: + return + self._raise_on_name_conflict(info) + for i in range(3): + if i != 0: + await asyncio.sleep(millis_to_seconds(_CHECK_TIME)) + self.sender.send(self.zeroconf.generate_service_query(info)) + self._raise_on_name_conflict(info) + + def _raise_on_name_conflict(self, info: ServiceInfo) -> None: + """Raise NonUniqueNameException if the ServiceInfo has a conflict.""" + if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): + raise NonUniqueNameException + + async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: + """Unregister a service. + + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. + """ + self.zeroconf.registry.remove(info) + return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) + + async def async_update_service(self, info: ServiceInfo) -> Awaitable: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. + + The service will be broadcast in a task. This task is returned + and therefore can be awaited if necessary. + """ + self.zeroconf.registry.update(info) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + + def _close(self) -> None: + """Shutdown zeroconf and the sender.""" + self.sender.close() + self.zeroconf.remove_notify_listener(self.async_notify) + self.zeroconf.close() + + async def async_close(self) -> None: + """Ends the background threads, and prevent this instance from + servicing further queries.""" + await self.async_remove_all_service_listeners() + await self.loop.run_in_executor(None, self._close) + + async def async_get_service_info( + self, type_: str, name: str, timeout: int = 3000 + ) -> Optional[AsyncServiceInfo]: + """Returns network's service information for a particular + name and type, or None if no service matches by the timeout, + which defaults to 3 seconds.""" + info = AsyncServiceInfo(type_, name) + if await info.async_request(self, timeout): + return info + return None + + async def async_wait(self, timeout: float) -> None: + """Calling task waits for a given number of milliseconds or until notified.""" + async with self.condition: + await wait_condition_or_timeout(self.condition, millis_to_seconds(timeout)) + + async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: + """Adds a listener for a particular service type. This object + will then have its add_service and remove_service methods called when + services of that type become available and unavailable.""" + await self.async_remove_service_listener(listener) + self.async_browsers[listener] = AsyncServiceBrowser(self, type_, listener) + + async def async_remove_service_listener(self, listener: AsyncServiceListener) -> None: + """Removes a listener from the set that is currently listening.""" + if listener in self.async_browsers: + await self.async_browsers[listener].async_cancel() + del self.async_browsers[listener] + + async def async_remove_all_service_listeners(self) -> None: + """Removes a listener from the set that is currently listening.""" + await asyncio.gather( + *[self.async_remove_service_listener(listener) for listener in list(self.async_browsers)] + ) + + async def __aenter__(self) -> 'AsyncZeroconf': + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + await self.async_close() + return None diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 611197474..bdca1c0d3 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -19,385 +19,17 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ -import asyncio -import contextlib -import queue -import threading -from types import TracebackType # noqa # used in type hints -from typing import Awaitable, Callable, Dict, List, Optional, Type, Union - -from . import ( - DNSOutgoing, - IPVersion, - InterfaceChoice, - InterfacesType, - NonUniqueNameException, - NotifyListener, - ServiceInfo, - Zeroconf, - _BROWSER_TIME, - _CHECK_TIME, - _LISTENER_TIME, - _MDNS_PORT, - _REGISTER_TIME, - _ServiceBrowserBase, - _UNREGISTER_TIME, - current_time_millis, - instance_name_from_service_info, - millis_to_seconds, -) - - -def _get_best_available_queue() -> queue.Queue: - """Create the best available queue type.""" - if hasattr(queue, "SimpleQueue"): - return queue.SimpleQueue() # type: ignore # pylint: disable=all - return queue.Queue() - - -# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed -async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: - """Wait for a condition or timeout.""" - loop = asyncio.get_event_loop() - future = loop.create_future() - - def _handle_timeout() -> None: - if not future.done(): - future.set_result(None) - - timer_handle = loop.call_later(timeout, _handle_timeout) - condition_wait = loop.create_task(condition.wait()) - - def _handle_wait_complete(_: asyncio.Task) -> None: - if not future.done(): - future.set_result(None) - - condition_wait.add_done_callback(_handle_wait_complete) - - try: - await future - finally: - timer_handle.cancel() - if not condition_wait.done(): - condition_wait.cancel() - with contextlib.suppress(asyncio.CancelledError): - await condition_wait - - -class _AsyncSender(threading.Thread): - """A thread to handle sending DNSOutgoing for asyncio.""" - - def __init__(self, zc: 'Zeroconf'): - """Create the sender thread.""" - super().__init__() - self.zc = zc - self.queue = _get_best_available_queue() - self.start() - self.name = "AsyncZeroconfSender" - - def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: - """Queue a send to be processed by the thread.""" - self.queue.put((out, addr, port)) - - def close(self) -> None: - """Close the instance.""" - self.queue.put(None) - self.join() - - def run(self) -> None: - """Runner that processes sends FIFO.""" - while True: - event = self.queue.get() - if event is None: - return - self.zc.send(*event) - - -class AsyncNotifyListener(NotifyListener): - """A NotifyListener that async code can use to wait for events.""" - - def __init__(self, aiozc: 'AsyncZeroconf') -> None: - """Create an event for async listeners to wait for.""" - self.aiozc = aiozc - self.loop = asyncio.get_event_loop() - - def notify_all(self) -> None: - """Schedule an async_notify_all.""" - self.loop.call_soon_threadsafe(asyncio.ensure_future, self._async_notify_all()) - - async def _async_notify_all(self) -> None: - """Notify all async listeners.""" - async with self.aiozc.condition: - self.aiozc.condition.notify_all() - - -class AsyncServiceListener: - def add_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def remove_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def update_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - -class AsyncServiceInfo(ServiceInfo): - """An async version of ServiceInfo.""" - - async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ - if self.load_from_cache(aiozc.zeroconf): - return True - - now = current_time_millis() - delay = _LISTENER_TIME - next_ = now - last = now + timeout - try: - aiozc.zeroconf.add_listener(self, None) - while not self._is_complete: - if last <= now: - return False - if next_ <= now: - out = self.generate_request_query(aiozc.zeroconf, now) - if not out.questions: - return self.load_from_cache(aiozc.zeroconf) - aiozc.sender.send(out) - next_ = now + delay - delay *= 2 - - await aiozc.async_wait(min(next_, last) - now) - now = current_time_millis() - finally: - aiozc.zeroconf.remove_listener(self) - - return True +import logging -class AsyncServiceBrowser(_ServiceBrowserBase): - """Used to browse for a service of a specific type. +from .aio import AsyncZeroconf # pylint: disable=unused-import # noqa - The listener object will have its add_service() and - remove_service() methods called when this browser - discovers changes in the services availability.""" +log = logging.getLogger(__name__) - def __init__( - self, - aiozc: 'AsyncZeroconf', - type_: Union[str, list], - handlers: Optional[Union[AsyncServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[AsyncServiceListener] = None, - addr: Optional[str] = None, - port: int = _MDNS_PORT, - delay: int = _BROWSER_TIME, - ) -> None: - self.aiozc = aiozc - super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore - self._browser_task = asyncio.ensure_future(self.async_run()) +# The asyncio module would shadow system asyncio in some import cases +# to resolve this, the module has been renamed zeroconf.aio - async def async_cancel(self) -> None: - """Cancel the browser.""" - self.cancel() - self._browser_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._browser_task - - async def async_run(self) -> None: - """Run the browser task.""" - self.run() - while True: - timeout = self._seconds_to_wait() - if timeout: - async with self.aiozc.condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - await wait_condition_or_timeout(self.aiozc.condition, timeout) - - out = self.generate_ready_queries() - if out: - self.aiozc.sender.send(out, addr=self.addr, port=self.port) - - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.aiozc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) - - -class AsyncZeroconf: - """Implementation of Zeroconf Multicast DNS Service Discovery - - Supports registration, unregistration, queries and browsing. - - The async version is currently a wrapper around the sync version - with I/O being done in the executor for backwards compatibility. - """ - - def __init__( - self, - interfaces: InterfacesType = InterfaceChoice.All, - unicast: bool = False, - ip_version: Optional[IPVersion] = None, - apple_p2p: bool = False, - zc: Optional[Zeroconf] = None, - ) -> None: - """Creates an instance of the Zeroconf class, establishing - multicast communications, listening and reaping threads. - - :param interfaces: :class:`InterfaceChoice` or a list of IP addresses - (IPv4 and IPv6) and interface indexes (IPv6 only). - - IPv6 notes for non-POSIX systems: - * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` - on Python versions before 3.8. - - Also listening on loopback (``::1``) doesn't work, use a real address. - :param ip_version: IP versions to support. If `choice` is a list, the default is detected - from it. Otherwise defaults to V4 only for backward compatibility. - :param apple_p2p: use AWDL interface (only macOS) - """ - self.zeroconf = zc or Zeroconf( - interfaces=interfaces, - unicast=unicast, - ip_version=ip_version, - apple_p2p=apple_p2p, - ) - self.loop = asyncio.get_event_loop() - self.async_notify = AsyncNotifyListener(self) - self.zeroconf.add_notify_listener(self.async_notify) - self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} - self.sender = _AsyncSender(self.zeroconf) - self.condition = asyncio.Condition() - - async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: - """Send a broadcasts to announce a service at intervals.""" - for i in range(3): - if i != 0: - await asyncio.sleep(millis_to_seconds(interval)) - self.sender.send(self.zeroconf.generate_service_broadcast(info, ttl)) - - async def async_register_service( - self, - info: ServiceInfo, - cooperating_responders: bool = False, - ) -> Awaitable: - """Registers service information to the network with a default TTL. - Zeroconf will then respond to requests for information for that - service. The name of the service may be changed if needed to make - it unique on the network. Additionally multiple cooperating responders - can register the same service on the network for resilience - (if you want this behavior set `cooperating_responders` to `True`). - - The service will be broadcast in a task. This task is returned - and therefore can be awaited if necessary. - """ - await self.async_check_service(info, cooperating_responders) - self.zeroconf.registry.add(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) - - async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: - """Checks the network for a unique service name.""" - instance_name_from_service_info(info) - if cooperating_responders: - return - self._raise_on_name_conflict(info) - for i in range(3): - if i != 0: - await asyncio.sleep(millis_to_seconds(_CHECK_TIME)) - self.sender.send(self.zeroconf.generate_service_query(info)) - self._raise_on_name_conflict(info) - - def _raise_on_name_conflict(self, info: ServiceInfo) -> None: - """Raise NonUniqueNameException if the ServiceInfo has a conflict.""" - if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): - raise NonUniqueNameException - - async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: - """Unregister a service. - - The service will be broadcast in a task. This task is returned - and therefore can be awaited if necessary. - """ - self.zeroconf.registry.remove(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) - - async def async_update_service(self, info: ServiceInfo) -> Awaitable: - """Registers service information to the network with a default TTL. - Zeroconf will then respond to requests for information for that - service. - - The service will be broadcast in a task. This task is returned - and therefore can be awaited if necessary. - """ - self.zeroconf.registry.update(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) - - def _close(self) -> None: - """Shutdown zeroconf and the sender.""" - self.sender.close() - self.zeroconf.remove_notify_listener(self.async_notify) - self.zeroconf.close() - - async def async_close(self) -> None: - """Ends the background threads, and prevent this instance from - servicing further queries.""" - await self.async_remove_all_service_listeners() - await self.loop.run_in_executor(None, self._close) - - async def async_get_service_info( - self, type_: str, name: str, timeout: int = 3000 - ) -> Optional[AsyncServiceInfo]: - """Returns network's service information for a particular - name and type, or None if no service matches by the timeout, - which defaults to 3 seconds.""" - info = AsyncServiceInfo(type_, name) - if await info.async_request(self, timeout): - return info - return None - - async def async_wait(self, timeout: float) -> None: - """Calling task waits for a given number of milliseconds or until notified.""" - async with self.condition: - await wait_condition_or_timeout(self.condition, millis_to_seconds(timeout)) - - async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: - """Adds a listener for a particular service type. This object - will then have its add_service and remove_service methods called when - services of that type become available and unavailable.""" - await self.async_remove_service_listener(listener) - self.async_browsers[listener] = AsyncServiceBrowser(self, type_, listener) - - async def async_remove_service_listener(self, listener: AsyncServiceListener) -> None: - """Removes a listener from the set that is currently listening.""" - if listener in self.async_browsers: - await self.async_browsers[listener].async_cancel() - del self.async_browsers[listener] - - async def async_remove_all_service_listeners(self) -> None: - """Removes a listener from the set that is currently listening.""" - await asyncio.gather( - *[self.async_remove_service_listener(listener) for listener in list(self.async_browsers)] - ) - - async def __aenter__(self) -> 'AsyncZeroconf': - return self - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: - await self.async_close() - return None +log.warning( + "zeroconf.asyncio namespace has changed to zeroconf.aio; " + "This compatibility module will be removed in the next version" +) diff --git a/zeroconf/test_aio.py b/zeroconf/test_aio.py new file mode 100644 index 000000000..b05d88b5e --- /dev/null +++ b/zeroconf/test_aio.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for aio.py.""" + +import asyncio +import socket +import unittest.mock + +import pytest + +from . import ( + BadTypeInNameException, + NonUniqueNameException, + ServiceInfo, + ServiceListener, + ServiceNameAlreadyRegistered, + Zeroconf, + _LISTENER_TIME, + current_time_millis, +) +from .aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf + + +@pytest.mark.asyncio +async def test_async_basic_usage() -> None: + """Test we can create and close the instance.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_with_sync_passed_in() -> None: + """Test we can create and close the instance when passing in a sync Zeroconf.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(zc=zc) + assert aiozc.zeroconf is zc + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_registration() -> None: + """Test registering services broadcasts the registration by default.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + await task + task = await aiozc.async_unregister_service(new_info) + await task + await aiozc.async_close() + + assert calls == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] + + +@pytest.mark.asyncio +async def test_async_service_registration_name_conflict() -> None: + """Test registering services throws on name conflict.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc2-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + + with pytest.raises(NonUniqueNameException): + task = await aiozc.async_register_service(info) + await task + + with pytest.raises(ServiceNameAlreadyRegistered): + task = await aiozc.async_register_service(info, cooperating_responders=True) + await task + + conflicting_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-3.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + + with pytest.raises(NonUniqueNameException): + task = await aiozc.async_register_service(conflicting_info) + await task + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_registration_name_does_not_match_type() -> None: + """Test registering services throws when the name does not match the type.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc3-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info.type = "_wrong._tcp.local." + with pytest.raises(BadTypeInNameException): + task = await aiozc.async_register_service(info) + await task + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_tasks() -> None: + """Test awaiting broadcast tasks""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc4-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + assert isinstance(task, asyncio.Task) + await task + + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + assert isinstance(task, asyncio.Task) + await task + + task = await aiozc.async_unregister_service(new_info) + assert isinstance(task, asyncio.Task) + await task + + await aiozc.async_close() + + assert calls == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] + + +@pytest.mark.asyncio +async def test_async_wait_unblocks_on_update() -> None: + """Test async_wait will unblock on update.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test-srvc4-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + + # Should unblock due to update from the + # registration + now = current_time_millis() + await aiozc.async_wait(50000) + assert current_time_millis() - now < 3000 + await task + + now = current_time_millis() + await aiozc.async_wait(50) + assert current_time_millis() - now < 1000 + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_info_async_request() -> None: + """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "abc" + registration_name = "%s.%s" % (name, type_) + registration_name2 = "%s.%s" % (name2, type_) + + # Start a tasks BEFORE the registration that will keep trying + # and see the registration a bit later + get_service_info_task1 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + await asyncio.sleep(_LISTENER_TIME / 1000 / 2) + get_service_info_task2 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-1.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info2 = ServiceInfo( + type_, + registration_name2, + 80, + 0, + 0, + desc, + "ash-5.local.", + addresses=[socket.inet_aton("10.0.1.5")], + ) + tasks = [] + tasks.append(await aiozc.async_register_service(info)) + tasks.append(await aiozc.async_register_service(info2)) + await asyncio.gather(*tasks) + + aiosinfo = await get_service_info_task1 + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + aiosinfo = await get_service_info_task2 + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] + + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3"), socket.inet_pton(socket.AF_INET6, "6001:db8::1")], + ) + + task = await aiozc.async_update_service(new_info) + await task + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + + aiosinfos = await asyncio.gather( + aiozc.async_get_service_info(type_, registration_name), + aiozc.async_get_service_info(type_, registration_name2), + ) + assert aiosinfos[0] is not None + assert aiosinfos[0].addresses == [socket.inet_aton("10.0.1.3")] + assert aiosinfos[1] is not None + assert aiosinfos[1].addresses == [socket.inet_aton("10.0.1.5")] + + aiosinfo = AsyncServiceInfo(type_, registration_name) + zc_cache = aiozc.zeroconf.cache + for name in zc_cache.names(): + for record in zc_cache.entries_with_name(name): + zc_cache.remove(record) + # Generating the race condition is almost impossible + # without patching since its a TOCTOU race + with unittest.mock.patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): + await aiosinfo.async_request(aiozc, 3000) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + + task = await aiozc.async_unregister_service(new_info) + await task + + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is None + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_service_browser() -> None: + """Test AsyncServiceBrowser.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test9-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + calls = [] + + with pytest.raises(NotImplementedError): + AsyncServiceListener().add_service(aiozc, "_type", "name._type") + + with pytest.raises(NotImplementedError): + AsyncServiceListener().remove_service(aiozc, "_type", "name._type") + + with pytest.raises(NotImplementedError): + AsyncServiceListener().update_service(aiozc, "_type", "name._type") + + class MyListener(AsyncServiceListener): + def add_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + await aiozc.async_add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + await task + task = await aiozc.async_unregister_service(new_info) + await task + await aiozc.async_wait(1) + await aiozc.async_close() + + assert calls == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] + + +@pytest.mark.asyncio +async def test_async_context_manager() -> None: + """Test using an async context manager.""" + type_ = "_test10-sr-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + async with AsyncZeroconf(interfaces=['127.0.0.1']) as aiozc: + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + assert aiosinfo is not None diff --git a/zeroconf/test_asyncio.py b/zeroconf/test_asyncio.py index ddf3fbee6..28e3a327b 100644 --- a/zeroconf/test_asyncio.py +++ b/zeroconf/test_asyncio.py @@ -2,25 +2,12 @@ # -*- coding: utf-8 -*- -"""Unit tests for async.py.""" +"""Unit tests for asyncio.py.""" -import asyncio -import socket -import unittest.mock import pytest -from . import ( - BadTypeInNameException, - NonUniqueNameException, - ServiceInfo, - ServiceListener, - ServiceNameAlreadyRegistered, - Zeroconf, - _LISTENER_TIME, - current_time_millis, -) -from .asyncio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf +from .asyncio import AsyncZeroconf @pytest.mark.asyncio @@ -28,442 +15,3 @@ async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_async_with_sync_passed_in() -> None: - """Test we can create and close the instance when passing in a sync Zeroconf.""" - zc = Zeroconf(interfaces=['127.0.0.1']) - aiozc = AsyncZeroconf(zc=zc) - assert aiozc.zeroconf is zc - await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_async_service_registration() -> None: - """Test registering services broadcasts the registration by default.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test1-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - calls = [] - - class MyListener(ServiceListener): - def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("add", type, name)) - - def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("remove", type, name)) - - def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("update", type, name)) - - listener = MyListener() - aiozc.zeroconf.add_service_listener(type_, listener) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - await task - new_info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.3")], - ) - task = await aiozc.async_update_service(new_info) - await task - task = await aiozc.async_unregister_service(new_info) - await task - await aiozc.async_close() - - assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), - ] - - -@pytest.mark.asyncio -async def test_async_service_registration_name_conflict() -> None: - """Test registering services throws on name conflict.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc2-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - await task - - with pytest.raises(NonUniqueNameException): - task = await aiozc.async_register_service(info) - await task - - with pytest.raises(ServiceNameAlreadyRegistered): - task = await aiozc.async_register_service(info, cooperating_responders=True) - await task - - conflicting_info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-3.local.", - addresses=[socket.inet_aton("10.0.1.3")], - ) - - with pytest.raises(NonUniqueNameException): - task = await aiozc.async_register_service(conflicting_info) - await task - - await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_async_service_registration_name_does_not_match_type() -> None: - """Test registering services throws when the name does not match the type.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc3-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - info.type = "_wrong._tcp.local." - with pytest.raises(BadTypeInNameException): - task = await aiozc.async_register_service(info) - await task - await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_async_tasks() -> None: - """Test awaiting broadcast tasks""" - - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc4-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - calls = [] - - class MyListener(ServiceListener): - def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("add", type, name)) - - def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("remove", type, name)) - - def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: - calls.append(("update", type, name)) - - listener = MyListener() - aiozc.zeroconf.add_service_listener(type_, listener) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - assert isinstance(task, asyncio.Task) - await task - - new_info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.3")], - ) - task = await aiozc.async_update_service(new_info) - assert isinstance(task, asyncio.Task) - await task - - task = await aiozc.async_unregister_service(new_info) - assert isinstance(task, asyncio.Task) - await task - - await aiozc.async_close() - - assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), - ] - - -@pytest.mark.asyncio -async def test_async_wait_unblocks_on_update() -> None: - """Test async_wait will unblock on update.""" - - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test-srvc4-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - - # Should unblock due to update from the - # registration - now = current_time_millis() - await aiozc.async_wait(50000) - assert current_time_millis() - now < 3000 - await task - - now = current_time_millis() - await aiozc.async_wait(50) - assert current_time_millis() - now < 1000 - - await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_service_info_async_request() -> None: - """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test1-srvc-type._tcp.local." - name = "xxxyyy" - name2 = "abc" - registration_name = "%s.%s" % (name, type_) - registration_name2 = "%s.%s" % (name2, type_) - - # Start a tasks BEFORE the registration that will keep trying - # and see the registration a bit later - get_service_info_task1 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) - await asyncio.sleep(_LISTENER_TIME / 1000 / 2) - get_service_info_task2 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-1.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - info2 = ServiceInfo( - type_, - registration_name2, - 80, - 0, - 0, - desc, - "ash-5.local.", - addresses=[socket.inet_aton("10.0.1.5")], - ) - tasks = [] - tasks.append(await aiozc.async_register_service(info)) - tasks.append(await aiozc.async_register_service(info2)) - await asyncio.gather(*tasks) - - aiosinfo = await get_service_info_task1 - assert aiosinfo is not None - assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] - - aiosinfo = await get_service_info_task2 - assert aiosinfo is not None - assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] - - aiosinfo = await aiozc.async_get_service_info(type_, registration_name) - assert aiosinfo is not None - assert aiosinfo.addresses == [socket.inet_aton("10.0.1.2")] - - new_info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.3"), socket.inet_pton(socket.AF_INET6, "6001:db8::1")], - ) - - task = await aiozc.async_update_service(new_info) - await task - - aiosinfo = await aiozc.async_get_service_info(type_, registration_name) - assert aiosinfo is not None - assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] - - aiosinfos = await asyncio.gather( - aiozc.async_get_service_info(type_, registration_name), - aiozc.async_get_service_info(type_, registration_name2), - ) - assert aiosinfos[0] is not None - assert aiosinfos[0].addresses == [socket.inet_aton("10.0.1.3")] - assert aiosinfos[1] is not None - assert aiosinfos[1].addresses == [socket.inet_aton("10.0.1.5")] - - aiosinfo = AsyncServiceInfo(type_, registration_name) - zc_cache = aiozc.zeroconf.cache - for name in zc_cache.names(): - for record in zc_cache.entries_with_name(name): - zc_cache.remove(record) - # Generating the race condition is almost impossible - # without patching since its a TOCTOU race - with unittest.mock.patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc, 3000) - assert aiosinfo is not None - assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] - - task = await aiozc.async_unregister_service(new_info) - await task - - aiosinfo = await aiozc.async_get_service_info(type_, registration_name) - assert aiosinfo is None - - await aiozc.async_close() - - -@pytest.mark.asyncio -async def test_async_service_browser() -> None: - """Test AsyncServiceBrowser.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - type_ = "_test9-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - calls = [] - - with pytest.raises(NotImplementedError): - AsyncServiceListener().add_service(aiozc, "_type", "name._type") - - with pytest.raises(NotImplementedError): - AsyncServiceListener().remove_service(aiozc, "_type", "name._type") - - with pytest.raises(NotImplementedError): - AsyncServiceListener().update_service(aiozc, "_type", "name._type") - - class MyListener(AsyncServiceListener): - def add_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: - calls.append(("add", type, name)) - - def remove_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: - calls.append(("remove", type, name)) - - def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: - calls.append(("update", type, name)) - - listener = MyListener() - await aiozc.async_add_service_listener(type_, listener) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - await task - new_info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.3")], - ) - task = await aiozc.async_update_service(new_info) - await task - task = await aiozc.async_unregister_service(new_info) - await task - await aiozc.async_wait(1) - await aiozc.async_close() - - assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), - ] - - -@pytest.mark.asyncio -async def test_async_context_manager() -> None: - """Test using an async context manager.""" - type_ = "_test10-sr-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - async with AsyncZeroconf(interfaces=['127.0.0.1']) as aiozc: - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - task = await aiozc.async_register_service(info) - await task - aiosinfo = await aiozc.async_get_service_info(type_, registration_name) - assert aiosinfo is not None From 26b70050ffe7dee4fb34428f285be377d1d8f210 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 11:43:27 -1000 Subject: [PATCH 0258/1433] Update changelog for zeroconf.asyncio -> zeroconf.aio (#506) --- README.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.rst b/README.rst index 10b25f143..b83fc65fe 100644 --- a/README.rst +++ b/README.rst @@ -134,9 +134,29 @@ See examples directory for more. Changelog ========= +0.33.0 (Unreleased) +=================== + +* Breaking change: zeroconf.asyncio has been removed in favor of zeroconf.aio - TBD + + The asyncio name could shadow system asyncio in some cases. If + zeroconf is in sys.path, this would result in loading zeroconf.asyncio + when system asyncio was intended. + 0.32.0 (Unreleased) =================== +* Breaking change: zeroconf.asyncio has been renamed zeroconf.aio (#503) @bdraco + + The asyncio name could shadow system asyncio in some cases. If + zeroconf is in sys.path, this would result in loading zeroconf.asyncio + when system asyncio was intended. + + An `zeroconf.asyncio` shim module has been added that imports `zeroconf.aio` + that was available in 0.31 to provide backwards compatibility in 0.32.0 + This module will be removed in 0.33.0 to fix the underlying problem + detailed in #502 + * Breaking change: Update internal version check to match docs (3.6+) (#491) @bdraco Python version eariler then 3.6 were likely broken with zeroconf From 1cfcc5636a845924eb683ad4acf4d9a36ef85fb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 12:18:41 -1000 Subject: [PATCH 0259/1433] Extract code for handling queries into QueryHandler (#507) --- zeroconf/__init__.py | 161 +++++++++++++++++++++++-------------------- 1 file changed, 88 insertions(+), 73 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index c362d16be..0b0ca19ba 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2717,6 +2717,91 @@ def _remove(self, info: ServiceInfo) -> None: del self.services[lower_name] +class QueryHandler: + """Query the ServiceRegistry.""" + + def __init__(self, registry: ServiceRegistry): + """Init the query handler.""" + self.registry = registry + + def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: + """Provide an answer to a service type enumeration query. + + https://datatracker.ietf.org/doc/html/rfc6763#section-9 + """ + for stype in self.registry.get_types(): + out.add_answer( + msg, + DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + ), + ) + + def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a PTR query.""" + for service in self.registry.get_infos_type(question.name.lower()): + out.add_answer(msg, service.dns_pointer()) + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer(service.dns_service()) + out.add_additional_answer(service.dns_text()) + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a query any query other then PTR. + + Add answer(s) for A, AAAA, SRV, or TXT queries. + """ + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): + for dns_address in service.dns_addresses(): + out.add_answer(msg, dns_address) + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + return + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, service.dns_service()) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, service.dns_text()) + if question.type == _TYPE_SRV: + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: + """Deal with incoming query packets. Provides a response if possible.""" + if unicast: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) + for question in msg.questions: + out.add_question(question) + else: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + for question in msg.questions: + if question.type == _TYPE_PTR: + if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + self._answer_service_type_enumeration_query(msg, out) + else: + self._answer_ptr_query(msg, out, question) + continue + + self._answer_non_ptr_query(msg, out, question) + + if out is not None and out.answers: + out.id = msg.id + return out + + return None + + class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -2777,6 +2862,7 @@ def __init__( self._notify_listeners = [] # type: List[NotifyListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] self.registry = ServiceRegistry() + self.query_handler = QueryHandler(self.registry) self.cache = DNSCache() @@ -3091,82 +3177,11 @@ def handle_response(self, msg: DNSIncoming) -> None: # because the data was not yet populated. self.cache.remove_records(removes) - def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: - """Provide an answer to a service type enumeration query. - - https://datatracker.ietf.org/doc/html/rfc6763#section-9 - """ - for stype in self.registry.get_types(): - out.add_answer( - msg, - DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - - def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name.lower()): - out.add_answer(msg, service.dns_pointer()) - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer(service.dns_service()) - out.add_additional_answer(service.dns_text()) - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a query any query other then PTR. - - Add answer(s) for A, AAAA, SRV, or TXT queries. - """ - name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(name_to_find) # type: ignore - if service is None: - return - - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if possible.""" - # Support unicast client responses - # - if port != _MDNS_PORT: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) - for question in msg.questions: - out.add_question(question) - else: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._answer_service_type_enumeration_query(msg, out) - else: - self._answer_ptr_query(msg, out, question) - continue - - self._answer_non_ptr_query(msg, out, question) - - if out is not None and out.answers: - out.id = msg.id + out = self.query_handler.response(msg, port != _MDNS_PORT) + if out: self.send(out, addr, port) def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: From db866f7d032ed031e6aa5e14fba24b3dafeafa8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 12:34:00 -1000 Subject: [PATCH 0260/1433] Stop monkey patching send in the PTR optimization test (#509) --- zeroconf/test.py | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 060b80a06..00e03302d 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2417,33 +2417,9 @@ def test_ptr_optimization(): type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) - # we are going to monkey patch the zeroconf send to check packet sizes - old_send = zc.send - nbr_answers = nbr_additionals = nbr_authorities = 0 has_srv = has_txt = has_a = False - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): - """Sends an outgoing packet.""" - nonlocal nbr_answers, nbr_additionals, nbr_authorities - nonlocal has_srv, has_txt, has_a - - nbr_answers += len(out.answers) - nbr_authorities += len(out.authorities) - for answer in out.additionals: - nbr_additionals += 1 - if answer.type == r._TYPE_SRV: - has_srv = True - elif answer.type == r._TYPE_TXT: - has_txt = True - elif answer.type == r._TYPE_A: - has_a = True - - old_send(out, addr=addr, port=port) - - # monkey patch the zeroconf send - setattr(zc, "send", send) - # register zc.register_service(info) nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -2451,7 +2427,18 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): # query query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) - zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) + out = zc.query_handler.response(r.DNSIncoming(query.packet()), False) + assert out is not None + nbr_answers += len(out.answers) + nbr_authorities += len(out.authorities) + for answer in out.additionals: + nbr_additionals += 1 + if answer.type == r._TYPE_SRV: + has_srv = True + elif answer.type == r._TYPE_TXT: + has_txt = True + elif answer.type == r._TYPE_A: + has_a = True assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 assert has_srv and has_txt and has_a From 954ca3fb498bdc7cd5a6a168c40ad5b6b2476e71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 13:08:15 -1000 Subject: [PATCH 0261/1433] Stop monkey patching send in the TTL test (#510) --- zeroconf/test.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 00e03302d..817557ddd 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1009,9 +1009,6 @@ def test_ttl(self): addresses=[socket.inet_aton("10.0.1.2")], ) - # we are going to monkey patch the zeroconf send to check packet sizes - old_send = zc.send - nbr_answers = nbr_additionals = nbr_authorities = 0 def get_ttl(record_type): @@ -1022,7 +1019,7 @@ def get_ttl(record_type): else: return r._DNS_OTHER_TTL - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def _process_outgoing_packet(out): """Sends an outgoing packet.""" nonlocal nbr_answers, nbr_additionals, nbr_authorities @@ -1035,14 +1032,14 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): for answer in out.authorities: nbr_authorities += 1 assert answer.ttl == get_ttl(answer.type) - old_send(out, addr=addr, port=port) - - # monkey patch the zeroconf send - setattr(zc, "send", send) # register service with default TTL expected_ttl = None - zc.register_service(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_query(info)) + zc.registry.add(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, None)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -1053,36 +1050,46 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): query.add_question(r.DNSQuestion(info.name, r._TYPE_SRV, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) - zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister expected_ttl = 0 - zc.unregister_service(info) + zc.registry.remove(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 + expected_ttl = None + for _ in range(3): + _process_outgoing_packet(zc.generate_service_query(info)) + zc.registry.add(info) # register service with custom TTL expected_ttl = r._DNS_HOST_TTL * 2 assert expected_ttl != r._DNS_HOST_TTL - zc.register_service(info, ttl=expected_ttl) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, expected_ttl)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 # query + expected_ttl = None query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, r._TYPE_SRV, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) - zc.handle_query(r.DNSIncoming(query.packet()), r._MDNS_ADDR, r._MDNS_PORT) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister expected_ttl = 0 - zc.unregister_service(info) + zc.registry.remove(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 zc.close() From 70b455ba53ce43e9280c02612e8a89665abd57f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 14:59:02 -1000 Subject: [PATCH 0262/1433] Remove uneeded wait in the Engine thread (#511) - It is not longer necessary to wait since the socketpair was added in #243 which will cause the select to unblock when a new socket is added or removed. --- zeroconf/__init__.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0b0ca19ba..6474e51ef 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1440,17 +1440,8 @@ def __init__(self, zc: 'Zeroconf') -> None: def run(self) -> None: while not self.zc.done: - rs = list(self.readers.keys()) - if not rs: - # No sockets to manage, but we wait for the timeout - # or addition of a socket - with self.condition: - self.condition.wait(self.timeout) - continue - try: - rs.append(self.socketpair[0]) - rr, _wr, _er = select.select(rs, [], [], self.timeout) + rr, _wr, _er = select.select([*self.readers.keys(), self.socketpair[0]], [], [], self.timeout) if self.zc.done: return From 9a766a2a96abd0f105056839b5c30f2ede31ea2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 17:19:55 -1000 Subject: [PATCH 0263/1433] Break out record updating into RecordManager (#512) --- zeroconf/__init__.py | 179 +++++++++++++++++++++++-------------------- 1 file changed, 98 insertions(+), 81 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6474e51ef..71e071822 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -35,9 +35,8 @@ import time import warnings from collections import OrderedDict -from contextlib import contextmanager from types import TracebackType # noqa # used in type hints -from typing import Dict, Generator, Iterable, List, Optional, Type, Union, cast +from typing import Dict, Iterable, List, Optional, Type, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints import ifaddr @@ -1464,8 +1463,8 @@ def run(self) -> None: now = current_time_millis() if now - self._last_cache_cleanup >= self.cache_cleanup_interval_ms: self._last_cache_cleanup = now - with self.zc.update_records(now, list(self.zc.cache.expire(now))): - pass + self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) + self.zc.record_manager.updates_complete() self.socketpair[0].close() self.socketpair[1].close() @@ -2793,6 +2792,99 @@ def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: return None +class RecordManager: + """Process records into the cache and notify listeners.""" + + def __init__(self, zeroconf: 'Zeroconf'): + """Init the record manager.""" + self.zc = zeroconf + self.cache = zeroconf.cache + + def updates(self, now: float, rec: List[DNSRecord]) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called before the cache is updated. + """ + for listener in self.zc.listeners: + listener.update_records(self.zc, now, rec) + + def updates_complete(self) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called after the cache is updated. + """ + for listener in self.zc.listeners: + listener.update_records_complete() + self.zc.notify_all() + + def updates_from_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + updates: List[DNSRecord] = [] + address_adds: List[DNSAddress] = [] + other_adds: List[DNSRecord] = [] + removes: List[DNSRecord] = [] + now = current_time_millis() + for record in msg.answers: + + updated = True + + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): + if entry == record: + updated = False + if record.created - entry.created > 1000 and entry not in msg.answers: + removes.append(entry) + + expired = record.is_expired(now) + maybe_entry = self.cache.get(record) + if not expired: + if maybe_entry is not None: + maybe_entry.reset_ttl(record) + else: + if isinstance(record, DNSAddress): + address_adds.append(record) + else: + other_adds.append(record) + if updated: + updates.append(record) + elif maybe_entry is not None: + updates.append(record) + removes.append(record) + + if not updates and not address_adds and not other_adds and not removes: + return + + self.updates(now, updates) + # The cache adds must be processed AFTER we trigger + # the updates since we compare existing data + # with the new data and updating the cache + # ahead of update_record will cause listeners + # to miss changes + # + # We must process address adds before non-addresses + # otherwise a fetch of ServiceInfo may miss an address + # because it thinks the cache is complete + # + # The cache is processed under the context manager to ensure + # that any ServiceBrowser that is going to call + # zc.get_service_info will see the cached value + # but ONLY after all the record updates have been + # processsed. + self.cache.add_records(itertools.chain(address_adds, other_adds)) + # Removes are processed last since + # ServiceInfo could generate an un-needed query + # because the data was not yet populated. + self.cache.remove_records(removes) + self.updates_complete() + + class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -2854,8 +2946,8 @@ def __init__( self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] self.registry = ServiceRegistry() self.query_handler = QueryHandler(self.registry) - self.cache = DNSCache() + self.record_manager = RecordManager(self) self.condition = threading.Condition() @@ -3088,85 +3180,10 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions log.exception('Unknown error, possibly benign: %r', e) - @contextmanager - def update_records(self, now: float, rec: List[DNSRecord]) -> Generator: - """Used to notify listeners of new information that has updated - a record. - - This method must be called before the cache is updated. - """ - try: - for listener in self.listeners: - listener.update_records(self, now, rec) - yield - finally: - for listener in self.listeners: - listener.update_records_complete() - self.notify_all() - def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" - updates = [] # type: List[DNSRecord] - address_adds = [] # type: List[DNSAddress] - other_adds = [] # type: List[DNSRecord] - removes = [] # type: List[DNSRecord] - now = current_time_millis() - for record in msg.answers: - - updated = True - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # rfc6762#section-10.2 para 2 - # Since unique is set, all old records with that name, rrtype, - # and rrclass that were received more than one second ago are declared - # invalid, and marked to expire from the cache in one second. - for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): - if entry == record: - updated = False - if record.created - entry.created > 1000 and entry not in msg.answers: - removes.append(entry) - - expired = record.is_expired(now) - maybe_entry = self.cache.get(record) - if not expired: - if maybe_entry is not None: - maybe_entry.reset_ttl(record) - else: - if isinstance(record, DNSAddress): - address_adds.append(record) - else: - other_adds.append(record) - if updated: - updates.append(record) - elif maybe_entry is not None: - updates.append(record) - removes.append(record) - - if not updates and not address_adds and not other_adds and not removes: - return - - with self.update_records(now, updates): - # The cache adds must be processed AFTER we trigger - # the updates since we compare existing data - # with the new data and updating the cache - # ahead of update_record will cause listeners - # to miss changes - # - # We must process address adds before non-addresses - # otherwise a fetch of ServiceInfo may miss an address - # because it thinks the cache is complete - # - # The cache is processed under the context manager to ensure - # that any ServiceBrowser that is going to call - # zc.get_service_info will see the cached value - # but ONLY after all the record updates have been - # processsed. - self.cache.add_records(itertools.chain(address_adds, other_adds)) - # Removes are processed last since - # ServiceInfo could generate an un-needed query - # because the data was not yet populated. - self.cache.remove_records(removes) + self.record_manager.updates_from_response(msg) def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if From 3d6c68278713a2ca66e27938feedcc451a078369 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 17:22:04 -1000 Subject: [PATCH 0264/1433] Update changelog (#513) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index b83fc65fe..f37b6de58 100644 --- a/README.rst +++ b/README.rst @@ -189,6 +189,12 @@ Changelog The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. +* Break out record updating into RecordManager (#512) @bdraco + +* Remove uneeded wait in the Engine thread (#511) @bdraco + +* Extract code for handling queries into QueryHandler (#507) @bdraco + * Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco * Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco From 6cc3adb020115ef9626caf61bb5f7550a2da8b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 19:02:29 -1000 Subject: [PATCH 0265/1433] Move RecordUpdateListener management into RecordManager (#514) --- zeroconf/__init__.py | 60 ++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 71e071822..dd047cca3 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2799,6 +2799,7 @@ def __init__(self, zeroconf: 'Zeroconf'): """Init the record manager.""" self.zc = zeroconf self.cache = zeroconf.cache + self.listeners: List[RecordUpdateListener] = [] def updates(self, now: float, rec: List[DNSRecord]) -> None: """Used to notify listeners of new information that has updated @@ -2806,7 +2807,7 @@ def updates(self, now: float, rec: List[DNSRecord]) -> None: This method must be called before the cache is updated. """ - for listener in self.zc.listeners: + for listener in self.listeners: listener.update_records(self.zc, now, rec) def updates_complete(self) -> None: @@ -2815,7 +2816,7 @@ def updates_complete(self) -> None: This method must be called after the cache is updated. """ - for listener in self.zc.listeners: + for listener in self.listeners: listener.update_records_complete() self.zc.notify_all() @@ -2884,6 +2885,35 @@ def updates_from_response(self, msg: DNSIncoming) -> None: self.cache.remove_records(removes) self.updates_complete() + def add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s).""" + now = current_time_millis() + self.listeners.append(listener) + records = [] + if question is not None: + questions = [question] if isinstance(question, DNSQuestion) else question + for single_question in questions: + for record in self.cache.entries_with_name(single_question.name): + if single_question.answered_by(record) and not record.is_expired(now): + records.append(record) + + if records: + listener.update_records(self.zc, now, records) + listener.update_records_complete() + self.zc.notify_all() + + def remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener.""" + try: + self.listeners.remove(listener) + self.zc.notify_all() + except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions + log.exception('Unknown error, possibly benign: %r', e) + class Zeroconf(QuietLogger): @@ -2941,7 +2971,6 @@ def __init__( log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) self.multi_socket = unicast or interfaces is not InterfaceChoice.Default - self.listeners = [] # type: List[RecordUpdateListener] self._notify_listeners = [] # type: List[NotifyListener] self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] self.registry = ServiceRegistry() @@ -2967,6 +2996,10 @@ def __init__( def done(self) -> bool: return self._GLOBAL_DONE + @property + def listeners(self) -> List[RecordUpdateListener]: + return self.record_manager.listeners + def wait(self, timeout: float) -> None: """Calling thread waits for a given number of milliseconds or until notified.""" @@ -3157,28 +3190,11 @@ def add_listener( """Adds a listener for a given question. The listener will have its update_record method called when information is available to answer the question(s).""" - now = current_time_millis() - self.listeners.append(listener) - records = [] - if question is not None: - questions = [question] if isinstance(question, DNSQuestion) else question - for single_question in questions: - for record in self.cache.entries_with_name(single_question.name): - if single_question.answered_by(record) and not record.is_expired(now): - records.append(record) - - if records: - listener.update_records(self, now, records) - listener.update_records_complete() - self.notify_all() + self.record_manager.add_listener(listener, question) def remove_listener(self, listener: RecordUpdateListener) -> None: """Removes a listener.""" - try: - self.listeners.remove(listener) - self.notify_all() - except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions - log.exception('Unknown error, possibly benign: %r', e) + self.record_manager.remove_listener(listener) def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers From f80a0515cf73b1e304d0615f8cee91ae38ac1ae8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 21:59:42 -1000 Subject: [PATCH 0266/1433] Small cleanups to RecordManager.add_listener (#516) --- zeroconf/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dd047cca3..8ddfaa7de 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2795,7 +2795,7 @@ def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: class RecordManager: """Process records into the cache and notify listeners.""" - def __init__(self, zeroconf: 'Zeroconf'): + def __init__(self, zeroconf: 'Zeroconf') -> None: """Init the record manager.""" self.zc = zeroconf self.cache = zeroconf.cache @@ -2891,19 +2891,20 @@ def add_listener( """Adds a listener for a given question. The listener will have its update_record method called when information is available to answer the question(s).""" - now = current_time_millis() self.listeners.append(listener) - records = [] + if question is not None: + now = current_time_millis() + records = [] questions = [question] if isinstance(question, DNSQuestion) else question for single_question in questions: for record in self.cache.entries_with_name(single_question.name): if single_question.answered_by(record) and not record.is_expired(now): records.append(record) + if records: + listener.update_records(self.zc, now, records) + listener.update_records_complete() - if records: - listener.update_records(self.zc, now, records) - listener.update_records_complete() self.zc.notify_all() def remove_listener(self, listener: RecordUpdateListener) -> None: From e12523933819087d2a087b8388e79b24af058a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 22:22:12 -1000 Subject: [PATCH 0267/1433] Remove broad exception catch from RecordManager.remove_listener (#517) --- zeroconf/__init__.py | 4 ++-- zeroconf/test.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8ddfaa7de..10a13d3c4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2912,8 +2912,8 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: try: self.listeners.remove(listener) self.zc.notify_all() - except Exception as e: # pylint: disable=broad-except # TODO stop catching all Exceptions - log.exception('Unknown error, possibly benign: %r', e) + except ValueError as e: + log.exception('Failed to remove listener: %r', e) class Zeroconf(QuietLogger): diff --git a/zeroconf/test.py b/zeroconf/test.py index 817557ddd..21118cf8e 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2747,7 +2747,9 @@ def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None nonlocal updates updates.append(record) - zc.add_listener(LegacyRecordUpdateListener(), None) + listener = LegacyRecordUpdateListener() + + zc.add_listener(listener, None) # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): @@ -2778,4 +2780,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): assert len(updates) assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + zc.remove_listener(listener) + # Removing a second time should not throw + zc.remove_listener(listener) + zc.close() From ef7aa250e140d70b8c62abf4d13dcaa36f128c63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Jun 2021 23:02:03 -1000 Subject: [PATCH 0268/1433] Add test helper to inject DNSIncoming (#518) --- zeroconf/test.py | 86 +++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 21118cf8e..ad4017cbd 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -26,6 +26,7 @@ import zeroconf as r from zeroconf import ( DNSHinfo, + DNSIncoming, DNSText, ServiceBrowser, ServiceInfo, @@ -59,6 +60,11 @@ def teardown_module(): log.setLevel(original_logging_level) +def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: + """Inject a DNSIncoming response.""" + zc.handle_response(msg) + + @lru_cache(maxsize=None) def has_working_ipv6(): """Return True if if the system can bind an IPv6 address.""" @@ -788,7 +794,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS try: # service added - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' @@ -805,7 +811,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # service updated. currently only text record can be updated service_text = b'path=/~humingchun/' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' @@ -814,14 +820,14 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # The split message only has a SRV and A record. # This should not evict TXT records from the cache - zeroconf.handle_response(mock_split_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) time.sleep(1.1) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is not None assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' # service removed - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) assert dns_text is None @@ -1471,6 +1477,9 @@ def update_service(self, zeroconf, type, name): cached_info.load_from_cache(zeroconf_browser) assert cached_info.properties is not None + # Populate the cache + zeroconf_browser.get_service_info(subtype, registration_name) + # get service info with only the cache cached_info = ServiceInfo(subtype, registration_name) cached_info.load_from_cache(zeroconf_browser) @@ -1649,7 +1658,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi wait_time = 3 # service added - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Added)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) service_add_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 0 @@ -1658,7 +1667,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service SRV updated service_updated_event.clear() service_server = 'ash-2.local.' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 1 @@ -1667,7 +1676,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service TXT updated service_updated_event.clear() service_text = b'path=/~matt2/' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -1676,7 +1685,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service TXT updated - duplicate update should not trigger another service_updated service_updated_event.clear() service_text = b'path=/~matt2/' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -1685,7 +1694,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service A updated service_updated_event.clear() service_address = '10.0.1.3' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 3 @@ -1696,14 +1705,14 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi service_server = 'ash-3.local.' service_text = b'path=/~matt3/' service_address = '10.0.1.3' - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 assert service_removed_count == 0 # service removed - zeroconf.handle_response(mock_incoming_msg(r.ServiceStateChange.Removed)) + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) service_removed_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 @@ -1933,10 +1942,11 @@ def get_service_info_helper(zc, type, name): # Expext query for SRV, A, AAAA last_sent = None send_event.clear() - zc.handle_response( + _inject_response( + zc, mock_incoming_msg( [r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text)] - ) + ), ) send_event.wait(wait_time) assert last_sent is not None @@ -1949,7 +1959,8 @@ def get_service_info_helper(zc, type, name): # Expext query for A, AAAA last_sent = None send_event.clear() - zc.handle_response( + _inject_response( + zc, mock_incoming_msg( [ r.DNSService( @@ -1963,7 +1974,7 @@ def get_service_info_helper(zc, type, name): service_server, ) ] - ) + ), ) send_event.wait(wait_time) assert last_sent is not None @@ -1976,7 +1987,8 @@ def get_service_info_helper(zc, type, name): # Expext no further queries last_sent = None send_event.clear() - zc.handle_response( + _inject_response( + zc, mock_incoming_msg( [ r.DNSAddress( @@ -1987,7 +1999,7 @@ def get_service_info_helper(zc, type, name): socket.inet_pton(socket.AF_INET, service_address), ) ] - ) + ), ) send_event.wait(wait_time) assert last_sent is None @@ -2059,7 +2071,8 @@ def get_service_info_helper(zc, type, name): # Expext no further queries last_sent = None send_event.clear() - zc.handle_response( + _inject_response( + zc, mock_incoming_msg( [ r.DNSText( @@ -2083,7 +2096,7 @@ def get_service_info_helper(zc, type, name): socket.inet_pton(socket.AF_INET, service_address), ), ] - ) + ), ) send_event.wait(wait_time) assert last_sent is None @@ -2135,11 +2148,13 @@ def mock_incoming_msg( wait_time = 3 # all three services added - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), ) - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), ) called_with_refresh_time_check = False @@ -2153,14 +2168,16 @@ def _mock_get_expiration_time(self, percent): # Set an expire time that will force a refresh with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), ) # Add the last record after updating the first one # to ensure the service_add_event only gets set # after the update - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), ) service_add_event.wait(wait_time) assert called_with_refresh_time_check is True @@ -2168,14 +2185,17 @@ def _mock_get_expiration_time(self, percent): assert service_removed_count == 0 # all three services removed - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), ) - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), ) - zeroconf.handle_response( - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), ) service_removed_event.wait(wait_time) assert service_added_count == 3 From 7ce29a2f736af13886aa66dc1c49e15768e6fdcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 09:07:34 -1000 Subject: [PATCH 0269/1433] Make the cache cleanup interval a constant (#522) --- zeroconf/__init__.py | 4 ++-- zeroconf/test.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 10a13d3c4..b3a5f451e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -82,6 +82,7 @@ _LISTENER_TIME = 200 # ms _BROWSER_TIME = 1000 # ms _BROWSER_BACKOFF_LIMIT = 3600 # s +_CACHE_CLEANUP_INTERVAL = 10000 # ms # Some DNS constants @@ -1431,7 +1432,6 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.readers = {} # type: Dict[socket.socket, Listener] self.timeout = 5 - self.cache_cleanup_interval_ms = 10000.0 self.condition = threading.Condition() self.socketpair = socket.socketpair() self._last_cache_cleanup = 0.0 @@ -1461,7 +1461,7 @@ def run(self) -> None: raise now = current_time_millis() - if now - self._last_cache_cleanup >= self.cache_cleanup_interval_ms: + if now - self._last_cache_cleanup >= _CACHE_CLEANUP_INTERVAL: self._last_cache_cleanup = now self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) self.zc.record_manager.updates_complete() diff --git a/zeroconf/test.py b/zeroconf/test.py index ad4017cbd..e862f5aab 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -1236,6 +1236,7 @@ def test_cache_empty_multiple_calls_does_not_throw(self): class TestReaper(unittest.TestCase): + @unittest.mock.patch.object(r, "_CACHE_CLEANUP_INTERVAL", 10) def test_reaper(self): zeroconf = Zeroconf(interfaces=['127.0.0.1']) cache = zeroconf.cache @@ -1245,7 +1246,6 @@ def test_reaper(self): zeroconf.cache.add(record_with_10s_ttl) zeroconf.cache.add(record_with_1s_ttl) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - zeroconf.engine.cache_cleanup_interval_ms = 10 time.sleep(1) with zeroconf.engine.condition: zeroconf.engine._notify() From b37d115a233b61e2989d1439f65cdd911b86f407 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 09:09:48 -1000 Subject: [PATCH 0270/1433] Update python compatibility as PyPy3 7.2 is required (#523) - When the version requirement changed to cpython 3.6, PyPy was not bumped as well --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f37b6de58..228f0facf 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ Python compatibility -------------------- * CPython 3.6+ -* PyPy3 5.8+ +* PyPy3 7.2+ Versioning ---------- From f49342cdaff2d012ad23635b49ae746ad71333df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 09:34:46 -1000 Subject: [PATCH 0271/1433] Fix flakey test_update_record (#525) - Ensure enough time has past that the first record update was processed before sending the second one --- zeroconf/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index e862f5aab..44e7fcd7c 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2156,7 +2156,6 @@ def mock_incoming_msg( zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), ) - called_with_refresh_time_check = False def _mock_get_expiration_time(self, percent): @@ -2172,6 +2171,7 @@ def _mock_get_expiration_time(self, percent): zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), ) + zeroconf.wait(100) # Add the last record after updating the first one # to ensure the service_add_event only gets set # after the update From 16d40b50ccab6a8d53fe4aeb7b0006f7fd67ef53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 09:41:01 -1000 Subject: [PATCH 0272/1433] Move ipversion auto detection code into its own function (#524) --- zeroconf/__init__.py | 29 +++++++++++++++++------------ zeroconf/test.py | 8 ++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b3a5f451e..7bbae60a1 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -2617,6 +2617,22 @@ def can_send_to(sock: socket.socket, address: str) -> bool: return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) +def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: + """Auto detect the IP version when it is not provided.""" + if isinstance(interfaces, list): + has_v6 = any( + isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + for i in interfaces + ) + has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) + if has_v4 and has_v6: + return IPVersion.All + if has_v6: + return IPVersion.V6Only + + return IPVersion.V4Only + + class ServiceRegistry: """A registry to keep track of services. @@ -2945,19 +2961,8 @@ def __init__( from it. Otherwise defaults to V4 only for backward compatibility. :param apple_p2p: use AWDL interface (only macOS) """ - if ip_version is None and isinstance(interfaces, list): - has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - for i in interfaces - ) - has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) - if has_v4 and has_v6: - ip_version = IPVersion.All - elif has_v6: - ip_version = IPVersion.V6Only - if ip_version is None: - ip_version = IPVersion.V4Only + ip_version = autodetect_ip_version(interfaces) # hook for threads self._GLOBAL_DONE = False diff --git a/zeroconf/test.py b/zeroconf/test.py index 44e7fcd7c..448f78dae 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2805,3 +2805,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.remove_listener(listener) zc.close() + + +def test_autodetect_ip_version(): + """Tests for auto detecting IPVersion based on interface ips.""" + assert r.autodetect_ip_version(["1.3.4.5"]) is r.IPVersion.V4Only + assert r.autodetect_ip_version([]) is r.IPVersion.V4Only + assert r.autodetect_ip_version(["::1", "1.2.3.4"]) is r.IPVersion.All + assert r.autodetect_ip_version(["::1"]) is r.IPVersion.V6Only From 14542bd2bd327fd9b3d93cfb48a3bf09d6c89e15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 10:18:10 -1000 Subject: [PATCH 0273/1433] Fix flakey test_update_record test (round 2) (#528) --- zeroconf/test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeroconf/test.py b/zeroconf/test.py index 448f78dae..f0092ea4c 100644 --- a/zeroconf/test.py +++ b/zeroconf/test.py @@ -2156,6 +2156,8 @@ def mock_incoming_msg( zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), ) + zeroconf.wait(100) + called_with_refresh_time_check = False def _mock_get_expiration_time(self, percent): @@ -2171,7 +2173,6 @@ def _mock_get_expiration_time(self, percent): zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), ) - zeroconf.wait(100) # Add the last record after updating the first one # to ensure the service_add_event only gets set # after the update From 3f1a5a7b7a929d5f699812a809347b0c2f799fbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 10:26:59 -1000 Subject: [PATCH 0274/1433] Relocate tests to tests directory (#527) --- Makefile | 4 ++-- setup.cfg | 3 +++ tests/__init__.py | 21 +++++++++++++++++++++ {zeroconf => tests}/test_aio.py | 4 ++-- {zeroconf => tests}/test_asyncio.py | 2 +- zeroconf/test.py => tests/test_init.py | 0 6 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py rename {zeroconf => tests}/test_aio.py (99%) rename {zeroconf => tests}/test_asyncio.py (87%) rename zeroconf/test.py => tests/test_init.py (100%) diff --git a/Makefile b/Makefile index 66bc85f16..a627c37e6 100644 --- a/Makefile +++ b/Makefile @@ -39,10 +39,10 @@ mypy: mypy examples/*.py zeroconf/*.py test: - pytest -v zeroconf/test.py zeroconf/test_aio.py zeroconf/test_asyncio.py + pytest -v tests test_coverage: - pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing zeroconf/test.py zeroconf/test_aio.py zeroconf/test_asyncio.py + pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/setup.cfg b/setup.cfg index d4354ef45..a9dddb260 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,6 @@ +[tool:pytest] +testpaths = tests + [flake8] show-source = 1 application-import-names=zeroconf diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..2ef4b15b1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" diff --git a/zeroconf/test_aio.py b/tests/test_aio.py similarity index 99% rename from zeroconf/test_aio.py rename to tests/test_aio.py index b05d88b5e..b50e5bc70 100644 --- a/zeroconf/test_aio.py +++ b/tests/test_aio.py @@ -10,7 +10,7 @@ import pytest -from . import ( +from zeroconf import ( BadTypeInNameException, NonUniqueNameException, ServiceInfo, @@ -20,7 +20,7 @@ _LISTENER_TIME, current_time_millis, ) -from .aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf +from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf @pytest.mark.asyncio diff --git a/zeroconf/test_asyncio.py b/tests/test_asyncio.py similarity index 87% rename from zeroconf/test_asyncio.py rename to tests/test_asyncio.py index 28e3a327b..ee8f80538 100644 --- a/zeroconf/test_asyncio.py +++ b/tests/test_asyncio.py @@ -7,7 +7,7 @@ import pytest -from .asyncio import AsyncZeroconf +from zeroconf.asyncio import AsyncZeroconf @pytest.mark.asyncio diff --git a/zeroconf/test.py b/tests/test_init.py similarity index 100% rename from zeroconf/test.py rename to tests/test_init.py From 2d8a27a54aee298af74121986b4ea76f1f50b421 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 10:40:08 -1000 Subject: [PATCH 0275/1433] Move asyncio utils into zeroconf.utils.aio (#530) --- Makefile | 6 ++-- tests/utils/__init__.py | 21 ++++++++++++ tests/utils/test_aio.py | 22 +++++++++++++ zeroconf/aio.py | 30 +---------------- zeroconf/utils/__init__.py | 21 ++++++++++++ zeroconf/utils/aio.py | 67 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 31 deletions(-) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_aio.py create mode 100644 zeroconf/utils/__init__.py create mode 100644 zeroconf/utils/aio.py diff --git a/Makefile b/Makefile index a627c37e6..de816c1c5 100644 --- a/Makefile +++ b/Makefile @@ -29,14 +29,16 @@ flake8: flake8 --max-line-length=$(MAX_LINE_LENGTH) setup.py examples zeroconf pylint: - pylint zeroconf/__init__.py zeroconf/aio.py zeroconf/asyncio.py + pylint zeroconf .PHONY: black_check black_check: black --check setup.py examples zeroconf mypy: - mypy examples/*.py zeroconf/*.py +# --no-warn-redundant-casts --no-warn-unused-ignores is needed since we support multiple python versions +# We should be able to drop this once python 3.6 goes away + mypy --no-warn-redundant-casts --no-warn-unused-ignores examples/*.py zeroconf test: pytest -v tests diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..2ef4b15b1 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,21 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py new file mode 100644 index 000000000..e38eb5832 --- /dev/null +++ b/tests/utils/test_aio.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for zeroconf.utils.aio.""" + +import asyncio + +import pytest + +from zeroconf.utils import aio as aioutils + + +@pytest.mark.asyncio +async def test_get_running_loop_from_async() -> None: + """Test we can get the event loop.""" + assert isinstance(aioutils.get_running_loop(), asyncio.AbstractEventLoop) + + +def test_get_running_loop_no_loop() -> None: + """Test we get None when there is no loop running.""" + assert aioutils.get_running_loop() is None diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 611197474..92e112285 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -46,6 +46,7 @@ instance_name_from_service_info, millis_to_seconds, ) +from .utils.aio import wait_condition_or_timeout def _get_best_available_queue() -> queue.Queue: @@ -55,35 +56,6 @@ def _get_best_available_queue() -> queue.Queue: return queue.Queue() -# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed -async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: - """Wait for a condition or timeout.""" - loop = asyncio.get_event_loop() - future = loop.create_future() - - def _handle_timeout() -> None: - if not future.done(): - future.set_result(None) - - timer_handle = loop.call_later(timeout, _handle_timeout) - condition_wait = loop.create_task(condition.wait()) - - def _handle_wait_complete(_: asyncio.Task) -> None: - if not future.done(): - future.set_result(None) - - condition_wait.add_done_callback(_handle_wait_complete) - - try: - await future - finally: - timer_handle.cancel() - if not condition_wait.done(): - condition_wait.cancel() - with contextlib.suppress(asyncio.CancelledError): - await condition_wait - - class _AsyncSender(threading.Thread): """A thread to handle sending DNSOutgoing for asyncio.""" diff --git a/zeroconf/utils/__init__.py b/zeroconf/utils/__init__.py new file mode 100644 index 000000000..2ef4b15b1 --- /dev/null +++ b/zeroconf/utils/__init__.py @@ -0,0 +1,21 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" diff --git a/zeroconf/utils/aio.py b/zeroconf/utils/aio.py new file mode 100644 index 000000000..87a79f265 --- /dev/null +++ b/zeroconf/utils/aio.py @@ -0,0 +1,67 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import asyncio +import contextlib +from typing import Optional, cast + + +# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed +async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: + """Wait for a condition or timeout.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + + def _handle_timeout() -> None: + if not future.done(): + future.set_result(None) + + timer_handle = loop.call_later(timeout, _handle_timeout) + condition_wait = loop.create_task(condition.wait()) + + def _handle_wait_complete(_: asyncio.Task) -> None: + if not future.done(): + future.set_result(None) + + condition_wait.add_done_callback(_handle_wait_complete) + + try: + await future + finally: + timer_handle.cancel() + if not condition_wait.done(): + condition_wait.cancel() + with contextlib.suppress(asyncio.CancelledError): + await condition_wait + + +# Remove the call to _get_running_loop once we drop python 3.6 support +def get_running_loop() -> Optional[asyncio.AbstractEventLoop]: + """Check if an event loop is already running.""" + with contextlib.suppress(RuntimeError): + if hasattr(asyncio, "get_running_loop"): + return cast( + asyncio.AbstractEventLoop, + asyncio.get_running_loop(), # type: ignore # pylint: disable=no-member # noqa + ) + return asyncio._get_running_loop() # pylint: disable=no-member,protected-access + return None From 89d4755106a6c3bced395b0a26eb3082c1268fa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 11:11:22 -1000 Subject: [PATCH 0276/1433] Move constants into const.py (#531) --- zeroconf/__init__.py | 173 +++++++++++++------------------------------ zeroconf/const.py | 145 ++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 121 deletions(-) create mode 100644 zeroconf/const.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 7bbae60a1..46251828f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -26,7 +26,6 @@ import itertools import logging import platform -import re import select import socket import struct @@ -41,6 +40,58 @@ import ifaddr +from .const import ( # noqa # import needed for backwards compat + _BROWSER_BACKOFF_LIMIT, + _BROWSER_TIME, + _CACHE_CLEANUP_INTERVAL, + _CHECK_TIME, + _CLASSES, + _CLASS_IN, + _CLASS_NONE, + _CLASS_MASK, + _CLASS_UNIQUE, + _DNS_HOST_TTL, + _DNS_OTHER_TTL, + _DNS_PORT, + _EXPIRE_FULL_TIME_PERCENT, + _EXPIRE_REFRESH_TIME_PERCENT, + _EXPIRE_STALE_TIME_PERCENT, + _FLAGS_AA, + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _FLAGS_TC, + _HAS_ASCII_CONTROL_CHARS, + _HAS_A_TO_Z, + _HAS_ONLY_A_TO_Z_NUM_HYPHEN, + _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE, + _IPPROTO_IPV6, + _LISTENER_TIME, + _LOCAL_TRAILER, + _MAX_MSG_ABSOLUTE, + _MAX_MSG_TYPICAL, + _MDNS_ADDR, + _MDNS_ADDR6, + _MDNS_ADDR6_BYTES, + _MDNS_ADDR_BYTES, + _MDNS_PORT, + _NONTCP_PROTOCOL_LOCAL_TRAILER, + _REGISTER_TIME, + _SERVICE_TYPE_ENUMERATION_NAME, + _TCP_PROTOCOL_LOCAL_TRAILER, + _TYPES, + _TYPE_A, + _TYPE_AAAA, + _TYPE_ANY, + _TYPE_CNAME, + _TYPE_HINFO, + _TYPE_PTR, + _TYPE_SOA, + _TYPE_SRV, + _TYPE_TXT, + _UNREGISTER_TIME, +) + __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' __version__ = '0.31.0' @@ -74,126 +125,6 @@ if log.level == logging.NOTSET: log.setLevel(logging.WARN) -# Some timing constants - -_UNREGISTER_TIME = 125 # ms -_CHECK_TIME = 175 # ms -_REGISTER_TIME = 225 # ms -_LISTENER_TIME = 200 # ms -_BROWSER_TIME = 1000 # ms -_BROWSER_BACKOFF_LIMIT = 3600 # s -_CACHE_CLEANUP_INTERVAL = 10000 # ms - -# Some DNS constants - -_MDNS_ADDR = '224.0.0.251' -_MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) -_MDNS_ADDR6 = 'ff02::fb' -_MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) -_MDNS_PORT = 5353 -_DNS_PORT = 53 -_DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 -_DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per RFC6762 - -_MAX_MSG_TYPICAL = 1460 # unused -_MAX_MSG_ABSOLUTE = 8966 - -_FLAGS_QR_MASK = 0x8000 # query response mask -_FLAGS_QR_QUERY = 0x0000 # query -_FLAGS_QR_RESPONSE = 0x8000 # response - -_FLAGS_AA = 0x0400 # Authoritative answer -_FLAGS_TC = 0x0200 # Truncated -_FLAGS_RD = 0x0100 # Recursion desired -_FLAGS_RA = 0x8000 # Recursion available - -_FLAGS_Z = 0x0040 # Zero -_FLAGS_AD = 0x0020 # Authentic data -_FLAGS_CD = 0x0010 # Checking disabled - -_CLASS_IN = 1 -_CLASS_CS = 2 -_CLASS_CH = 3 -_CLASS_HS = 4 -_CLASS_NONE = 254 -_CLASS_ANY = 255 -_CLASS_MASK = 0x7FFF -_CLASS_UNIQUE = 0x8000 - -_TYPE_A = 1 -_TYPE_NS = 2 -_TYPE_MD = 3 -_TYPE_MF = 4 -_TYPE_CNAME = 5 -_TYPE_SOA = 6 -_TYPE_MB = 7 -_TYPE_MG = 8 -_TYPE_MR = 9 -_TYPE_NULL = 10 -_TYPE_WKS = 11 -_TYPE_PTR = 12 -_TYPE_HINFO = 13 -_TYPE_MINFO = 14 -_TYPE_MX = 15 -_TYPE_TXT = 16 -_TYPE_AAAA = 28 -_TYPE_SRV = 33 -_TYPE_ANY = 255 - -# Mapping constants to names - -_CLASSES = { - _CLASS_IN: "in", - _CLASS_CS: "cs", - _CLASS_CH: "ch", - _CLASS_HS: "hs", - _CLASS_NONE: "none", - _CLASS_ANY: "any", -} - -_TYPES = { - _TYPE_A: "a", - _TYPE_NS: "ns", - _TYPE_MD: "md", - _TYPE_MF: "mf", - _TYPE_CNAME: "cname", - _TYPE_SOA: "soa", - _TYPE_MB: "mb", - _TYPE_MG: "mg", - _TYPE_MR: "mr", - _TYPE_NULL: "null", - _TYPE_WKS: "wks", - _TYPE_PTR: "ptr", - _TYPE_HINFO: "hinfo", - _TYPE_MINFO: "minfo", - _TYPE_MX: "mx", - _TYPE_TXT: "txt", - _TYPE_AAAA: "quada", - _TYPE_SRV: "srv", - _TYPE_ANY: "any", -} - -_HAS_A_TO_Z = re.compile(r'[A-Za-z]') -_HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') -_HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') -_HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') - -_EXPIRE_FULL_TIME_PERCENT = 100 -_EXPIRE_STALE_TIME_PERCENT = 50 -_EXPIRE_REFRESH_TIME_PERCENT = 75 - -_LOCAL_TRAILER = '.local.' -_TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' -_NONTCP_PROTOCOL_LOCAL_TRAILER = '._udp.local.' - -# https://datatracker.ietf.org/doc/html/rfc6763#section-9 -_SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local." - -try: - _IPPROTO_IPV6 = socket.IPPROTO_IPV6 -except AttributeError: - # Sigh: https://bugs.python.org/issue29515 - _IPPROTO_IPV6 = 41 int2byte = struct.Struct(">B").pack diff --git a/zeroconf/const.py b/zeroconf/const.py new file mode 100644 index 000000000..365fee098 --- /dev/null +++ b/zeroconf/const.py @@ -0,0 +1,145 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import re +import socket + +# Some timing constants + +_UNREGISTER_TIME = 125 # ms +_CHECK_TIME = 175 # ms +_REGISTER_TIME = 225 # ms +_LISTENER_TIME = 200 # ms +_BROWSER_TIME = 1000 # ms +_BROWSER_BACKOFF_LIMIT = 3600 # s +_CACHE_CLEANUP_INTERVAL = 10000 # ms + +# Some DNS constants + +_MDNS_ADDR = '224.0.0.251' +_MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) +_MDNS_ADDR6 = 'ff02::fb' +_MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) +_MDNS_PORT = 5353 +_DNS_PORT = 53 +_DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 +_DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per RFC6762 + +_MAX_MSG_TYPICAL = 1460 # unused +_MAX_MSG_ABSOLUTE = 8966 + +_FLAGS_QR_MASK = 0x8000 # query response mask +_FLAGS_QR_QUERY = 0x0000 # query +_FLAGS_QR_RESPONSE = 0x8000 # response + +_FLAGS_AA = 0x0400 # Authoritative answer +_FLAGS_TC = 0x0200 # Truncated +_FLAGS_RD = 0x0100 # Recursion desired +_FLAGS_RA = 0x8000 # Recursion available + +_FLAGS_Z = 0x0040 # Zero +_FLAGS_AD = 0x0020 # Authentic data +_FLAGS_CD = 0x0010 # Checking disabled + +_CLASS_IN = 1 +_CLASS_CS = 2 +_CLASS_CH = 3 +_CLASS_HS = 4 +_CLASS_NONE = 254 +_CLASS_ANY = 255 +_CLASS_MASK = 0x7FFF +_CLASS_UNIQUE = 0x8000 + +_TYPE_A = 1 +_TYPE_NS = 2 +_TYPE_MD = 3 +_TYPE_MF = 4 +_TYPE_CNAME = 5 +_TYPE_SOA = 6 +_TYPE_MB = 7 +_TYPE_MG = 8 +_TYPE_MR = 9 +_TYPE_NULL = 10 +_TYPE_WKS = 11 +_TYPE_PTR = 12 +_TYPE_HINFO = 13 +_TYPE_MINFO = 14 +_TYPE_MX = 15 +_TYPE_TXT = 16 +_TYPE_AAAA = 28 +_TYPE_SRV = 33 +_TYPE_ANY = 255 + +# Mapping constants to names + +_CLASSES = { + _CLASS_IN: "in", + _CLASS_CS: "cs", + _CLASS_CH: "ch", + _CLASS_HS: "hs", + _CLASS_NONE: "none", + _CLASS_ANY: "any", +} + +_TYPES = { + _TYPE_A: "a", + _TYPE_NS: "ns", + _TYPE_MD: "md", + _TYPE_MF: "mf", + _TYPE_CNAME: "cname", + _TYPE_SOA: "soa", + _TYPE_MB: "mb", + _TYPE_MG: "mg", + _TYPE_MR: "mr", + _TYPE_NULL: "null", + _TYPE_WKS: "wks", + _TYPE_PTR: "ptr", + _TYPE_HINFO: "hinfo", + _TYPE_MINFO: "minfo", + _TYPE_MX: "mx", + _TYPE_TXT: "txt", + _TYPE_AAAA: "quada", + _TYPE_SRV: "srv", + _TYPE_ANY: "any", +} + +_HAS_A_TO_Z = re.compile(r'[A-Za-z]') +_HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') +_HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') +_HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') + +_EXPIRE_FULL_TIME_PERCENT = 100 +_EXPIRE_STALE_TIME_PERCENT = 50 +_EXPIRE_REFRESH_TIME_PERCENT = 75 + +_LOCAL_TRAILER = '.local.' +_TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' +_NONTCP_PROTOCOL_LOCAL_TRAILER = '._udp.local.' + +# https://datatracker.ietf.org/doc/html/rfc6763#section-9 +_SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local." + +try: + _IPPROTO_IPV6 = socket.IPPROTO_IPV6 +except AttributeError: + # Sigh: https://bugs.python.org/issue29515 + _IPPROTO_IPV6 = 41 From 5100506f896b649e6a6a8e2efb592362cd2644d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 11:27:38 -1000 Subject: [PATCH 0277/1433] Move exceptions into zeroconf.exceptions (#532) --- zeroconf/__init__.py | 41 +++++++++------------------------ zeroconf/exceptions.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 31 deletions(-) create mode 100644 zeroconf/exceptions.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 46251828f..90439eab0 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -91,6 +91,16 @@ _TYPE_TXT, _UNREGISTER_TIME, ) +from .exceptions import ( + AbstractMethodException, + BadTypeInNameException, + Error, + IncomingDecodeError, + NamePartTooLongException, + NonUniqueNameException, + ServiceNameAlreadyRegistered, +) + __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' @@ -306,37 +316,6 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: return info.name[: -len(service_name) - 1] -# Exceptions - - -class Error(Exception): - pass - - -class IncomingDecodeError(Error): - pass - - -class NonUniqueNameException(Error): - pass - - -class NamePartTooLongException(Error): - pass - - -class AbstractMethodException(Error): - pass - - -class BadTypeInNameException(Error): - pass - - -class ServiceNameAlreadyRegistered(Error): - pass - - # implementation classes diff --git a/zeroconf/exceptions.py b/zeroconf/exceptions.py new file mode 100644 index 000000000..ea4686595 --- /dev/null +++ b/zeroconf/exceptions.py @@ -0,0 +1,51 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +# Exceptions + + +class Error(Exception): + pass + + +class IncomingDecodeError(Error): + pass + + +class NonUniqueNameException(Error): + pass + + +class NamePartTooLongException(Error): + pass + + +class AbstractMethodException(Error): + pass + + +class BadTypeInNameException(Error): + pass + + +class ServiceNameAlreadyRegistered(Error): + pass From e2e4eede9117827f47c66a4852dd2d236b46ecda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 11:37:50 -1000 Subject: [PATCH 0278/1433] Move logger into zeroconf.logger (#533) --- zeroconf/__init__.py | 36 +-------------------------- zeroconf/logger.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 zeroconf/logger.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 90439eab0..212b4b7bd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -24,7 +24,6 @@ import errno import ipaddress import itertools -import logging import platform import select import socket @@ -100,7 +99,7 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) - +from .logger import QuietLogger, log __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' @@ -129,12 +128,6 @@ ''' ) -log = logging.getLogger(__name__) -log.addHandler(logging.NullHandler()) - -if log.level == logging.NOTSET: - log.setLevel(logging.WARN) - int2byte = struct.Struct(">B").pack @@ -319,33 +312,6 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: # implementation classes -class QuietLogger: - _seen_logs = {} # type: Dict[str, Union[int, tuple]] - - @classmethod - def log_exception_warning(cls, *logger_data: Any) -> None: - exc_info = sys.exc_info() - exc_str = str(exc_info[1]) - if exc_str not in cls._seen_logs: - # log at warning level the first time this is seen - cls._seen_logs[exc_str] = exc_info - logger = log.warning - else: - logger = log.debug - logger(*(logger_data or ['Exception occurred']), exc_info=True) - - @classmethod - def log_warning_once(cls, *args: Any) -> None: - msg_str = args[0] - if msg_str not in cls._seen_logs: - cls._seen_logs[msg_str] = 0 - logger = log.warning - else: - logger = log.debug - cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 - logger(*args) - - class DNSEntry: """A DNS entry""" diff --git a/zeroconf/logger.py b/zeroconf/logger.py new file mode 100644 index 000000000..b7cb745a6 --- /dev/null +++ b/zeroconf/logger.py @@ -0,0 +1,58 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import logging +import sys +from typing import Any, Dict, Union, cast + +log = logging.getLogger(__name__.split('.')[0]) +log.addHandler(logging.NullHandler()) + +if log.level == logging.NOTSET: + log.setLevel(logging.WARN) + + +class QuietLogger: + _seen_logs = {} # type: Dict[str, Union[int, tuple]] + + @classmethod + def log_exception_warning(cls, *logger_data: Any) -> None: + exc_info = sys.exc_info() + exc_str = str(exc_info[1]) + if exc_str not in cls._seen_logs: + # log at warning level the first time this is seen + cls._seen_logs[exc_str] = exc_info + logger = log.warning + else: + logger = log.debug + logger(*(logger_data or ['Exception occurred']), exc_info=True) + + @classmethod + def log_warning_once(cls, *args: Any) -> None: + msg_str = args[0] + if msg_str not in cls._seen_logs: + cls._seen_logs[msg_str] = 0 + logger = log.warning + else: + logger = log.debug + cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 + logger(*args) From 328c1b9acdcd5cafa2df3e5b4b833b908d299500 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 11:58:09 -1000 Subject: [PATCH 0279/1433] Add missing coverage for QuietLogger (#534) --- tests/test_logger.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/test_logger.py diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 000000000..52bf830f9 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for logger.py.""" + +from unittest.mock import patch +from zeroconf.logger import QuietLogger + + +def test_log_warning_once(): + """Test we only log with warning level once.""" + quiet_logger = QuietLogger() + with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( + "zeroconf.logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_warning_once("the warning") + + assert mock_log_warning.mock_calls + assert not mock_log_debug.mock_calls + + with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( + "zeroconf.logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_warning_once("the warning") + + assert not mock_log_warning.mock_calls + assert mock_log_debug.mock_calls + + +def test_log_exception_warning(): + """Test we only log with warning level once.""" + quiet_logger = QuietLogger() + with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( + "zeroconf.logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_exception_warning("the exception warning") + + assert mock_log_warning.mock_calls + assert not mock_log_debug.mock_calls + + with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( + "zeroconf.logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_exception_warning("the exception warning") + + assert not mock_log_warning.mock_calls + assert mock_log_debug.mock_calls From 2976cc2001cbba2c0afc57b9a3d301f382ddac8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 12:20:23 -1000 Subject: [PATCH 0280/1433] Avoid making DNSOutgoing aware of the Zeroconf object (#535) - This is not a breaking change since this code has not yet shipped --- zeroconf/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 212b4b7bd..8083dbb05 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -880,22 +880,22 @@ def add_additional_answer(self, record: DNSRecord) -> None: self.additionals.append(record) def add_question_or_one_cache( - self, zc: "Zeroconf", now: float, name: str, type_: int, class_: int + self, cache: "DNSCache", now: float, name: str, type_: int, class_: int ) -> None: """Add a question if it is not already cached.""" - cached_entry = zc.cache.get_by_details(name, type_, class_) + cached_entry = cache.get_by_details(name, type_, class_) if not cached_entry: self.add_question(DNSQuestion(name, type_, class_)) else: self.add_answer_at_time(cached_entry, now) def add_question_or_all_cache( - self, zc: "Zeroconf", now: float, name: str, type_: int, class_: int + self, cache: "DNSCache", now: float, name: str, type_: int, class_: int ) -> None: """Add a question if it is not already cached. This is currently only used for IPv6 addresses. """ - cached_entries = zc.cache.get_all_by_details(name, type_, class_) + cached_entries = cache.get_all_by_details(name, type_, class_) if not cached_entries: self.add_question(DNSQuestion(name, type_, class_)) return @@ -2131,10 +2131,10 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question_or_one_cache(zc, now, self.name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(zc, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_one_cache(zc, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc, now, self.server, _TYPE_AAAA, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) return out def __eq__(self, other: object) -> bool: From 7ff810a02e608fae39634be09d6c3ce0a93485b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 12:30:21 -1000 Subject: [PATCH 0281/1433] Move time utility functions into zeroconf.utils.time (#536) --- zeroconf/__init__.py | 11 +---------- zeroconf/aio.py | 3 +-- zeroconf/utils/time.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 zeroconf/utils/time.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8083dbb05..ef5168a43 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -100,6 +100,7 @@ ServiceNameAlreadyRegistered, ) from .logger import QuietLogger, log +from .utils.time import current_time_millis, millis_to_seconds __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' @@ -158,16 +159,6 @@ class IPVersion(enum.Enum): # utility functions -def current_time_millis() -> float: - """Current system time in milliseconds""" - return time.time() * 1000 - - -def millis_to_seconds(millis: float) -> float: - """Convert milliseconds to seconds.""" - return millis / 1000.0 - - def _is_v6_address(addr: bytes) -> bool: return len(addr) == 16 diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 92e112285..55c4c2cb3 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -42,11 +42,10 @@ _REGISTER_TIME, _ServiceBrowserBase, _UNREGISTER_TIME, - current_time_millis, instance_name_from_service_info, - millis_to_seconds, ) from .utils.aio import wait_condition_or_timeout +from .utils.time import current_time_millis, millis_to_seconds def _get_best_available_queue() -> queue.Queue: diff --git a/zeroconf/utils/time.py b/zeroconf/utils/time.py new file mode 100644 index 000000000..0ba91ead8 --- /dev/null +++ b/zeroconf/utils/time.py @@ -0,0 +1,34 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + + +import time + + +def current_time_millis() -> float: + """Current system time in milliseconds""" + return time.time() * 1000 + + +def millis_to_seconds(millis: float) -> float: + """Convert milliseconds to seconds.""" + return millis / 1000.0 From 5af3eb58bfdc1736e6db175c4c6f7c6f2c05b694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 13:00:13 -1000 Subject: [PATCH 0282/1433] Breakout network utils into zeroconf.utils.net (#537) --- zeroconf/__init__.py | 346 ++------------------------------------- zeroconf/aio.py | 3 +- zeroconf/utils/net.py | 365 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 334 deletions(-) create mode 100644 zeroconf/utils/net.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ef5168a43..f04b98b01 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -22,7 +22,6 @@ import enum import errno -import ipaddress import itertools import platform import select @@ -37,8 +36,6 @@ from typing import Dict, Iterable, List, Optional, Type, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints -import ifaddr - from .const import ( # noqa # import needed for backwards compat _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -100,6 +97,20 @@ ServiceNameAlreadyRegistered, ) from .logger import QuietLogger, log +from .utils.net import ( # noqa # import needed for backwards compat + add_multicast_member, + can_send_to, + autodetect_ip_version, + create_sockets, + get_all_addresses_v6, + InterfaceChoice, + InterfacesType, + ServiceStateChange, + IPVersion, + _is_v6_address, + _encode_address, + get_all_addresses, +) from .utils.time import current_time_millis, millis_to_seconds __author__ = 'Paul Scott-Murphy, William McBrine' @@ -132,43 +143,9 @@ int2byte = struct.Struct(">B").pack - -@enum.unique -class InterfaceChoice(enum.Enum): - Default = 1 - All = 2 - - -InterfacesType = Union[List[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] - - -@enum.unique -class ServiceStateChange(enum.Enum): - Added = 1 - Removed = 2 - Updated = 3 - - -@enum.unique -class IPVersion(enum.Enum): - V4Only = 1 - V6Only = 2 - All = 3 - - # utility functions -def _is_v6_address(addr: bytes) -> bool: - return len(addr) == 16 - - -def _encode_address(address: str) -> bytes: - is_ipv6 = ':' in address - address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET - return socket.inet_pton(address_family, address) - - def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches """ Validate a fully qualified service name, instance or subtype. [rfc6763] @@ -2205,301 +2182,6 @@ def find( return tuple(sorted(listener.found_services)) -def get_all_addresses() -> List[str]: - return list(set(addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4)) - - -def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: - # IPv6 multicast uses positive indexes for interfaces - # TODO: What about multi-address interfaces? - return list( - set((addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6) - ) - - -def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: - ipaddr = ipaddress.ip_address(ip) - for adapter in adapters: - for adapter_ip in adapter.ips: - # IPv6 addresses are represented as tuples - if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index)) - - raise RuntimeError('No adapter found for IP address %s' % ip) - - -def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: - for adapter in adapters: - if adapter.index == index: - for adapter_ip in adapter.ips: - # IPv6 addresses are represented as tuples - if isinstance(adapter_ip.ip, tuple): - return cast(Tuple[str, int, int], adapter_ip.ip) - - raise RuntimeError('No adapter found for index %s' % index) - - -def ip6_addresses_to_indexes( - interfaces: List[Union[str, int, Tuple[Tuple[str, int, int], int]]] -) -> List[Tuple[Tuple[str, int, int], int]]: - """Convert IPv6 interface addresses to interface indexes. - - IPv4 addresses are ignored. - - :param interfaces: List of IP addresses and indexes. - :returns: List of indexes. - """ - result = [] - adapters = ifaddr.get_adapters() - - for iface in interfaces: - if isinstance(iface, int): - result.append((interface_index_to_ip6_address(adapters, iface), iface)) - elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: - result.append(ip6_to_address_and_index(adapters, iface)) - - return result - - -def normalize_interface_choice( - choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only -) -> List[Union[str, Tuple[Tuple[str, int, int], int]]]: - """Convert the interfaces choice into internal representation. - - :param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only). - :param ip_address: IP version to use (ignored if `choice` is a list). - :returns: List of IP addresses (for IPv4) and indexes (for IPv6). - """ - result = [] # type: List[Union[str, Tuple[Tuple[str, int, int], int]]] - if choice is InterfaceChoice.Default: - if ip_version != IPVersion.V4Only: - # IPv6 multicast uses interface 0 to mean the default - result.append((('', 0, 0), 0)) - if ip_version != IPVersion.V6Only: - result.append('0.0.0.0') - elif choice is InterfaceChoice.All: - if ip_version != IPVersion.V4Only: - result.extend(get_all_addresses_v6()) - if ip_version != IPVersion.V6Only: - result.extend(get_all_addresses()) - if not result: - raise RuntimeError( - 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version - ) - elif isinstance(choice, list): - # First, take IPv4 addresses. - result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] - # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. - result += ip6_addresses_to_indexes(choice) - else: - raise TypeError("choice must be a list or InterfaceChoice, got %r" % choice) - return result - - -def new_socket( # pylint: disable=too-many-branches - bind_addr: Union[Tuple[str], Tuple[str, int, int]], - port: int = _MDNS_PORT, - ip_version: IPVersion = IPVersion.V4Only, - apple_p2p: bool = False, -) -> socket.socket: - log.debug( - 'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r', - port, - ip_version, - apple_p2p, - bind_addr, - ) - if ip_version == IPVersion.V4Only: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - else: - s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) - - if ip_version == IPVersion.All: - # make V6 sockets work for both V4 and V6 (required for Windows) - try: - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) - except OSError: - log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') - raise - - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - # SO_REUSEADDR should be equivalent to SO_REUSEPORT for - # multicast UDP sockets (p 731, "TCP/IP Illustrated, - # Volume 2"), but some BSD-derived systems require - # SO_REUSEPORT to be specified explicitly. Also, not all - # versions of Python have SO_REUSEPORT available. - # Catch OSError and socket.error for kernel versions <3.9 because lacking - # SO_REUSEPORT support. - try: - reuseport = socket.SO_REUSEPORT - except AttributeError: - pass - else: - try: - s.setsockopt(socket.SOL_SOCKET, reuseport, 1) - except OSError as err: - if err.errno != errno.ENOPROTOOPT: - raise - - if port == _MDNS_PORT: - ttl = struct.pack(b'B', 255) - loop = struct.pack(b'B', 1) - if ip_version != IPVersion.V6Only: - # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and - # IP_MULTICAST_LOOP socket options as an unsigned char. - try: - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) - except socket.error as e: - if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS - raise - if ip_version != IPVersion.V4Only: - # However, char doesn't work here (at least on Linux) - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) - - if apple_p2p: - # SO_RECV_ANYIF = 0x1104 - # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h - s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) - - s.bind((bind_addr[0], port, *bind_addr[1:])) - log.debug('Created socket %s', s) - return s - - -def add_multicast_member( - listen_socket: socket.socket, - interface: Union[str, Tuple[Tuple[str, int, int], int]], -) -> bool: - # This is based on assumptions in normalize_interface_choice - is_v6 = isinstance(interface, tuple) - err_einval = {errno.EINVAL} - if sys.platform == 'win32': - # No WSAEINVAL definition in typeshed - err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member - log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) - try: - if is_v6: - iface_bin = struct.pack('@I', cast(int, interface[1])) - _value = _MDNS_ADDR6_BYTES + iface_bin - listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) - else: - _value = _MDNS_ADDR_BYTES + socket.inet_aton(cast(str, interface)) - listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) - except socket.error as e: - _errno = get_errno(e) - if _errno == errno.EADDRINUSE: - log.info( - 'Address in use when adding %s to multicast group, ' - 'it is expected to happen on some systems', - interface, - ) - return False - if _errno == errno.EADDRNOTAVAIL: - log.info( - 'Address not available when adding %s to multicast ' - 'group, it is expected to happen on some systems', - interface, - ) - return False - if _errno in err_einval: - log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) - return False - raise - return True - - -def new_respond_socket( - interface: Union[str, Tuple[Tuple[str, int, int], int]], - apple_p2p: bool = False, -) -> Optional[socket.socket]: - is_v6 = isinstance(interface, tuple) - respond_socket = new_socket( - ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), - apple_p2p=apple_p2p, - bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), - ) - log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) - if is_v6: - iface_bin = struct.pack('@I', cast(int, interface[1])) - respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) - else: - respond_socket.setsockopt( - socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface)) - ) - return respond_socket - - -def create_sockets( - interfaces: InterfacesType = InterfaceChoice.All, - unicast: bool = False, - ip_version: IPVersion = IPVersion.V4Only, - apple_p2p: bool = False, -) -> Tuple[Optional[socket.socket], List[socket.socket]]: - if unicast: - listen_socket = None - else: - listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=('',)) - - normalized_interfaces = normalize_interface_choice(interfaces, ip_version) - - # If we are using InterfaceChoice.Default we can use - # a single socket to listen and respond. - if not unicast and interfaces is InterfaceChoice.Default: - for i in normalized_interfaces: - add_multicast_member(cast(socket.socket, listen_socket), i) - return listen_socket, [cast(socket.socket, listen_socket)] - - respond_sockets = [] - - for i in normalized_interfaces: - if not unicast: - if add_multicast_member(cast(socket.socket, listen_socket), i): - respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) - else: - respond_socket = None - else: - respond_socket = new_socket( - port=0, - ip_version=ip_version, - apple_p2p=apple_p2p, - bind_addr=i[0] if isinstance(i, tuple) else (i,), - ) - - if respond_socket is not None: - respond_sockets.append(respond_socket) - - return listen_socket, respond_sockets - - -def get_errno(e: Exception) -> int: - assert isinstance(e, socket.error) - return cast(int, e.args[0]) - - -def can_send_to(sock: socket.socket, address: str) -> bool: - addr = ipaddress.ip_address(address) - return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) - - -def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: - """Auto detect the IP version when it is not provided.""" - if isinstance(interfaces, list): - has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - for i in interfaces - ) - has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) - if has_v4 and has_v6: - return IPVersion.All - if has_v6: - return IPVersion.V6Only - - return IPVersion.V4Only - - class ServiceRegistry: """A registry to keep track of services. diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 55c4c2cb3..1df182432 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -29,8 +29,6 @@ from . import ( DNSOutgoing, IPVersion, - InterfaceChoice, - InterfacesType, NonUniqueNameException, NotifyListener, ServiceInfo, @@ -45,6 +43,7 @@ instance_name_from_service_info, ) from .utils.aio import wait_condition_or_timeout +from .utils.net import InterfaceChoice, InterfacesType from .utils.time import current_time_millis, millis_to_seconds diff --git a/zeroconf/utils/net.py b/zeroconf/utils/net.py new file mode 100644 index 000000000..5ea499241 --- /dev/null +++ b/zeroconf/utils/net.py @@ -0,0 +1,365 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import enum +import errno +import ipaddress +import socket +import struct +import sys +from typing import Any, List, Optional, Tuple, Union, cast + +import ifaddr + +from ..const import _IPPROTO_IPV6, _MDNS_ADDR6_BYTES, _MDNS_ADDR_BYTES, _MDNS_PORT +from ..logger import log + + +@enum.unique +class InterfaceChoice(enum.Enum): + Default = 1 + All = 2 + + +InterfacesType = Union[List[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] + + +@enum.unique +class ServiceStateChange(enum.Enum): + Added = 1 + Removed = 2 + Updated = 3 + + +@enum.unique +class IPVersion(enum.Enum): + V4Only = 1 + V6Only = 2 + All = 3 + + +# utility functions + + +def _is_v6_address(addr: bytes) -> bool: + return len(addr) == 16 + + +def _encode_address(address: str) -> bytes: + is_ipv6 = ':' in address + address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET + return socket.inet_pton(address_family, address) + + +def get_all_addresses() -> List[str]: + return list(set(addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4)) + + +def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: + # IPv6 multicast uses positive indexes for interfaces + # TODO: What about multi-address interfaces? + return list( + set((addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6) + ) + + +def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: + ipaddr = ipaddress.ip_address(ip) + for adapter in adapters: + for adapter_ip in adapter.ips: + # IPv6 addresses are represented as tuples + if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: + return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index)) + + raise RuntimeError('No adapter found for IP address %s' % ip) + + +def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: + for adapter in adapters: + if adapter.index == index: + for adapter_ip in adapter.ips: + # IPv6 addresses are represented as tuples + if isinstance(adapter_ip.ip, tuple): + return cast(Tuple[str, int, int], adapter_ip.ip) + + raise RuntimeError('No adapter found for index %s' % index) + + +def ip6_addresses_to_indexes( + interfaces: List[Union[str, int, Tuple[Tuple[str, int, int], int]]] +) -> List[Tuple[Tuple[str, int, int], int]]: + """Convert IPv6 interface addresses to interface indexes. + + IPv4 addresses are ignored. + + :param interfaces: List of IP addresses and indexes. + :returns: List of indexes. + """ + result = [] + adapters = ifaddr.get_adapters() + + for iface in interfaces: + if isinstance(iface, int): + result.append((interface_index_to_ip6_address(adapters, iface), iface)) + elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: + result.append(ip6_to_address_and_index(adapters, iface)) + + return result + + +def normalize_interface_choice( + choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only +) -> List[Union[str, Tuple[Tuple[str, int, int], int]]]: + """Convert the interfaces choice into internal representation. + + :param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only). + :param ip_address: IP version to use (ignored if `choice` is a list). + :returns: List of IP addresses (for IPv4) and indexes (for IPv6). + """ + result = [] # type: List[Union[str, Tuple[Tuple[str, int, int], int]]] + if choice is InterfaceChoice.Default: + if ip_version != IPVersion.V4Only: + # IPv6 multicast uses interface 0 to mean the default + result.append((('', 0, 0), 0)) + if ip_version != IPVersion.V6Only: + result.append('0.0.0.0') + elif choice is InterfaceChoice.All: + if ip_version != IPVersion.V4Only: + result.extend(get_all_addresses_v6()) + if ip_version != IPVersion.V6Only: + result.extend(get_all_addresses()) + if not result: + raise RuntimeError( + 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version + ) + elif isinstance(choice, list): + # First, take IPv4 addresses. + result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] + # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. + result += ip6_addresses_to_indexes(choice) + else: + raise TypeError("choice must be a list or InterfaceChoice, got %r" % choice) + return result + + +def new_socket( # pylint: disable=too-many-branches + bind_addr: Union[Tuple[str], Tuple[str, int, int]], + port: int = _MDNS_PORT, + ip_version: IPVersion = IPVersion.V4Only, + apple_p2p: bool = False, +) -> socket.socket: + log.debug( + 'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r', + port, + ip_version, + apple_p2p, + bind_addr, + ) + if ip_version == IPVersion.V4Only: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + else: + s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + + if ip_version == IPVersion.All: + # make V6 sockets work for both V4 and V6 (required for Windows) + try: + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) + except OSError: + log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') + raise + + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # SO_REUSEADDR should be equivalent to SO_REUSEPORT for + # multicast UDP sockets (p 731, "TCP/IP Illustrated, + # Volume 2"), but some BSD-derived systems require + # SO_REUSEPORT to be specified explicitly. Also, not all + # versions of Python have SO_REUSEPORT available. + # Catch OSError and socket.error for kernel versions <3.9 because lacking + # SO_REUSEPORT support. + try: + reuseport = socket.SO_REUSEPORT + except AttributeError: + pass + else: + try: + s.setsockopt(socket.SOL_SOCKET, reuseport, 1) + except OSError as err: + if err.errno != errno.ENOPROTOOPT: + raise + + if port == _MDNS_PORT: + ttl = struct.pack(b'B', 255) + loop = struct.pack(b'B', 1) + if ip_version != IPVersion.V6Only: + # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and + # IP_MULTICAST_LOOP socket options as an unsigned char. + try: + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + except socket.error as e: + if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS + raise + if ip_version != IPVersion.V4Only: + # However, char doesn't work here (at least on Linux) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + + if apple_p2p: + # SO_RECV_ANYIF = 0x1104 + # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h + s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + + s.bind((bind_addr[0], port, *bind_addr[1:])) + log.debug('Created socket %s', s) + return s + + +def add_multicast_member( + listen_socket: socket.socket, + interface: Union[str, Tuple[Tuple[str, int, int], int]], +) -> bool: + # This is based on assumptions in normalize_interface_choice + is_v6 = isinstance(interface, tuple) + err_einval = {errno.EINVAL} + if sys.platform == 'win32': + # No WSAEINVAL definition in typeshed + err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member + log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) + try: + if is_v6: + iface_bin = struct.pack('@I', cast(int, interface[1])) + _value = _MDNS_ADDR6_BYTES + iface_bin + listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) + else: + _value = _MDNS_ADDR_BYTES + socket.inet_aton(cast(str, interface)) + listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) + except socket.error as e: + _errno = get_errno(e) + if _errno == errno.EADDRINUSE: + log.info( + 'Address in use when adding %s to multicast group, ' + 'it is expected to happen on some systems', + interface, + ) + return False + if _errno == errno.EADDRNOTAVAIL: + log.info( + 'Address not available when adding %s to multicast ' + 'group, it is expected to happen on some systems', + interface, + ) + return False + if _errno in err_einval: + log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) + return False + raise + return True + + +def new_respond_socket( + interface: Union[str, Tuple[Tuple[str, int, int], int]], + apple_p2p: bool = False, +) -> Optional[socket.socket]: + is_v6 = isinstance(interface, tuple) + respond_socket = new_socket( + ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), + apple_p2p=apple_p2p, + bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), + ) + log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) + if is_v6: + iface_bin = struct.pack('@I', cast(int, interface[1])) + respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) + else: + respond_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface)) + ) + return respond_socket + + +def create_sockets( + interfaces: InterfacesType = InterfaceChoice.All, + unicast: bool = False, + ip_version: IPVersion = IPVersion.V4Only, + apple_p2p: bool = False, +) -> Tuple[Optional[socket.socket], List[socket.socket]]: + if unicast: + listen_socket = None + else: + listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=('',)) + + normalized_interfaces = normalize_interface_choice(interfaces, ip_version) + + # If we are using InterfaceChoice.Default we can use + # a single socket to listen and respond. + if not unicast and interfaces is InterfaceChoice.Default: + for i in normalized_interfaces: + add_multicast_member(cast(socket.socket, listen_socket), i) + return listen_socket, [cast(socket.socket, listen_socket)] + + respond_sockets = [] + + for i in normalized_interfaces: + if not unicast: + if add_multicast_member(cast(socket.socket, listen_socket), i): + respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) + else: + respond_socket = None + else: + respond_socket = new_socket( + port=0, + ip_version=ip_version, + apple_p2p=apple_p2p, + bind_addr=i[0] if isinstance(i, tuple) else (i,), + ) + + if respond_socket is not None: + respond_sockets.append(respond_socket) + + return listen_socket, respond_sockets + + +def get_errno(e: Exception) -> int: + assert isinstance(e, socket.error) + return cast(int, e.args[0]) + + +def can_send_to(sock: socket.socket, address: str) -> bool: + addr = ipaddress.ip_address(address) + return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) + + +def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: + """Auto detect the IP version when it is not provided.""" + if isinstance(interfaces, list): + has_v6 = any( + isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + for i in interfaces + ) + has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) + if has_v4 and has_v6: + return IPVersion.All + if has_v6: + return IPVersion.V6Only + + return IPVersion.V4Only From 6af42b54640ebba541302bfcf7688b3926453b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 13:11:12 -1000 Subject: [PATCH 0283/1433] Move int2byte to zeroconf.utils.struct (#540) --- zeroconf/__init__.py | 3 +-- zeroconf/utils/struct.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 zeroconf/utils/struct.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f04b98b01..1b335679b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -111,6 +111,7 @@ _encode_address, get_all_addresses, ) +from .utils.struct import int2byte from .utils.time import current_time_millis, millis_to_seconds __author__ = 'Paul Scott-Murphy, William McBrine' @@ -141,8 +142,6 @@ ) -int2byte = struct.Struct(">B").pack - # utility functions diff --git a/zeroconf/utils/struct.py b/zeroconf/utils/struct.py new file mode 100644 index 000000000..6ec999882 --- /dev/null +++ b/zeroconf/utils/struct.py @@ -0,0 +1,25 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import struct + +int2byte = struct.Struct(">B").pack From 8733cad2eae71ebdf94ecadc6fd5439882477235 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 13:15:37 -1000 Subject: [PATCH 0284/1433] Update zeroconf.aio import locations (#539) --- zeroconf/aio.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 1df182432..9a23be930 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -28,22 +28,16 @@ from . import ( DNSOutgoing, - IPVersion, - NonUniqueNameException, NotifyListener, ServiceInfo, Zeroconf, - _BROWSER_TIME, - _CHECK_TIME, - _LISTENER_TIME, - _MDNS_PORT, - _REGISTER_TIME, _ServiceBrowserBase, - _UNREGISTER_TIME, instance_name_from_service_info, ) +from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME +from .exceptions import NonUniqueNameException from .utils.aio import wait_condition_or_timeout -from .utils.net import InterfaceChoice, InterfacesType +from .utils.net import IPVersion, InterfaceChoice, InterfacesType from .utils.time import current_time_millis, millis_to_seconds From 1e3e7df8b7fdacd90cf5d864411e5db5a915be94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 13:22:45 -1000 Subject: [PATCH 0285/1433] Relocate DNS classes to zeroconf.dns (#541) --- zeroconf/__init__.py | 996 +--------------------------------------- zeroconf/dns.py | 1030 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1045 insertions(+), 981 deletions(-) create mode 100644 zeroconf/dns.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1b335679b..2ae88431d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -20,20 +20,18 @@ USA """ -import enum import errno import itertools import platform import select import socket -import struct import sys import threading import time import warnings from collections import OrderedDict from types import TracebackType # noqa # used in type hints -from typing import Dict, Iterable, List, Optional, Type, Union, cast +from typing import Dict, List, Optional, Type, Union, cast from typing import Any, Callable, Set, Tuple # noqa # used in type hints from .const import ( # noqa # import needed for backwards compat @@ -87,7 +85,20 @@ _TYPE_TXT, _UNREGISTER_TIME, ) -from .exceptions import ( +from .dns import ( # noqa # import needed for backwards compat + DNSAddress, + DNSCache, + DNSEntry, + DNSHinfo, + DNSIncoming, + DNSOutgoing, + DNSPointer, + DNSQuestion, + DNSRecord, + DNSService, + DNSText, +) +from .exceptions import ( # noqa # import needed for backwards compat AbstractMethodException, BadTypeInNameException, Error, @@ -279,983 +290,6 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: # implementation classes -class DNSEntry: - - """A DNS entry""" - - def __init__(self, name: str, type_: int, class_: int) -> None: - self.key = name.lower() - self.name = name - self.type = type_ - self.class_ = class_ & _CLASS_MASK - self.unique = (class_ & _CLASS_UNIQUE) != 0 - - def __eq__(self, other: Any) -> bool: - """Equality test on key (lowercase name), type, and class""" - return ( - self.key == other.key - and self.type == other.type - and self.class_ == other.class_ - and isinstance(other, DNSEntry) - ) - - @staticmethod - def get_class_(class_: int) -> str: - """Class accessor""" - return _CLASSES.get(class_, "?(%s)" % class_) - - @staticmethod - def get_type(t: int) -> str: - """Type accessor""" - return _TYPES.get(t, "?(%s)" % t) - - def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: - """String representation with additional information""" - result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) - if self.unique: - result += "-unique," - else: - result += "," - result += self.name - if other is not None: - result += "]=%s" % cast(Any, other) - else: - result += "]" - return result - - -class DNSQuestion(DNSEntry): - - """A DNS question entry""" - - def __init__(self, name: str, type_: int, class_: int) -> None: - DNSEntry.__init__(self, name, type_, class_) - - def answered_by(self, rec: 'DNSRecord') -> bool: - """Returns true if the question is answered by the record""" - return ( - self.class_ == rec.class_ - and (self.type == rec.type or self.type == _TYPE_ANY) - and self.name == rec.name - ) - - def __repr__(self) -> str: - """String representation""" - return DNSEntry.entry_to_string(self, "question", None) - - -class DNSRecord(DNSEntry): - - """A DNS record - like a DNS entry, but has a TTL""" - - # TODO: Switch to just int ttl - def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) -> None: - DNSEntry.__init__(self, name, type_, class_) - self.ttl = ttl - self.created = current_time_millis() - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - - def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use - """Abstract method""" - raise AbstractMethodException - - def suppressed_by(self, msg: 'DNSIncoming') -> bool: - """Returns true if any answer in a message can suffice for the - information held in this record.""" - for record in msg.answers: - if self.suppressed_by_answer(record): - return True - return False - - def suppressed_by_answer(self, other: 'DNSRecord') -> bool: - """Returns true if another record has same name, type and class, - and if its TTL is at least half of this record's.""" - return self == other and other.ttl > (self.ttl / 2) - - def get_expiration_time(self, percent: int) -> float: - """Returns the time at which this record will have expired - by a certain percentage.""" - return self.created + (percent * self.ttl * 10) - - # TODO: Switch to just int here - def get_remaining_ttl(self, now: float) -> Union[int, float]: - """Returns the remaining TTL in seconds.""" - return max(0, millis_to_seconds(self._expiration_time - now)) - - def is_expired(self, now: float) -> bool: - """Returns true if this record has expired.""" - return self._expiration_time <= now - - def is_stale(self, now: float) -> bool: - """Returns true if this record is at least half way expired.""" - return self._stale_time <= now - - def reset_ttl(self, other: 'DNSRecord') -> None: - """Sets this record's TTL and created time to that of - another record.""" - self.created = other.created - self.ttl = other.ttl - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - - def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use - """Abstract method""" - raise AbstractMethodException - - def to_string(self, other: Union[bytes, str]) -> str: - """String representation with additional information""" - arg = "%s/%s,%s" % (self.ttl, int(self.get_remaining_ttl(current_time_millis())), cast(Any, other)) - return DNSEntry.entry_to_string(self, "record", arg) - - -class DNSAddress(DNSRecord): - - """A DNS address record""" - - def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) - self.address = address - - def write(self, out: 'DNSOutgoing') -> None: - """Used in constructing an outgoing packet""" - out.write_string(self.address) - - def __eq__(self, other: Any) -> bool: - """Tests equality on address""" - return ( - isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address - ) - - def __repr__(self) -> str: - """String representation""" - try: - return self.to_string( - socket.inet_ntop( - socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address - ) - ) - except (ValueError, OSError): - return self.to_string(str(self.address)) - - -class DNSHinfo(DNSRecord): - - """A DNS host information record""" - - def __init__(self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) - self.cpu = cpu - self.os = os - - def write(self, out: 'DNSOutgoing') -> None: - """Used in constructing an outgoing packet""" - out.write_character_string(self.cpu.encode('utf-8')) - out.write_character_string(self.os.encode('utf-8')) - - def __eq__(self, other: Any) -> bool: - """Tests equality on cpu and os""" - return ( - isinstance(other, DNSHinfo) - and DNSEntry.__eq__(self, other) - and self.cpu == other.cpu - and self.os == other.os - ) - - def __repr__(self) -> str: - """String representation""" - return self.to_string(self.cpu + " " + self.os) - - -class DNSPointer(DNSRecord): - - """A DNS pointer record""" - - def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) - self.alias = alias - - def write(self, out: 'DNSOutgoing') -> None: - """Used in constructing an outgoing packet""" - out.write_name(self.alias) - - def __eq__(self, other: Any) -> bool: - """Tests equality on alias""" - return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) - - def __repr__(self) -> str: - """String representation""" - return self.to_string(self.alias) - - -class DNSText(DNSRecord): - - """A DNS text record""" - - def __init__(self, name: str, type_: int, class_: int, ttl: int, text: bytes) -> None: - assert isinstance(text, (bytes, type(None))) - DNSRecord.__init__(self, name, type_, class_, ttl) - self.text = text - - def write(self, out: 'DNSOutgoing') -> None: - """Used in constructing an outgoing packet""" - out.write_string(self.text) - - def __eq__(self, other: Any) -> bool: - """Tests equality on text""" - return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) - - def __repr__(self) -> str: - """String representation""" - if len(self.text) > 10: - return self.to_string(self.text[:7]) + "..." - return self.to_string(self.text) - - -class DNSService(DNSRecord): - - """A DNS service record""" - - def __init__( - self, - name: str, - type_: int, - class_: int, - ttl: Union[float, int], - priority: int, - weight: int, - port: int, - server: str, - ) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) - self.priority = priority - self.weight = weight - self.port = port - self.server = server - - def write(self, out: 'DNSOutgoing') -> None: - """Used in constructing an outgoing packet""" - out.write_short(self.priority) - out.write_short(self.weight) - out.write_short(self.port) - out.write_name(self.server) - - def __eq__(self, other: Any) -> bool: - """Tests equality on priority, weight, port and server""" - return ( - isinstance(other, DNSService) - and self.priority == other.priority - and self.weight == other.weight - and self.port == other.port - and self.server == other.server - and DNSEntry.__eq__(self, other) - ) - - def __repr__(self) -> str: - """String representation""" - return self.to_string("%s:%s" % (self.server, self.port)) - - -class DNSMessage: - """A base class for DNS messages.""" - - def __init__(self, flags: int) -> None: - """Construct a DNS message.""" - self.flags = flags - - def is_query(self) -> bool: - """Returns true if this is a query.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def is_response(self) -> bool: - """Returns true if this is a response.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - - -class DNSIncoming(DNSMessage, QuietLogger): - - """Object representation of an incoming DNS packet""" - - def __init__(self, data: bytes) -> None: - """Constructor from string holding bytes of packet""" - super().__init__(0) - self.offset = 0 - self.data = data - self.questions = [] # type: List[DNSQuestion] - self.answers = [] # type: List[DNSRecord] - self.id = 0 - self.num_questions = 0 - self.num_answers = 0 - self.num_authorities = 0 - self.num_additionals = 0 - self.valid = False - - try: - self.read_header() - self.read_questions() - self.read_others() - self.valid = True - - except (IndexError, struct.error, IncomingDecodeError): - self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) - - def __repr__(self) -> str: - return '' % ', '.join( - [ - 'id=%s' % self.id, - 'flags=%s' % self.flags, - 'n_q=%s' % self.num_questions, - 'n_ans=%s' % self.num_answers, - 'n_auth=%s' % self.num_authorities, - 'n_add=%s' % self.num_additionals, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - ] - ) - - def unpack(self, format_: bytes) -> tuple: - length = struct.calcsize(format_) - info = struct.unpack(format_, self.data[self.offset : self.offset + length]) - self.offset += length - return info - - def read_header(self) -> None: - """Reads header portion of packet""" - ( - self.id, - self.flags, - self.num_questions, - self.num_answers, - self.num_authorities, - self.num_additionals, - ) = self.unpack(b'!6H') - - def read_questions(self) -> None: - """Reads questions section of packet""" - for _ in range(self.num_questions): - name = self.read_name() - type_, class_ = self.unpack(b'!HH') - - question = DNSQuestion(name, type_, class_) - self.questions.append(question) - - # def read_int(self): - # """Reads an integer from the packet""" - # return self.unpack(b'!I')[0] - - def read_character_string(self) -> bytes: - """Reads a character string from the packet""" - length = self.data[self.offset] - self.offset += 1 - return self.read_string(length) - - def read_string(self, length: int) -> bytes: - """Reads a string of a given length from the packet""" - info = self.data[self.offset : self.offset + length] - self.offset += length - return info - - def read_unsigned_short(self) -> int: - """Reads an unsigned short from the packet""" - return cast(int, self.unpack(b'!H')[0]) - - def read_others(self) -> None: - """Reads the answers, authorities and additionals section of the - packet""" - n = self.num_answers + self.num_authorities + self.num_additionals - for _ in range(n): - domain = self.read_name() - type_, class_, ttl, length = self.unpack(b'!HHiH') - - rec = None # type: Optional[DNSRecord] - if type_ == _TYPE_A: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) - elif type_ in (_TYPE_CNAME, _TYPE_PTR): - rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) - elif type_ == _TYPE_TXT: - rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) - elif type_ == _TYPE_SRV: - rec = DNSService( - domain, - type_, - class_, - ttl, - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_name(), - ) - elif type_ == _TYPE_HINFO: - rec = DNSHinfo( - domain, - type_, - class_, - ttl, - self.read_character_string().decode('utf-8'), - self.read_character_string().decode('utf-8'), - ) - elif type_ == _TYPE_AAAA: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) - else: - # Try to ignore types we don't know about - # Skip the payload for the resource record so the next - # records can be parsed correctly - self.offset += length - - if rec is not None: - self.answers.append(rec) - - def read_utf(self, offset: int, length: int) -> str: - """Reads a UTF-8 string of a given length from the packet""" - return str(self.data[offset : offset + length], 'utf-8', 'replace') - - def read_name(self) -> str: - """Reads a domain name from the packet""" - result = '' - off = self.offset - next_ = -1 - first = off - - while True: - length = self.data[off] - off += 1 - if length == 0: - break - t = length & 0xC0 - if t == 0x00: - result += self.read_utf(off, length) + '.' - off += length - elif t == 0xC0: - if next_ < 0: - next_ = off + 1 - off = ((length & 0x3F) << 8) | self.data[off] - if off >= first: - raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) - first = off - else: - raise IncomingDecodeError("Bad domain name at %s" % (off,)) - - if next_ >= 0: - self.offset = next_ - else: - self.offset = off - - return result - - -class DNSOutgoing(DNSMessage): - - """Object representation of an outgoing packet""" - - def __init__(self, flags: int, multicast: bool = True) -> None: - super().__init__(flags) - self.finished = False - self.id = 0 - self.multicast = multicast - self.packets_data = [] # type: List[bytes] - - # these 3 are per-packet -- see also reset_for_next_packet() - self.names = {} # type: Dict[str, int] - self.data = [] # type: List[bytes] - self.size = 12 - self.allow_long = True - - self.state = self.State.init - - self.questions = [] # type: List[DNSQuestion] - self.answers = [] # type: List[Tuple[DNSRecord, float]] - self.authorities = [] # type: List[DNSPointer] - self.additionals = [] # type: List[DNSRecord] - - def reset_for_next_packet(self) -> None: - self.names = {} - self.data = [] - self.size = 12 - self.allow_long = True - - def __repr__(self) -> str: - return '' % ', '.join( - [ - 'multicast=%s' % self.multicast, - 'flags=%s' % self.flags, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - 'authorities=%s' % self.authorities, - 'additionals=%s' % self.additionals, - ] - ) - - class State(enum.Enum): - init = 0 - finished = 1 - - def add_question(self, record: DNSQuestion) -> None: - """Adds a question""" - self.questions.append(record) - - def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: - """Adds an answer""" - if not record.suppressed_by(inp): - self.add_answer_at_time(record, 0) - - def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: - """Adds an answer if it does not expire by a certain time""" - if record is not None: - if now == 0 or not record.is_expired(now): - self.answers.append((record, now)) - - def add_authorative_answer(self, record: DNSPointer) -> None: - """Adds an authoritative answer""" - self.authorities.append(record) - - def add_additional_answer(self, record: DNSRecord) -> None: - """Adds an additional answer - - From: RFC 6763, DNS-Based Service Discovery, February 2013 - - 12. DNS Additional Record Generation - - DNS has an efficiency feature whereby a DNS server may place - additional records in the additional section of the DNS message. - These additional records are records that the client did not - explicitly request, but the server has reasonable grounds to expect - that the client might request them shortly, so including them can - save the client from having to issue additional queries. - - This section recommends which additional records SHOULD be generated - to improve network efficiency, for both Unicast and Multicast DNS-SD - responses. - - 12.1. PTR Records - - When including a DNS-SD Service Instance Enumeration or Selective - Instance Enumeration (subtype) PTR record in a response packet, the - server/responder SHOULD include the following additional records: - - o The SRV record(s) named in the PTR rdata. - o The TXT record(s) named in the PTR rdata. - o All address records (type "A" and "AAAA") named in the SRV rdata. - - 12.2. SRV Records - - When including an SRV record in a response packet, the - server/responder SHOULD include the following additional records: - - o All address records (type "A" and "AAAA") named in the SRV rdata. - - """ - self.additionals.append(record) - - def add_question_or_one_cache( - self, cache: "DNSCache", now: float, name: str, type_: int, class_: int - ) -> None: - """Add a question if it is not already cached.""" - cached_entry = cache.get_by_details(name, type_, class_) - if not cached_entry: - self.add_question(DNSQuestion(name, type_, class_)) - else: - self.add_answer_at_time(cached_entry, now) - - def add_question_or_all_cache( - self, cache: "DNSCache", now: float, name: str, type_: int, class_: int - ) -> None: - """Add a question if it is not already cached. - This is currently only used for IPv6 addresses. - """ - cached_entries = cache.get_all_by_details(name, type_, class_) - if not cached_entries: - self.add_question(DNSQuestion(name, type_, class_)) - return - for cached_entry in cached_entries: - self.add_answer_at_time(cached_entry, now) - - def pack(self, format_: Union[bytes, str], value: Any) -> None: - self.data.append(struct.pack(format_, value)) - self.size += struct.calcsize(format_) - - def write_byte(self, value: int) -> None: - """Writes a single byte to the packet""" - self.pack(b'!c', int2byte(value)) - - def insert_short_at_start(self, value: int) -> None: - """Inserts an unsigned short at the start of the packet""" - self.data.insert(0, struct.pack(b'!H', value)) - - def replace_short(self, index: int, value: int) -> None: - """Replaces an unsigned short in a certain position in the packet""" - self.data[index] = struct.pack(b'!H', value) - - def write_short(self, value: int) -> None: - """Writes an unsigned short to the packet""" - self.pack(b'!H', value) - - def write_int(self, value: Union[float, int]) -> None: - """Writes an unsigned integer to the packet""" - self.pack(b'!I', int(value)) - - def write_string(self, value: bytes) -> None: - """Writes a string to the packet""" - assert isinstance(value, bytes) - self.data.append(value) - self.size += len(value) - - def write_utf(self, s: str) -> None: - """Writes a UTF-8 string of a given length to the packet""" - utfstr = s.encode('utf-8') - length = len(utfstr) - if length > 64: - raise NamePartTooLongException - self.write_byte(length) - self.write_string(utfstr) - - def write_character_string(self, value: bytes) -> None: - assert isinstance(value, bytes) - length = len(value) - if length > 256: - raise NamePartTooLongException - self.write_byte(length) - self.write_string(value) - - def write_name(self, name: str) -> None: - """ - Write names to packet - - 18.14. Name Compression - - When generating Multicast DNS messages, implementations SHOULD use - name compression wherever possible to compress the names of resource - records, by replacing some or all of the resource record name with a - compact two-byte reference to an appearance of that data somewhere - earlier in the message [RFC1035]. - """ - - # split name into each label - parts = name.split('.') - if not parts[-1]: - parts.pop() - - # construct each suffix - name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] - - # look for an existing name or suffix - for count, sub_name in enumerate(name_suffices): - if sub_name in self.names: - break - else: - count = len(name_suffices) - - # note the new names we are saving into the packet - name_length = len(name.encode('utf-8')) - for suffix in name_suffices[:count]: - self.names[suffix] = self.size + name_length - len(suffix.encode('utf-8')) - 1 - - # write the new names out. - for part in parts[:count]: - self.write_utf(part) - - # if we wrote part of the name, create a pointer to the rest - if count != len(name_suffices): - # Found substring in packet, create pointer - index = self.names[name_suffices[count]] - self.write_byte((index >> 8) | 0xC0) - self.write_byte(index & 0xFF) - else: - # this is the end of a name - self.write_byte(0) - - def write_question(self, question: DNSQuestion) -> bool: - """Writes a question to the packet""" - start_data_length, start_size = len(self.data), self.size - self.write_name(question.name) - self.write_short(question.type) - self.write_short(question.class_) - return self._check_data_limit_or_rollback(start_data_length, start_size) - - def write_record(self, record: DNSRecord, now: float) -> bool: - """Writes a record (answer, authoritative answer, additional) to - the packet. Returns True on success, or False if we did not (either - because the packet was already finished or because the record does - not fit.""" - if self.state == self.State.finished: - return False - - start_data_length, start_size = len(self.data), self.size - self.write_name(record.name) - self.write_short(record.type) - if record.unique and self.multicast: - self.write_short(record.class_ | _CLASS_UNIQUE) - else: - self.write_short(record.class_) - if now == 0: - self.write_int(record.ttl) - else: - self.write_int(record.get_remaining_ttl(now)) - index = len(self.data) - - self.write_short(0) # Will get replaced with the actual size - record.write(self) - # Adjust size for the short we will write before this record - length = sum((len(d) for d in self.data[index + 1 :])) - # Here we replace the 0 length short we wrote - # before with the actual length - self.replace_short(index, length) - return self._check_data_limit_or_rollback(start_data_length, start_size) - - def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: - """Check data limit, if we go over, then rollback and return False.""" - len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL - self.allow_long = False - - if self.size <= len_limit: - return True - - log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) - - while len(self.data) > start_data_length: - self.data.pop() - self.size = start_size - - rollback_names = [name for name, idx in self.names.items() if idx >= start_size] - for name in rollback_names: - del self.names[name] - return False - - def packet(self) -> bytes: - """Returns a bytestring containing the first packet's bytes. - - Generally, you want to use packets() in case the response - does not fit in a single packet, but this exists for - backward compatibility.""" - packets = self.packets() - if len(packets) == 0: - return b'' - if len(packets[0]) > _MAX_MSG_ABSOLUTE: - QuietLogger.log_warning_once( - "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] - ) - return packets[0] - - def _write_questions_from_offset(self, questions_offset: int) -> int: - questions_written = 0 - for question in self.questions[questions_offset:]: - if not self.write_question(question): - break - questions_written += 1 - return questions_written - - def _write_answers_from_offset(self, answer_offset: int) -> int: - answers_written = 0 - for answer, time_ in self.answers[answer_offset:]: - if not self.write_record(answer, time_): - break - answers_written += 1 - return answers_written - - def _write_authorities_from_offset(self, authority_offset: int) -> int: - authorities_written = 0 - for authority in self.authorities[authority_offset:]: - if not self.write_record(authority, 0): - break - authorities_written += 1 - return authorities_written - - def _write_additionals_from_offset(self, additional_offset: int) -> int: - additionals_written = 0 - for additional in self.additionals[additional_offset:]: - if not self.write_record(additional, 0): - break - additionals_written += 1 - return additionals_written - - def _has_more_to_add( - self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int - ) -> bool: - """Check if all questions, answers, authority, and additionals have been written to the packet.""" - return ( - questions_offset < len(self.questions) - or answer_offset < len(self.answers) - or authority_offset < len(self.authorities) - or additional_offset < len(self.additionals) - ) - - def packets(self) -> List[bytes]: - """Returns a list of bytestrings containing the packets' bytes - - No further parts should be added to the packet once this - is done. The packets are each restricted to _MAX_MSG_TYPICAL - or less in length, except for the case of a single answer which - will be written out to a single oversized packet no more than - _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP - fragmentation potentially).""" - - if self.state == self.State.finished: - return self.packets_data - - questions_offset = 0 - answer_offset = 0 - authority_offset = 0 - additional_offset = 0 - # we have to at least write out the question - first_time = True - - while first_time or self._has_more_to_add( - questions_offset, answer_offset, authority_offset, additional_offset - ): - first_time = False - log.debug( - "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) - log.debug( - "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", - len(self.questions), - len(self.answers), - len(self.authorities), - len(self.additionals), - ) - - questions_written = self._write_questions_from_offset(questions_offset) - answers_written = self._write_answers_from_offset(answer_offset) - authorities_written = self._write_authorities_from_offset(authority_offset) - additionals_written = self._write_additionals_from_offset(additional_offset) - - self.insert_short_at_start(additionals_written) - self.insert_short_at_start(authorities_written) - self.insert_short_at_start(answers_written) - self.insert_short_at_start(questions_written) - - questions_offset += questions_written - answer_offset += answers_written - authority_offset += authorities_written - additional_offset += additionals_written - log.debug( - "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) - - if self.is_query() and self._has_more_to_add( - questions_offset, answer_offset, authority_offset, additional_offset - ): - # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - log.debug("Setting TC flag") - self.insert_short_at_start(self.flags | _FLAGS_TC) - else: - self.insert_short_at_start(self.flags) - - if self.multicast: - self.insert_short_at_start(0) - else: - self.insert_short_at_start(self.id) - - self.packets_data.append(b''.join(self.data)) - self.reset_for_next_packet() - - if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( - len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) - ) > 0: - log.warning("packets() made no progress adding records; returning") - break - self.state = self.State.finished - return self.packets_data - - -class DNSCache: - - """A cache of DNS entries""" - - def __init__(self) -> None: - self.cache = {} # type: Dict[str, List[DNSRecord]] - self.service_cache = {} # type: Dict[str, List[DNSRecord]] - - def add(self, entry: DNSRecord) -> None: - """Adds an entry""" - # Insert last in list, get will return newest entry - # iteration will result in last update winning - self.cache.setdefault(entry.key, []).append(entry) - if isinstance(entry, DNSService): - self.service_cache.setdefault(entry.server, []).append(entry) - - def add_records(self, entries: Iterable[DNSRecord]) -> None: - """Add multiple records.""" - for entry in entries: - self.add(entry) - - def remove(self, entry: DNSRecord) -> None: - """Removes an entry.""" - if isinstance(entry, DNSService): - DNSCache.remove_key(self.service_cache, entry.server, entry) - DNSCache.remove_key(self.cache, entry.key, entry) - - def remove_records(self, entries: Iterable[DNSRecord]) -> None: - """Remove multiple records.""" - for entry in entries: - self.remove(entry) - - @staticmethod - def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: - """Forgiving remove of a cache key.""" - try: - cache[key].remove(entry) - if not cache[key]: - del cache[key] - except (KeyError, ValueError): - pass - - def get(self, entry: DNSEntry) -> Optional[DNSRecord]: - """Gets an entry by key. Will return None if there is no - matching entry.""" - for cached_entry in reversed(self.entries_with_name(entry.key)): - if entry.__eq__(cached_entry): - return cached_entry - return None - - def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: - """Gets the first matching entry by details. Returns None if no entries match.""" - return self.get(DNSEntry(name, type_, class_)) - - def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: - """Gets all matching entries by details.""" - match_entry = DNSEntry(name, type_, class_) - return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] - - def entries_with_server(self, server: str) -> List[DNSRecord]: - """Returns a list of entries whose server matches the name.""" - return self.service_cache.get(server, [])[:] - - def entries_with_name(self, name: str) -> List[DNSRecord]: - """Returns a list of entries whose key matches the name.""" - return self.cache.get(name.lower(), [])[:] - - def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: - now = current_time_millis() - for record in reversed(self.entries_with_name(name)): - if ( - record.type == _TYPE_PTR - and not record.is_expired(now) - and cast(DNSPointer, record).alias == alias - ): - return record - return None - - def names(self) -> List[str]: - """Return a copy of the list of current cache names.""" - return list(self.cache) - - def expire(self, now: float) -> Iterable[DNSRecord]: - """Purge expired entries from the cache.""" - for name in self.names(): - for record in self.entries_with_name(name): - if record.is_expired(now): - self.remove(record) - yield record - - class Engine(threading.Thread): """An engine wraps read access to sockets, allowing objects that diff --git a/zeroconf/dns.py b/zeroconf/dns.py new file mode 100644 index 000000000..60d3c9192 --- /dev/null +++ b/zeroconf/dns.py @@ -0,0 +1,1030 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import enum +import socket +import struct +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast + +from .const import ( + _CLASSES, + _CLASS_MASK, + _CLASS_UNIQUE, + _EXPIRE_FULL_TIME_PERCENT, + _EXPIRE_STALE_TIME_PERCENT, + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _FLAGS_TC, + _MAX_MSG_ABSOLUTE, + _MAX_MSG_TYPICAL, + _TYPES, + _TYPE_A, + _TYPE_AAAA, + _TYPE_ANY, + _TYPE_CNAME, + _TYPE_HINFO, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) +from .exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException +from .logger import QuietLogger, log +from .utils.net import _is_v6_address +from .utils.struct import int2byte +from .utils.time import current_time_millis, millis_to_seconds + + +class DNSEntry: + + """A DNS entry""" + + def __init__(self, name: str, type_: int, class_: int) -> None: + self.key = name.lower() + self.name = name + self.type = type_ + self.class_ = class_ & _CLASS_MASK + self.unique = (class_ & _CLASS_UNIQUE) != 0 + + def __eq__(self, other: Any) -> bool: + """Equality test on key (lowercase name), type, and class""" + return ( + self.key == other.key + and self.type == other.type + and self.class_ == other.class_ + and isinstance(other, DNSEntry) + ) + + @staticmethod + def get_class_(class_: int) -> str: + """Class accessor""" + return _CLASSES.get(class_, "?(%s)" % class_) + + @staticmethod + def get_type(t: int) -> str: + """Type accessor""" + return _TYPES.get(t, "?(%s)" % t) + + def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: + """String representation with additional information""" + result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) + if self.unique: + result += "-unique," + else: + result += "," + result += self.name + if other is not None: + result += "]=%s" % cast(Any, other) + else: + result += "]" + return result + + +class DNSQuestion(DNSEntry): + + """A DNS question entry""" + + def __init__(self, name: str, type_: int, class_: int) -> None: + DNSEntry.__init__(self, name, type_, class_) + + def answered_by(self, rec: 'DNSRecord') -> bool: + """Returns true if the question is answered by the record""" + return ( + self.class_ == rec.class_ + and (self.type == rec.type or self.type == _TYPE_ANY) + and self.name == rec.name + ) + + def __repr__(self) -> str: + """String representation""" + return DNSEntry.entry_to_string(self, "question", None) + + +class DNSRecord(DNSEntry): + + """A DNS record - like a DNS entry, but has a TTL""" + + # TODO: Switch to just int ttl + def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) -> None: + DNSEntry.__init__(self, name, type_, class_) + self.ttl = ttl + self.created = current_time_millis() + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) + self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) + + def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use + """Abstract method""" + raise AbstractMethodException + + def suppressed_by(self, msg: 'DNSIncoming') -> bool: + """Returns true if any answer in a message can suffice for the + information held in this record.""" + for record in msg.answers: + if self.suppressed_by_answer(record): + return True + return False + + def suppressed_by_answer(self, other: 'DNSRecord') -> bool: + """Returns true if another record has same name, type and class, + and if its TTL is at least half of this record's.""" + return self == other and other.ttl > (self.ttl / 2) + + def get_expiration_time(self, percent: int) -> float: + """Returns the time at which this record will have expired + by a certain percentage.""" + return self.created + (percent * self.ttl * 10) + + # TODO: Switch to just int here + def get_remaining_ttl(self, now: float) -> Union[int, float]: + """Returns the remaining TTL in seconds.""" + return max(0, millis_to_seconds(self._expiration_time - now)) + + def is_expired(self, now: float) -> bool: + """Returns true if this record has expired.""" + return self._expiration_time <= now + + def is_stale(self, now: float) -> bool: + """Returns true if this record is at least half way expired.""" + return self._stale_time <= now + + def reset_ttl(self, other: 'DNSRecord') -> None: + """Sets this record's TTL and created time to that of + another record.""" + self.created = other.created + self.ttl = other.ttl + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) + self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) + + def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use + """Abstract method""" + raise AbstractMethodException + + def to_string(self, other: Union[bytes, str]) -> str: + """String representation with additional information""" + arg = "%s/%s,%s" % (self.ttl, int(self.get_remaining_ttl(current_time_millis())), cast(Any, other)) + return DNSEntry.entry_to_string(self, "record", arg) + + +class DNSAddress(DNSRecord): + + """A DNS address record""" + + def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None: + DNSRecord.__init__(self, name, type_, class_, ttl) + self.address = address + + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet""" + out.write_string(self.address) + + def __eq__(self, other: Any) -> bool: + """Tests equality on address""" + return ( + isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address + ) + + def __repr__(self) -> str: + """String representation""" + try: + return self.to_string( + socket.inet_ntop( + socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address + ) + ) + except (ValueError, OSError): + return self.to_string(str(self.address)) + + +class DNSHinfo(DNSRecord): + + """A DNS host information record""" + + def __init__(self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str) -> None: + DNSRecord.__init__(self, name, type_, class_, ttl) + self.cpu = cpu + self.os = os + + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet""" + out.write_character_string(self.cpu.encode('utf-8')) + out.write_character_string(self.os.encode('utf-8')) + + def __eq__(self, other: Any) -> bool: + """Tests equality on cpu and os""" + return ( + isinstance(other, DNSHinfo) + and DNSEntry.__eq__(self, other) + and self.cpu == other.cpu + and self.os == other.os + ) + + def __repr__(self) -> str: + """String representation""" + return self.to_string(self.cpu + " " + self.os) + + +class DNSPointer(DNSRecord): + + """A DNS pointer record""" + + def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> None: + DNSRecord.__init__(self, name, type_, class_, ttl) + self.alias = alias + + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet""" + out.write_name(self.alias) + + def __eq__(self, other: Any) -> bool: + """Tests equality on alias""" + return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) + + def __repr__(self) -> str: + """String representation""" + return self.to_string(self.alias) + + +class DNSText(DNSRecord): + + """A DNS text record""" + + def __init__(self, name: str, type_: int, class_: int, ttl: int, text: bytes) -> None: + assert isinstance(text, (bytes, type(None))) + DNSRecord.__init__(self, name, type_, class_, ttl) + self.text = text + + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet""" + out.write_string(self.text) + + def __eq__(self, other: Any) -> bool: + """Tests equality on text""" + return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) + + def __repr__(self) -> str: + """String representation""" + if len(self.text) > 10: + return self.to_string(self.text[:7]) + "..." + return self.to_string(self.text) + + +class DNSService(DNSRecord): + + """A DNS service record""" + + def __init__( + self, + name: str, + type_: int, + class_: int, + ttl: Union[float, int], + priority: int, + weight: int, + port: int, + server: str, + ) -> None: + DNSRecord.__init__(self, name, type_, class_, ttl) + self.priority = priority + self.weight = weight + self.port = port + self.server = server + + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet""" + out.write_short(self.priority) + out.write_short(self.weight) + out.write_short(self.port) + out.write_name(self.server) + + def __eq__(self, other: Any) -> bool: + """Tests equality on priority, weight, port and server""" + return ( + isinstance(other, DNSService) + and self.priority == other.priority + and self.weight == other.weight + and self.port == other.port + and self.server == other.server + and DNSEntry.__eq__(self, other) + ) + + def __repr__(self) -> str: + """String representation""" + return self.to_string("%s:%s" % (self.server, self.port)) + + +class DNSMessage: + """A base class for DNS messages.""" + + def __init__(self, flags: int) -> None: + """Construct a DNS message.""" + self.flags = flags + + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + +class DNSIncoming(DNSMessage, QuietLogger): + + """Object representation of an incoming DNS packet""" + + def __init__(self, data: bytes) -> None: + """Constructor from string holding bytes of packet""" + super().__init__(0) + self.offset = 0 + self.data = data + self.questions = [] # type: List[DNSQuestion] + self.answers = [] # type: List[DNSRecord] + self.id = 0 + self.num_questions = 0 + self.num_answers = 0 + self.num_authorities = 0 + self.num_additionals = 0 + self.valid = False + + try: + self.read_header() + self.read_questions() + self.read_others() + self.valid = True + + except (IndexError, struct.error, IncomingDecodeError): + self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) + + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'id=%s' % self.id, + 'flags=%s' % self.flags, + 'n_q=%s' % self.num_questions, + 'n_ans=%s' % self.num_answers, + 'n_auth=%s' % self.num_authorities, + 'n_add=%s' % self.num_additionals, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + ] + ) + + def unpack(self, format_: bytes) -> tuple: + length = struct.calcsize(format_) + info = struct.unpack(format_, self.data[self.offset : self.offset + length]) + self.offset += length + return info + + def read_header(self) -> None: + """Reads header portion of packet""" + ( + self.id, + self.flags, + self.num_questions, + self.num_answers, + self.num_authorities, + self.num_additionals, + ) = self.unpack(b'!6H') + + def read_questions(self) -> None: + """Reads questions section of packet""" + for _ in range(self.num_questions): + name = self.read_name() + type_, class_ = self.unpack(b'!HH') + + question = DNSQuestion(name, type_, class_) + self.questions.append(question) + + # def read_int(self): + # """Reads an integer from the packet""" + # return self.unpack(b'!I')[0] + + def read_character_string(self) -> bytes: + """Reads a character string from the packet""" + length = self.data[self.offset] + self.offset += 1 + return self.read_string(length) + + def read_string(self, length: int) -> bytes: + """Reads a string of a given length from the packet""" + info = self.data[self.offset : self.offset + length] + self.offset += length + return info + + def read_unsigned_short(self) -> int: + """Reads an unsigned short from the packet""" + return cast(int, self.unpack(b'!H')[0]) + + def read_others(self) -> None: + """Reads the answers, authorities and additionals section of the + packet""" + n = self.num_answers + self.num_authorities + self.num_additionals + for _ in range(n): + domain = self.read_name() + type_, class_, ttl, length = self.unpack(b'!HHiH') + rec = None # type: Optional[DNSRecord] + if type_ == _TYPE_A: + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) + elif type_ in (_TYPE_CNAME, _TYPE_PTR): + rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) + elif type_ == _TYPE_TXT: + rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) + elif type_ == _TYPE_SRV: + rec = DNSService( + domain, + type_, + class_, + ttl, + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_name(), + ) + elif type_ == _TYPE_HINFO: + rec = DNSHinfo( + domain, + type_, + class_, + ttl, + self.read_character_string().decode('utf-8'), + self.read_character_string().decode('utf-8'), + ) + elif type_ == _TYPE_AAAA: + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) + else: + # Try to ignore types we don't know about + # Skip the payload for the resource record so the next + # records can be parsed correctly + self.offset += length + + if rec is not None: + self.answers.append(rec) + + def read_utf(self, offset: int, length: int) -> str: + """Reads a UTF-8 string of a given length from the packet""" + return str(self.data[offset : offset + length], 'utf-8', 'replace') + + def read_name(self) -> str: + """Reads a domain name from the packet""" + result = '' + off = self.offset + next_ = -1 + first = off + + while True: + length = self.data[off] + off += 1 + if length == 0: + break + t = length & 0xC0 + if t == 0x00: + result += self.read_utf(off, length) + '.' + off += length + elif t == 0xC0: + if next_ < 0: + next_ = off + 1 + off = ((length & 0x3F) << 8) | self.data[off] + if off >= first: + raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) + first = off + else: + raise IncomingDecodeError("Bad domain name at %s" % (off,)) + + if next_ >= 0: + self.offset = next_ + else: + self.offset = off + + return result + + +class DNSOutgoing(DNSMessage): + + """Object representation of an outgoing packet""" + + def __init__(self, flags: int, multicast: bool = True) -> None: + super().__init__(flags) + self.finished = False + self.id = 0 + self.multicast = multicast + self.packets_data = [] # type: List[bytes] + + # these 3 are per-packet -- see also reset_for_next_packet() + self.names = {} # type: Dict[str, int] + self.data = [] # type: List[bytes] + self.size = 12 + self.allow_long = True + + self.state = self.State.init + + self.questions = [] # type: List[DNSQuestion] + self.answers = [] # type: List[Tuple[DNSRecord, float]] + self.authorities = [] # type: List[DNSPointer] + self.additionals = [] # type: List[DNSRecord] + + def reset_for_next_packet(self) -> None: + self.names = {} + self.data = [] + self.size = 12 + self.allow_long = True + + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'multicast=%s' % self.multicast, + 'flags=%s' % self.flags, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + 'authorities=%s' % self.authorities, + 'additionals=%s' % self.additionals, + ] + ) + + class State(enum.Enum): + init = 0 + finished = 1 + + def add_question(self, record: DNSQuestion) -> None: + """Adds a question""" + self.questions.append(record) + + def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: + """Adds an answer""" + if not record.suppressed_by(inp): + self.add_answer_at_time(record, 0) + + def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: + """Adds an answer if it does not expire by a certain time""" + if record is not None: + if now == 0 or not record.is_expired(now): + self.answers.append((record, now)) + + def add_authorative_answer(self, record: DNSPointer) -> None: + """Adds an authoritative answer""" + self.authorities.append(record) + + def add_additional_answer(self, record: DNSRecord) -> None: + """Adds an additional answer + + From: RFC 6763, DNS-Based Service Discovery, February 2013 + + 12. DNS Additional Record Generation + + DNS has an efficiency feature whereby a DNS server may place + additional records in the additional section of the DNS message. + These additional records are records that the client did not + explicitly request, but the server has reasonable grounds to expect + that the client might request them shortly, so including them can + save the client from having to issue additional queries. + + This section recommends which additional records SHOULD be generated + to improve network efficiency, for both Unicast and Multicast DNS-SD + responses. + + 12.1. PTR Records + + When including a DNS-SD Service Instance Enumeration or Selective + Instance Enumeration (subtype) PTR record in a response packet, the + server/responder SHOULD include the following additional records: + + o The SRV record(s) named in the PTR rdata. + o The TXT record(s) named in the PTR rdata. + o All address records (type "A" and "AAAA") named in the SRV rdata. + + 12.2. SRV Records + + When including an SRV record in a response packet, the + server/responder SHOULD include the following additional records: + + o All address records (type "A" and "AAAA") named in the SRV rdata. + + """ + self.additionals.append(record) + + def add_question_or_one_cache( + self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached.""" + cached_entry = cache.get_by_details(name, type_, class_) + if not cached_entry: + self.add_question(DNSQuestion(name, type_, class_)) + else: + self.add_answer_at_time(cached_entry, now) + + def add_question_or_all_cache( + self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached. + This is currently only used for IPv6 addresses. + """ + cached_entries = cache.get_all_by_details(name, type_, class_) + if not cached_entries: + self.add_question(DNSQuestion(name, type_, class_)) + return + for cached_entry in cached_entries: + self.add_answer_at_time(cached_entry, now) + + def pack(self, format_: Union[bytes, str], value: Any) -> None: + self.data.append(struct.pack(format_, value)) + self.size += struct.calcsize(format_) + + def write_byte(self, value: int) -> None: + """Writes a single byte to the packet""" + self.pack(b'!c', int2byte(value)) + + def insert_short_at_start(self, value: int) -> None: + """Inserts an unsigned short at the start of the packet""" + self.data.insert(0, struct.pack(b'!H', value)) + + def replace_short(self, index: int, value: int) -> None: + """Replaces an unsigned short in a certain position in the packet""" + self.data[index] = struct.pack(b'!H', value) + + def write_short(self, value: int) -> None: + """Writes an unsigned short to the packet""" + self.pack(b'!H', value) + + def write_int(self, value: Union[float, int]) -> None: + """Writes an unsigned integer to the packet""" + self.pack(b'!I', int(value)) + + def write_string(self, value: bytes) -> None: + """Writes a string to the packet""" + assert isinstance(value, bytes) + self.data.append(value) + self.size += len(value) + + def write_utf(self, s: str) -> None: + """Writes a UTF-8 string of a given length to the packet""" + utfstr = s.encode('utf-8') + length = len(utfstr) + if length > 64: + raise NamePartTooLongException + self.write_byte(length) + self.write_string(utfstr) + + def write_character_string(self, value: bytes) -> None: + assert isinstance(value, bytes) + length = len(value) + if length > 256: + raise NamePartTooLongException + self.write_byte(length) + self.write_string(value) + + def write_name(self, name: str) -> None: + """ + Write names to packet + + 18.14. Name Compression + + When generating Multicast DNS messages, implementations SHOULD use + name compression wherever possible to compress the names of resource + records, by replacing some or all of the resource record name with a + compact two-byte reference to an appearance of that data somewhere + earlier in the message [RFC1035]. + """ + + # split name into each label + parts = name.split('.') + if not parts[-1]: + parts.pop() + + # construct each suffix + name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] + + # look for an existing name or suffix + for count, sub_name in enumerate(name_suffices): + if sub_name in self.names: + break + else: + count = len(name_suffices) + + # note the new names we are saving into the packet + name_length = len(name.encode('utf-8')) + for suffix in name_suffices[:count]: + self.names[suffix] = self.size + name_length - len(suffix.encode('utf-8')) - 1 + + # write the new names out. + for part in parts[:count]: + self.write_utf(part) + + # if we wrote part of the name, create a pointer to the rest + if count != len(name_suffices): + # Found substring in packet, create pointer + index = self.names[name_suffices[count]] + self.write_byte((index >> 8) | 0xC0) + self.write_byte(index & 0xFF) + else: + # this is the end of a name + self.write_byte(0) + + def write_question(self, question: DNSQuestion) -> bool: + """Writes a question to the packet""" + start_data_length, start_size = len(self.data), self.size + self.write_name(question.name) + self.write_short(question.type) + self.write_short(question.class_) + return self._check_data_limit_or_rollback(start_data_length, start_size) + + def write_record(self, record: DNSRecord, now: float) -> bool: + """Writes a record (answer, authoritative answer, additional) to + the packet. Returns True on success, or False if we did not (either + because the packet was already finished or because the record does + not fit.""" + if self.state == self.State.finished: + return False + + start_data_length, start_size = len(self.data), self.size + self.write_name(record.name) + self.write_short(record.type) + if record.unique and self.multicast: + self.write_short(record.class_ | _CLASS_UNIQUE) + else: + self.write_short(record.class_) + if now == 0: + self.write_int(record.ttl) + else: + self.write_int(record.get_remaining_ttl(now)) + index = len(self.data) + + self.write_short(0) # Will get replaced with the actual size + record.write(self) + # Adjust size for the short we will write before this record + length = sum((len(d) for d in self.data[index + 1 :])) + # Here we replace the 0 length short we wrote + # before with the actual length + self.replace_short(index, length) + return self._check_data_limit_or_rollback(start_data_length, start_size) + + def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: + """Check data limit, if we go over, then rollback and return False.""" + len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL + self.allow_long = False + + if self.size <= len_limit: + return True + + log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) + + while len(self.data) > start_data_length: + self.data.pop() + self.size = start_size + + rollback_names = [name for name, idx in self.names.items() if idx >= start_size] + for name in rollback_names: + del self.names[name] + return False + + def packet(self) -> bytes: + """Returns a bytestring containing the first packet's bytes. + + Generally, you want to use packets() in case the response + does not fit in a single packet, but this exists for + backward compatibility.""" + packets = self.packets() + if len(packets) == 0: + return b'' + if len(packets[0]) > _MAX_MSG_ABSOLUTE: + QuietLogger.log_warning_once( + "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] + ) + return packets[0] + + def _write_questions_from_offset(self, questions_offset: int) -> int: + questions_written = 0 + for question in self.questions[questions_offset:]: + if not self.write_question(question): + break + questions_written += 1 + return questions_written + + def _write_answers_from_offset(self, answer_offset: int) -> int: + answers_written = 0 + for answer, time_ in self.answers[answer_offset:]: + if not self.write_record(answer, time_): + break + answers_written += 1 + return answers_written + + def _write_authorities_from_offset(self, authority_offset: int) -> int: + authorities_written = 0 + for authority in self.authorities[authority_offset:]: + if not self.write_record(authority, 0): + break + authorities_written += 1 + return authorities_written + + def _write_additionals_from_offset(self, additional_offset: int) -> int: + additionals_written = 0 + for additional in self.additionals[additional_offset:]: + if not self.write_record(additional, 0): + break + additionals_written += 1 + return additionals_written + + def _has_more_to_add( + self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int + ) -> bool: + """Check if all questions, answers, authority, and additionals have been written to the packet.""" + return ( + questions_offset < len(self.questions) + or answer_offset < len(self.answers) + or authority_offset < len(self.authorities) + or additional_offset < len(self.additionals) + ) + + def packets(self) -> List[bytes]: + """Returns a list of bytestrings containing the packets' bytes + + No further parts should be added to the packet once this + is done. The packets are each restricted to _MAX_MSG_TYPICAL + or less in length, except for the case of a single answer which + will be written out to a single oversized packet no more than + _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP + fragmentation potentially).""" + + if self.state == self.State.finished: + return self.packets_data + + questions_offset = 0 + answer_offset = 0 + authority_offset = 0 + additional_offset = 0 + # we have to at least write out the question + first_time = True + + while first_time or self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset + ): + first_time = False + log.debug( + "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + log.debug( + "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", + len(self.questions), + len(self.answers), + len(self.authorities), + len(self.additionals), + ) + + questions_written = self._write_questions_from_offset(questions_offset) + answers_written = self._write_answers_from_offset(answer_offset) + authorities_written = self._write_authorities_from_offset(authority_offset) + additionals_written = self._write_additionals_from_offset(additional_offset) + + self.insert_short_at_start(additionals_written) + self.insert_short_at_start(authorities_written) + self.insert_short_at_start(answers_written) + self.insert_short_at_start(questions_written) + + questions_offset += questions_written + answer_offset += answers_written + authority_offset += authorities_written + additional_offset += additionals_written + log.debug( + "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + + if self.is_query() and self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset + ): + # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + log.debug("Setting TC flag") + self.insert_short_at_start(self.flags | _FLAGS_TC) + else: + self.insert_short_at_start(self.flags) + + if self.multicast: + self.insert_short_at_start(0) + else: + self.insert_short_at_start(self.id) + + self.packets_data.append(b''.join(self.data)) + self.reset_for_next_packet() + + if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( + len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) + ) > 0: + log.warning("packets() made no progress adding records; returning") + break + self.state = self.State.finished + return self.packets_data + + +class DNSCache: + + """A cache of DNS entries""" + + def __init__(self) -> None: + self.cache = {} # type: Dict[str, List[DNSRecord]] + self.service_cache = {} # type: Dict[str, List[DNSRecord]] + + def add(self, entry: DNSRecord) -> None: + """Adds an entry""" + # Insert last in list, get will return newest entry + # iteration will result in last update winning + self.cache.setdefault(entry.key, []).append(entry) + if isinstance(entry, DNSService): + self.service_cache.setdefault(entry.server, []).append(entry) + + def add_records(self, entries: Iterable[DNSRecord]) -> None: + """Add multiple records.""" + for entry in entries: + self.add(entry) + + def remove(self, entry: DNSRecord) -> None: + """Removes an entry.""" + if isinstance(entry, DNSService): + DNSCache.remove_key(self.service_cache, entry.server, entry) + DNSCache.remove_key(self.cache, entry.key, entry) + + def remove_records(self, entries: Iterable[DNSRecord]) -> None: + """Remove multiple records.""" + for entry in entries: + self.remove(entry) + + @staticmethod + def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: + """Forgiving remove of a cache key.""" + try: + cache[key].remove(entry) + if not cache[key]: + del cache[key] + except (KeyError, ValueError): + pass + + def get(self, entry: DNSEntry) -> Optional[DNSRecord]: + """Gets an entry by key. Will return None if there is no + matching entry.""" + for cached_entry in reversed(self.entries_with_name(entry.key)): + if entry.__eq__(cached_entry): + return cached_entry + return None + + def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: + """Gets the first matching entry by details. Returns None if no entries match.""" + return self.get(DNSEntry(name, type_, class_)) + + def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: + """Gets all matching entries by details.""" + match_entry = DNSEntry(name, type_, class_) + return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] + + def entries_with_server(self, server: str) -> List[DNSRecord]: + """Returns a list of entries whose server matches the name.""" + return self.service_cache.get(server, [])[:] + + def entries_with_name(self, name: str) -> List[DNSRecord]: + """Returns a list of entries whose key matches the name.""" + return self.cache.get(name.lower(), [])[:] + + def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: + now = current_time_millis() + for record in reversed(self.entries_with_name(name)): + if ( + record.type == _TYPE_PTR + and not record.is_expired(now) + and cast(DNSPointer, record).alias == alias + ): + return record + return None + + def names(self) -> List[str]: + """Return a copy of the list of current cache names.""" + return list(self.cache) + + def expire(self, now: float) -> Iterable[DNSRecord]: + """Purge expired entries from the cache.""" + for name in self.names(): + for record in self.entries_with_name(name): + if record.is_expired(now): + self.remove(record) + yield record From b4814f5f216cd4072bafdd7dd1e68ee522f329c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 13:43:02 -1000 Subject: [PATCH 0286/1433] Move service_type_name to zeroconf.utils.name (#543) --- zeroconf/__init__.py | 122 +------------------------------- zeroconf/utils/name.py | 153 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 121 deletions(-) create mode 100644 zeroconf/utils/name.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 2ae88431d..6f5d9c9cd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -108,6 +108,7 @@ ServiceNameAlreadyRegistered, ) from .logger import QuietLogger, log +from .utils.name import service_type_name from .utils.net import ( # noqa # import needed for backwards compat add_multicast_member, can_send_to, @@ -156,127 +157,6 @@ # utility functions -def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches - """ - Validate a fully qualified service name, instance or subtype. [rfc6763] - - Returns fully qualified service name. - - Domain names used by mDNS-SD take the following forms: - - . <_tcp|_udp> . local. - . . <_tcp|_udp> . local. - ._sub . . <_tcp|_udp> . local. - - 1) must end with 'local.' - - This is true because we are implementing mDNS and since the 'm' means - multi-cast, the 'local.' domain is mandatory. - - 2) local is preceded with either '_udp.' or '_tcp.' unless - strict is False - - 3) service name precedes <_tcp|_udp> unless - strict is False - - The rules for Service Names [RFC6335] state that they may be no more - than fifteen characters long (not counting the mandatory underscore), - consisting of only letters, digits, and hyphens, must begin and end - with a letter or digit, must not contain consecutive hyphens, and - must contain at least one letter. - - The instance name and sub type may be up to 63 bytes. - - The portion of the Service Instance Name is a user- - friendly name consisting of arbitrary Net-Unicode text [RFC5198]. It - MUST NOT contain ASCII control characters (byte values 0x00-0x1F and - 0x7F) [RFC20] but otherwise is allowed to contain any characters, - without restriction, including spaces, uppercase, lowercase, - punctuation -- including dots -- accented characters, non-Roman text, - and anything else that may be represented using Net-Unicode. - - :param type_: Type, SubType or service name to validate - :return: fully qualified service name (eg: _http._tcp.local.) - """ - - if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): - remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') - trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] - has_protocol = True - elif strict: - raise BadTypeInNameException( - "Type '%s' must end with '%s' or '%s'" - % (type_, _TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER) - ) - elif type_.endswith(_LOCAL_TRAILER): - remaining = type_[: -len(_LOCAL_TRAILER)].split('.') - trailer = type_[-len(_LOCAL_TRAILER) + 1 :] - has_protocol = False - else: - raise BadTypeInNameException("Type '%s' must end with '%s'" % (type_, _LOCAL_TRAILER)) - - if strict or has_protocol: - service_name = remaining.pop() - if not service_name: - raise BadTypeInNameException("No Service name found") - - if len(remaining) == 1 and len(remaining[0]) == 0: - raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) - - if service_name[0] != '_': - raise BadTypeInNameException("Service name (%s) must start with '_'" % service_name) - - test_service_name = service_name[1:] - - if len(test_service_name) > 15: - raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % test_service_name) - - if '--' in test_service_name: - raise BadTypeInNameException("Service name (%s) must not contain '--'" % test_service_name) - - if '-' in (test_service_name[0], test_service_name[-1]): - raise BadTypeInNameException( - "Service name (%s) may not start or end with '-'" % test_service_name - ) - - if not _HAS_A_TO_Z.search(test_service_name): - raise BadTypeInNameException( - "Service name (%s) must contain at least one letter (eg: 'A-Z')" % test_service_name - ) - - allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN if strict else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE - ) - - if not allowed_characters_re.search(test_service_name): - raise BadTypeInNameException( - "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" % (test_service_name, "" if strict else ", underscore ('_')") - ) - else: - service_name = '' - - if remaining and remaining[-1] == '_sub': - remaining.pop() - if len(remaining) == 0 or len(remaining[0]) == 0: - raise BadTypeInNameException("_sub requires a subtype name") - - if len(remaining) > 1: - remaining = ['.'.join(remaining)] - - if remaining: - length = len(remaining[0].encode('utf-8')) - if length > 63: - raise BadTypeInNameException("Too long: '%s'" % remaining[0]) - - if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): - raise BadTypeInNameException( - "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0] - ) - - return service_name + trailer - - def instance_name_from_service_info(info: "ServiceInfo") -> str: """Calculate the instance name from the ServiceInfo.""" # This is kind of funky because of the subtype based tests diff --git a/zeroconf/utils/name.py b/zeroconf/utils/name.py new file mode 100644 index 000000000..65713eb05 --- /dev/null +++ b/zeroconf/utils/name.py @@ -0,0 +1,153 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from ..const import ( + _HAS_ASCII_CONTROL_CHARS, + _HAS_A_TO_Z, + _HAS_ONLY_A_TO_Z_NUM_HYPHEN, + _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE, + _LOCAL_TRAILER, + _NONTCP_PROTOCOL_LOCAL_TRAILER, + _TCP_PROTOCOL_LOCAL_TRAILER, +) +from ..exceptions import BadTypeInNameException + + +def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches + """ + Validate a fully qualified service name, instance or subtype. [rfc6763] + + Returns fully qualified service name. + + Domain names used by mDNS-SD take the following forms: + + . <_tcp|_udp> . local. + . . <_tcp|_udp> . local. + ._sub . . <_tcp|_udp> . local. + + 1) must end with 'local.' + + This is true because we are implementing mDNS and since the 'm' means + multi-cast, the 'local.' domain is mandatory. + + 2) local is preceded with either '_udp.' or '_tcp.' unless + strict is False + + 3) service name precedes <_tcp|_udp> unless + strict is False + + The rules for Service Names [RFC6335] state that they may be no more + than fifteen characters long (not counting the mandatory underscore), + consisting of only letters, digits, and hyphens, must begin and end + with a letter or digit, must not contain consecutive hyphens, and + must contain at least one letter. + + The instance name and sub type may be up to 63 bytes. + + The portion of the Service Instance Name is a user- + friendly name consisting of arbitrary Net-Unicode text [RFC5198]. It + MUST NOT contain ASCII control characters (byte values 0x00-0x1F and + 0x7F) [RFC20] but otherwise is allowed to contain any characters, + without restriction, including spaces, uppercase, lowercase, + punctuation -- including dots -- accented characters, non-Roman text, + and anything else that may be represented using Net-Unicode. + + :param type_: Type, SubType or service name to validate + :return: fully qualified service name (eg: _http._tcp.local.) + """ + + if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): + remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') + trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] + has_protocol = True + elif strict: + raise BadTypeInNameException( + "Type '%s' must end with '%s' or '%s'" + % (type_, _TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER) + ) + elif type_.endswith(_LOCAL_TRAILER): + remaining = type_[: -len(_LOCAL_TRAILER)].split('.') + trailer = type_[-len(_LOCAL_TRAILER) + 1 :] + has_protocol = False + else: + raise BadTypeInNameException("Type '%s' must end with '%s'" % (type_, _LOCAL_TRAILER)) + + if strict or has_protocol: + service_name = remaining.pop() + if not service_name: + raise BadTypeInNameException("No Service name found") + + if len(remaining) == 1 and len(remaining[0]) == 0: + raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) + + if service_name[0] != '_': + raise BadTypeInNameException("Service name (%s) must start with '_'" % service_name) + + test_service_name = service_name[1:] + + if len(test_service_name) > 15: + raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % test_service_name) + + if '--' in test_service_name: + raise BadTypeInNameException("Service name (%s) must not contain '--'" % test_service_name) + + if '-' in (test_service_name[0], test_service_name[-1]): + raise BadTypeInNameException( + "Service name (%s) may not start or end with '-'" % test_service_name + ) + + if not _HAS_A_TO_Z.search(test_service_name): + raise BadTypeInNameException( + "Service name (%s) must contain at least one letter (eg: 'A-Z')" % test_service_name + ) + + allowed_characters_re = ( + _HAS_ONLY_A_TO_Z_NUM_HYPHEN if strict else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE + ) + + if not allowed_characters_re.search(test_service_name): + raise BadTypeInNameException( + "Service name (%s) must contain only these characters: " + "A-Z, a-z, 0-9, hyphen ('-')%s" % (test_service_name, "" if strict else ", underscore ('_')") + ) + else: + service_name = '' + + if remaining and remaining[-1] == '_sub': + remaining.pop() + if len(remaining) == 0 or len(remaining[0]) == 0: + raise BadTypeInNameException("_sub requires a subtype name") + + if len(remaining) > 1: + remaining = ['.'.join(remaining)] + + if remaining: + length = len(remaining[0].encode('utf-8')) + if length > 63: + raise BadTypeInNameException("Too long: '%s'" % remaining[0]) + + if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): + raise BadTypeInNameException( + "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0] + ) + + return service_name + trailer From bdea21c0a61b6d9d0af3810f18dbc2fc2364c484 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 14:36:56 -1000 Subject: [PATCH 0287/1433] Breakout service classes into zeroconf.services (#544) --- tests/test_init.py | 221 ------------ tests/test_services.py | 266 ++++++++++++++ zeroconf/__init__.py | 720 +------------------------------------- zeroconf/services.py | 771 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1047 insertions(+), 931 deletions(-) create mode 100644 tests/test_services.py create mode 100644 zeroconf/services.py diff --git a/tests/test_init.py b/tests/test_init.py index f0092ea4c..a740cab40 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -2210,167 +2210,6 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() -def test_backoff(): - got_query = Event() - - type_ = "_http._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - - # we are going to monkey patch the zeroconf send to check query transmission - old_send = zeroconf_browser.send - - time_offset = 0.0 - start_time = time.time() * 1000 - initial_query_interval = r._BROWSER_TIME / 1000 - - def current_time_millis(): - """Current system time in milliseconds""" - return start_time + time_offset * 1000 - - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): - """Sends an outgoing packet.""" - got_query.set() - old_send(out, addr=addr, port=port) - - # monkey patch the zeroconf send - setattr(zeroconf_browser, "send", send) - - # monkey patch the zeroconf current_time_millis - r.current_time_millis = current_time_millis - - # monkey patch the backoff limit to prevent test running forever - r._BROWSER_BACKOFF_LIMIT = 10 # seconds - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - try: - # Test that queries are sent at increasing intervals - sleep_count = 0 - next_query_interval = 0.0 - expected_query_time = 0.0 - while True: - zeroconf_browser.notify_all() - sleep_count += 1 - got_query.wait(0.1) - if time_offset == expected_query_time: - assert got_query.is_set() - got_query.clear() - if next_query_interval == r._BROWSER_BACKOFF_LIMIT: - # Only need to test up to the point where we've seen a query - # after the backoff limit has been hit - break - elif next_query_interval == 0: - next_query_interval = initial_query_interval - expected_query_time = initial_query_interval - else: - next_query_interval = min(2 * next_query_interval, r._BROWSER_BACKOFF_LIMIT) - expected_query_time += next_query_interval - else: - assert not got_query.is_set() - time_offset += initial_query_interval - - finally: - browser.cancel() - zeroconf_browser.close() - - -def test_integration(): - service_added = Event() - service_removed = Event() - unexpected_ttl = Event() - got_query = Event() - - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ - - def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() - elif state_change is ServiceStateChange.Removed: - service_removed.set() - - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - - # we are going to monkey patch the zeroconf send to check packet sizes - old_send = zeroconf_browser.send - - time_offset = 0.0 - - def current_time_millis(): - """Current system time in milliseconds""" - return time.time() * 1000 + time_offset * 1000 - - expected_ttl = r._DNS_HOST_TTL - - nbr_answers = 0 - - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): - """Sends an outgoing packet.""" - pout = r.DNSIncoming(out.packet()) - nonlocal nbr_answers - for answer in pout.answers: - nbr_answers += 1 - if not answer.ttl > expected_ttl / 2: - unexpected_ttl.set() - - got_query.set() - old_send(out, addr=addr, port=port) - - # monkey patch the zeroconf send - setattr(zeroconf_browser, "send", send) - - # monkey patch the zeroconf current_time_millis - r.current_time_millis = current_time_millis - - # monkey patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - r._BROWSER_BACKOFF_LIMIT = int(expected_ttl / 4) - - service_added = Event() - service_removed = Event() - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - zeroconf_registrar.register_service(info) - - try: - service_added.wait(1) - assert service_added.is_set() - - # Test that we receive queries containing answers only if the remaining TTL - # is greater than half the original TTL - sleep_count = 0 - test_iterations = 50 - while nbr_answers < test_iterations: - # Increase simulated time shift by 1/4 of the TTL in seconds - time_offset += expected_ttl / 4 - zeroconf_browser.notify_all() - sleep_count += 1 - got_query.wait(0.1) - got_query.clear() - # Prevent the test running indefinitely in an error condition - assert sleep_count < test_iterations * 4 - assert not unexpected_ttl.is_set() - - # Don't remove service, allow close() to cleanup - - finally: - zeroconf_registrar.close() - service_removed.wait(1) - assert service_removed.is_set() - browser.cancel() - zeroconf_browser.close() - - def test_multiple_addresses(): type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -2748,66 +2587,6 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.close() -def test_legacy_record_update_listener(): - """Test a RecordUpdateListener that does not implement update_records.""" - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - - with pytest.raises(RuntimeError): - r.RecordUpdateListener().update_record( - zc, 0, r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) - ) - - updates = [] - - class LegacyRecordUpdateListener(r.RecordUpdateListener): - """A RecordUpdateListener that does not implement update_records.""" - - def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: - nonlocal updates - updates.append(record) - - listener = LegacyRecordUpdateListener() - - zc.add_listener(listener, None) - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - # start a browser - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - browser = ServiceBrowser(zc, type_, [on_service_state_change]) - - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - - zc.register_service(info_service) - - zc.wait(1) - - browser.cancel() - - assert len(updates) - assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 - - zc.remove_listener(listener) - # Removing a second time should not throw - zc.remove_listener(listener) - - zc.close() - - def test_autodetect_ip_version(): """Tests for auto detecting IPVersion based on interface ips.""" assert r.autodetect_ip_version(["1.3.4.5"]) is r.IPVersion.V4Only diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 000000000..d931d5c02 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf.services. """ + +import logging +import socket +import threading +import time +from threading import Event + +import pytest + +import zeroconf as r +import zeroconf.services as s +from zeroconf import ( + ServiceBrowser, + ServiceInfo, + ServiceStateChange, + Zeroconf, +) + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads = frozenset(threading.enumerate()) - threads_before + assert not threads + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +def test_backoff(): + got_query = Event() + + type_ = "_http._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to monkey patch the zeroconf send to check query transmission + old_send = zeroconf_browser.send + + time_offset = 0.0 + start_time = time.time() * 1000 + initial_query_interval = s._BROWSER_TIME / 1000 + + def current_time_millis(): + """Current system time in milliseconds""" + return start_time + time_offset * 1000 + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + got_query.set() + old_send(out, addr=addr, port=port) + + # monkey patch the zeroconf send + setattr(zeroconf_browser, "send", send) + + # monkey patch the zeroconf current_time_millis + s.current_time_millis = current_time_millis + + # monkey patch the backoff limit to prevent test running forever + s._BROWSER_BACKOFF_LIMIT = 10 # seconds + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + try: + # Test that queries are sent at increasing intervals + sleep_count = 0 + next_query_interval = 0.0 + expected_query_time = 0.0 + while True: + zeroconf_browser.notify_all() + sleep_count += 1 + got_query.wait(0.1) + if time_offset == expected_query_time: + assert got_query.is_set() + got_query.clear() + if next_query_interval == s._BROWSER_BACKOFF_LIMIT: + # Only need to test up to the point where we've seen a query + # after the backoff limit has been hit + break + elif next_query_interval == 0: + next_query_interval = initial_query_interval + expected_query_time = initial_query_interval + else: + next_query_interval = min(2 * next_query_interval, s._BROWSER_BACKOFF_LIMIT) + expected_query_time += next_query_interval + else: + assert not got_query.is_set() + time_offset += initial_query_interval + + finally: + browser.cancel() + zeroconf_browser.close() + + +def test_integration(): + service_added = Event() + service_removed = Event() + unexpected_ttl = Event() + got_query = Event() + + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + elif state_change is ServiceStateChange.Removed: + service_removed.set() + + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to monkey patch the zeroconf send to check packet sizes + old_send = zeroconf_browser.send + + time_offset = 0.0 + + def current_time_millis(): + """Current system time in milliseconds""" + return time.time() * 1000 + time_offset * 1000 + + expected_ttl = r._DNS_HOST_TTL + + nbr_answers = 0 + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packet()) + nonlocal nbr_answers + for answer in pout.answers: + nbr_answers += 1 + if not answer.ttl > expected_ttl / 2: + unexpected_ttl.set() + + got_query.set() + old_send(out, addr=addr, port=port) + + # monkey patch the zeroconf send + setattr(zeroconf_browser, "send", send) + + # monkey patch the zeroconf current_time_millis + s.current_time_millis = current_time_millis + + # monkey patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL + s._BROWSER_BACKOFF_LIMIT = int(expected_ttl / 4) + + service_added = Event() + service_removed = Event() + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zeroconf_registrar.register_service(info) + + try: + service_added.wait(1) + assert service_added.is_set() + + # Test that we receive queries containing answers only if the remaining TTL + # is greater than half the original TTL + sleep_count = 0 + test_iterations = 50 + while nbr_answers < test_iterations: + # Increase simulated time shift by 1/4 of the TTL in seconds + time_offset += expected_ttl / 4 + zeroconf_browser.notify_all() + sleep_count += 1 + got_query.wait(0.1) + got_query.clear() + # Prevent the test running indefinitely in an error condition + assert sleep_count < test_iterations * 4 + assert not unexpected_ttl.is_set() + + # Don't remove service, allow close() to cleanup + + finally: + zeroconf_registrar.close() + service_removed.wait(1) + assert service_removed.is_set() + browser.cancel() + zeroconf_browser.close() + + +def test_legacy_record_update_listener(): + """Test a RecordUpdateListener that does not implement update_records.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + with pytest.raises(RuntimeError): + r.RecordUpdateListener().update_record( + zc, 0, r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + ) + + updates = [] + + class LegacyRecordUpdateListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: + nonlocal updates + updates.append(record) + + listener = LegacyRecordUpdateListener() + + zc.add_listener(listener, None) + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + zc.register_service(info_service) + + zc.wait(1) + + browser.cancel() + + assert len(updates) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + + zc.remove_listener(listener) + # Removing a second time should not throw + zc.remove_listener(listener) + + zc.close() diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 6f5d9c9cd..8faba3041 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -28,11 +28,9 @@ import sys import threading import time -import warnings -from collections import OrderedDict from types import TracebackType # noqa # used in type hints from typing import Dict, List, Optional, Type, Union, cast -from typing import Any, Callable, Set, Tuple # noqa # used in type hints +from typing import Set, Tuple # noqa # used in type hints from .const import ( # noqa # import needed for backwards compat _BROWSER_BACKOFF_LIMIT, @@ -108,6 +106,14 @@ ServiceNameAlreadyRegistered, ) from .logger import QuietLogger, log +from .services import ( # noqa # import needed for backwards compat + Signal, + SignalRegistrationInterface, + RecordUpdateListener, + _ServiceBrowserBase, + ServiceBrowser, + ServiceInfo, +) from .utils.name import service_type_name from .utils.net import ( # noqa # import needed for backwards compat add_multicast_member, @@ -123,7 +129,7 @@ _encode_address, get_all_addresses, ) -from .utils.struct import int2byte +from .utils.struct import int2byte # noqa # import needed for backwards compat from .utils.time import current_time_millis, millis_to_seconds __author__ = 'Paul Scott-Murphy, William McBrine' @@ -317,66 +323,6 @@ def handle_read(self, socket_: socket.socket) -> None: self.zc.handle_response(msg) -class Signal: - def __init__(self) -> None: - self._handlers = [] # type: List[Callable[..., None]] - - def fire(self, **kwargs: Any) -> None: - for h in list(self._handlers): - h(**kwargs) - - @property - def registration_interface(self) -> 'SignalRegistrationInterface': - return SignalRegistrationInterface(self._handlers) - - -class SignalRegistrationInterface: - def __init__(self, handlers: List[Callable[..., None]]) -> None: - self._handlers = handlers - - def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': - self._handlers.append(handler) - return self - - def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': - self._handlers.remove(handler) - return self - - -class RecordUpdateListener: - def update_record( # pylint: disable=no-self-use - self, zc: 'Zeroconf', now: float, record: DNSRecord - ) -> None: - """Update a single record. - - This method is deprecated and will be removed in a future version. - update_records should be implemented instead. - """ - raise RuntimeError("update_record is deprecated and will be removed in a future version.") - - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Update multiple records in one shot. - - All records that are received in a single packet are passed - to update_records. - - This implementation is a compatiblity shim to ensure older code - that uses RecordUpdateListener as a base class will continue to - get calls to update_record. This method will raise - NotImplementedError in a future version. - - At this point the cache will not have the new records - """ - for record in records: - self.update_record(zc, now, record) - - def update_records_complete(self) -> None: - """Called when a record update has completed for all handlers. - - At this point the cache will have the new records. - """ - - class ServiceListener: def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() @@ -396,652 +342,6 @@ def notify_all(self) -> None: raise NotImplementedError() -class _ServiceBrowserBase(RecordUpdateListener): - """Base class for ServiceBrowser.""" - - def __init__( - self, - zc: 'Zeroconf', - type_: Union[str, list], - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[ServiceListener] = None, - addr: Optional[str] = None, - port: int = _MDNS_PORT, - delay: int = _BROWSER_TIME, - ) -> None: - """Creates a browser for a specific type""" - assert handlers or listener, 'You need to specify at least one handler' - self.types = set(type_ if isinstance(type_, list) else [type_]) # type: Set[str] - for check_type_ in self.types: - if not check_type_.endswith(service_type_name(check_type_, strict=False)): - raise BadTypeInNameException - self.zc = zc - self.addr = addr - self.port = port - self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - self._services = { - check_type_: {} for check_type_ in self.types - } # type: Dict[str, Dict[str, DNSRecord]] - current_time = current_time_millis() - self._next_time = {check_type_: current_time for check_type_ in self.types} - self._delay = {check_type_: delay for check_type_ in self.types} - self._pending_handlers = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] - self._handlers_to_call = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] - - self._service_state_changed = Signal() - - self.done = False - - if hasattr(handlers, 'add_service'): - listener = cast(ServiceListener, handlers) - handlers = None - - handlers = cast(List[Callable[..., None]], handlers or []) - - if listener: - - def on_change( - zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange - ) -> None: - assert listener is not None - args = (zeroconf, service_type, name) - if state_change is ServiceStateChange.Added: - listener.add_service(*args) - elif state_change is ServiceStateChange.Removed: - listener.remove_service(*args) - elif state_change is ServiceStateChange.Updated: - if hasattr(listener, 'update_service'): - listener.update_service(*args) - else: - warnings.warn( - "%r has no update_service method. Provide one (it can be empty if you " - "don't care about the updates), it'll become mandatory." % (listener,), - FutureWarning, - ) - else: - raise NotImplementedError(state_change) - - handlers.append(on_change) - - for h in handlers: - self.service_state_changed.register_handler(h) - - @property - def service_state_changed(self) -> SignalRegistrationInterface: - return self._service_state_changed.registration_interface - - def _record_matching_type(self, record: DNSRecord) -> Optional[str]: - """Return the type if the record matches one of the types we are browsing.""" - return next((type_ for type_ in self.types if record.name.endswith(type_)), None) - - def _enqueue_callback( - self, - state_change: ServiceStateChange, - type_: str, - name: str, - ) -> None: - # Code to ensure we only do a single update message - # Precedence is; Added, Remove, Update - key = (name, type_) - if ( - state_change is ServiceStateChange.Added - or ( - state_change is ServiceStateChange.Removed - and self._pending_handlers.get(key) != ServiceStateChange.Added - ) - or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) - ): - self._pending_handlers[key] = state_change - - def _process_record_update( - self, - zc: 'Zeroconf', - now: float, - record: DNSRecord, - ) -> None: - """Process a single record update from a batch of updates.""" - expired = record.is_expired(now) - - if isinstance(record, DNSPointer): - if record.name not in self.types: - return - service_key = record.alias.lower() - services_by_type = self._services[record.name] - old_record = services_by_type.get(service_key) - if old_record is None: - services_by_type[service_key] = record - self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) - elif expired: - del services_by_type[service_key] - self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) - else: - old_record.reset_ttl(record) - expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - if expires < self._next_time[record.name]: - self._next_time[record.name] = expires - return - - # If its expired or already exists in the cache it cannot be updated. - if expired or self.zc.cache.get(record): - return - - if isinstance(record, DNSAddress): - # Only trigger an updated event if the address is new - if record.address in set( - service.address - for service in zc.cache.entries_with_name(record.name) - if isinstance(service, DNSAddress) - ): - return - - # Iterate through the DNSCache and callback any services that use this address - for service in self.zc.cache.entries_with_server(record.name): - type_ = self._record_matching_type(service) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) - break - - return - - type_ = self._record_matching_type(record) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) - - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Callback invoked by Zeroconf when new information arrives. - - Updates information required by browser in the Zeroconf cache. - - Ensures that there is are no unecessary duplicates in the list. - """ - for record in records: - self._process_record_update(zc, now, record) - - def update_records_complete(self) -> None: - """Called when a record update has completed for all handlers. - - At this point the cache will have the new records. - """ - self._handlers_to_call.update(self._pending_handlers) - self._pending_handlers.clear() - - def cancel(self) -> None: - """Cancel the browser.""" - self.done = True - self.zc.remove_listener(self) - - def run(self) -> None: - """Run the browser.""" - questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] - self.zc.add_listener(self, questions) - - def generate_ready_queries(self) -> Optional[DNSOutgoing]: - """Generate the service browser query for any type that is due.""" - out = None - now = current_time_millis() - - if min(self._next_time.values()) > now: - return out - - for type_, due in self._next_time.items(): - if due > now: - continue - - if out is None: - out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) - out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) - - for record in self._services[type_].values(): - if not record.is_stale(now): - out.add_answer_at_time(record, now) - - self._next_time[type_] = now + self._delay[type_] - self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - return out - - def _seconds_to_wait(self) -> Optional[float]: - """Returns the number of seconds to wait for the next event.""" - # If there are handlers to call - # we want to process them right away - if self._handlers_to_call: - return None - - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - now = current_time_millis() - - if next_time <= now: - return None - - return millis_to_seconds(next_time - now) - - -class ServiceBrowser(_ServiceBrowserBase, threading.Thread): - """Used to browse for a service of a specific type. - - The listener object will have its add_service() and - remove_service() methods called when this browser - discovers changes in the services availability.""" - - def __init__( - self, - zc: 'Zeroconf', - type_: Union[str, list], - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[ServiceListener] = None, - addr: Optional[str] = None, - port: int = _MDNS_PORT, - delay: int = _BROWSER_TIME, - ) -> None: - threading.Thread.__init__(self) - super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) - self.daemon = True - self.start() - self.name = "zeroconf-ServiceBrowser-%s-%s" % ( - '-'.join([type_[:-7] for type_ in self.types]), - getattr(self, 'native_id', self.ident), - ) - - def cancel(self) -> None: - """Cancel the browser.""" - super().cancel() - self.join() - - def run(self) -> None: - """Run the browser thread.""" - super().run() - while True: - timeout = self._seconds_to_wait() - if timeout: - with self.zc.condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - self.zc.condition.wait(timeout) - - if self.zc.done or self.done: - return - - out = self.generate_ready_queries() - if out: - self.zc.send(out, addr=self.addr, port=self.port) - - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) - - -class ServiceInfo(RecordUpdateListener): - """Service information. - - Constructor parameters are as follows: - - * `type_`: fully qualified service type name - * `name`: fully qualified service name - * `port`: port that the service runs on - * `weight`: weight of the service - * `priority`: priority of the service - * `properties`: dictionary of properties (or a bytes object holding the contents of the `text` field). - converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to - value-less attributes. - * `server`: fully qualified name for service host (defaults to name) - * `host_ttl`: ttl used for A/SRV records - * `other_ttl`: ttl used for PTR/TXT records - * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, - or in parsed form as text; at most one of those parameters can be provided) - - """ - - text = b'' - - def __init__( - self, - type_: str, - name: str, - port: Optional[int] = None, - weight: int = 0, - priority: int = 0, - properties: Union[bytes, Dict] = b'', - server: Optional[str] = None, - host_ttl: int = _DNS_HOST_TTL, - other_ttl: int = _DNS_OTHER_TTL, - *, - addresses: Optional[List[bytes]] = None, - parsed_addresses: Optional[List[str]] = None - ) -> None: - # Accept both none, or one, but not both. - if addresses is not None and parsed_addresses is not None: - raise TypeError("addresses and parsed_addresses cannot be provided together") - if not type_.endswith(service_type_name(name, strict=False)): - raise BadTypeInNameException - self.type = type_ - self.name = name - self.key = name.lower() - if addresses is not None: - self._addresses = addresses - elif parsed_addresses is not None: - self._addresses = [_encode_address(a) for a in parsed_addresses] - else: - self._addresses = [] - # This results in an ugly error when registering, better check now - invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] - if invalid: - raise TypeError( - 'Addresses must be bytes, got %s. Hint: convert string addresses ' - 'with socket.inet_pton' % invalid - ) - self.port = port - self.weight = weight - self.priority = priority - if server: - self.server = server - else: - self.server = name - self.server_key = self.server.lower() - self._properties = {} # type: Dict - self._set_properties(properties) - self.host_ttl = host_ttl - self.other_ttl = other_ttl - - @property - def addresses(self) -> List[bytes]: - """IPv4 addresses of this service. - - Only IPv4 addresses are returned for backward compatibility. - Use :meth:`addresses_by_version` or :meth:`parsed_addresses` to - include IPv6 addresses as well. - """ - return self.addresses_by_version(IPVersion.V4Only) - - @addresses.setter - def addresses(self, value: List[bytes]) -> None: - """Replace the addresses list. - - This replaces all currently stored addresses, both IPv4 and IPv6. - """ - self._addresses = value - - @property - def properties(self) -> Dict: - """If properties were set in the constructor this property returns the original dictionary - of type `Dict[Union[bytes, str], Any]`. - - If properties are coming from the network, after decoding a TXT record, the keys are always - bytes and the values are either bytes, if there was a value, even empty, or `None`, if there - was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. - """ - return self._properties - - def addresses_by_version(self, version: IPVersion) -> List[bytes]: - """List addresses matching IP version.""" - if version == IPVersion.V4Only: - return [addr for addr in self._addresses if not _is_v6_address(addr)] - if version == IPVersion.V6Only: - return list(filter(_is_v6_address, self._addresses)) - return self._addresses - - def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: - """List addresses in their parsed string form.""" - result = self.addresses_by_version(version) - return [ - socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) - for addr in result - ] - - def _set_properties(self, properties: Union[bytes, Dict]) -> None: - """Sets properties and text of this info from a dictionary""" - if isinstance(properties, dict): - self._properties = properties - list_ = [] - result = b'' - for key, value in properties.items(): - if isinstance(key, str): - key = key.encode('utf-8') - - record = key - if value is not None: - if not isinstance(value, bytes): - value = str(value).encode('utf-8') - record += b'=' + value - list_.append(record) - for item in list_: - result = b''.join((result, int2byte(len(item)), item)) - self.text = result - else: - self.text = properties - - def _set_text(self, text: bytes) -> None: - """Sets properties and text given a text field""" - self.text = text - result = {} # type: Dict - end = len(text) - index = 0 - strs = [] - while index < end: - length = text[index] - index += 1 - strs.append(text[index : index + length]) - index += length - - for s in strs: - parts = s.split(b'=', 1) - try: - key, value = parts # type: Tuple[bytes, Optional[bytes]] - except ValueError: - # No equals sign at all - key = s - value = None - - # Only update non-existent properties - if key and result.get(key) is None: - result[key] = value - - self._properties = result - - def get_name(self) -> str: - """Name accessor""" - return self.name[: len(self.name) - len(self.type) - 1] - - def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: - """Updates service information from a DNS record. - - This method is deprecated and will be removed in a future version. - update_records should be implemented instead. - """ - if record is not None: - self.update_records(zc, now, [record]) - - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Updates service information from a DNS record.""" - update_addresses = False - for record in records: - if isinstance(record, DNSService): - update_addresses = True - self._process_record(record, now) - - # Only update addresses if the DNSService (.server) has changed - if not update_addresses: - return - - for record in self._get_address_records_from_cache(zc): - self._process_record(record, now) - - def _process_record(self, record: DNSRecord, now: float) -> None: - if record.is_expired(now): - return - - if isinstance(record, DNSAddress): - if record.key == self.server_key and record.address not in self._addresses: - self._addresses.append(record.address) - return - - if isinstance(record, DNSService): - if record.key != self.key: - return - self.name = record.name - self.server = record.server - self.server_key = record.server.lower() - self.port = record.port - self.weight = record.weight - self.priority = record.priority - return - - if isinstance(record, DNSText): - if record.key == self.key: - self._set_text(record.text) - - def dns_addresses(self, override_ttl: Optional[int] = None) -> List[DNSAddress]: - """Return matching DNSAddress from ServiceInfo.""" - return [ - DNSAddress( - self.server, - _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.host_ttl, - address, - ) - for address in self._addresses - ] - - def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: - """Return DNSPointer from ServiceInfo.""" - return DNSPointer( - self.type, - _TYPE_PTR, - _CLASS_IN, - override_ttl if override_ttl is not None else self.other_ttl, - self.name, - ) - - def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: - """Return DNSService from ServiceInfo.""" - return DNSService( - self.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.host_ttl, - self.priority, - self.weight, - cast(int, self.port), - self.server, - ) - - def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: - """Return DNSText from ServiceInfo.""" - return DNSText( - self.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.other_ttl, - self.text, - ) - - def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: - """Get the address records from the cache.""" - address_records = [] - cached_a_record = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) - if cached_a_record: - address_records.append(cached_a_record) - address_records.extend(zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) - return address_records - - def load_from_cache(self, zc: 'Zeroconf') -> bool: - """Populate the service info from the cache.""" - now = current_time_millis() - record_updates = [] - cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) - if cached_srv_record: - # If there is a srv record, A and AAAA will already - # be called and we do not want to do it twice - record_updates.append(cached_srv_record) - else: - record_updates.extend(self._get_address_records_from_cache(zc)) - cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) - if cached_txt_record: - record_updates.append(cached_txt_record) - self.update_records(zc, now, record_updates) - return self._is_complete - - @property - def _is_complete(self) -> bool: - """The ServiceInfo has all expected properties.""" - return not (self.text is None or not self._addresses) - - def request(self, zc: 'Zeroconf', timeout: float) -> bool: - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ - if self.load_from_cache(zc): - return True - - now = current_time_millis() - delay = _LISTENER_TIME - next_ = now - last = now + timeout - try: - # Do not set a question on the listener to preload from cache - # since we just checked it above in load_from_cache - zc.add_listener(self, None) - while not self._is_complete: - if last <= now: - return False - if next_ <= now: - out = self.generate_request_query(zc, now) - if not out.questions: - return True - zc.send(out) - next_ = now + delay - delay *= 2 - - zc.wait(min(next_, last) - now) - now = current_time_millis() - finally: - zc.remove_listener(self) - - return True - - def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: - """Generate the request query.""" - out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_one_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) - return out - - def __eq__(self, other: object) -> bool: - """Tests equality of service name""" - return isinstance(other, ServiceInfo) and other.name == self.name - - def __repr__(self) -> str: - """String representation""" - return '%s(%s)' % ( - type(self).__name__, - ', '.join( - '%s=%r' % (name, getattr(self, name)) - for name in ( - 'type', - 'name', - 'addresses', - 'port', - 'weight', - 'priority', - 'server', - 'properties', - ) - ), - ) - - class ZeroconfServiceTypes(ServiceListener): """ Return all of the advertised services on any local networks diff --git a/zeroconf/services.py b/zeroconf/services.py new file mode 100644 index 000000000..5c61741e8 --- /dev/null +++ b/zeroconf/services.py @@ -0,0 +1,771 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import socket +import threading +import warnings +from collections import OrderedDict +from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast + +from .const import ( + _BROWSER_BACKOFF_LIMIT, + _BROWSER_TIME, + _CLASS_IN, + _CLASS_UNIQUE, + _DNS_HOST_TTL, + _DNS_OTHER_TTL, + _EXPIRE_REFRESH_TIME_PERCENT, + _FLAGS_QR_QUERY, + _LISTENER_TIME, + _MDNS_ADDR, + _MDNS_ADDR6, + _MDNS_PORT, + _TYPE_A, + _TYPE_AAAA, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) +from .dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from .exceptions import BadTypeInNameException +from .utils.name import service_type_name +from .utils.net import ( + IPVersion, + ServiceStateChange, + _encode_address, + _is_v6_address, +) +from .utils.struct import int2byte +from .utils.time import current_time_millis, millis_to_seconds + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from . import ( # pylint: disable=cyclic-import + ServiceListener, + Zeroconf, + ) + + +class Signal: + def __init__(self) -> None: + self._handlers = [] # type: List[Callable[..., None]] + + def fire(self, **kwargs: Any) -> None: + for h in list(self._handlers): + h(**kwargs) + + @property + def registration_interface(self) -> 'SignalRegistrationInterface': + return SignalRegistrationInterface(self._handlers) + + +class SignalRegistrationInterface: + def __init__(self, handlers: List[Callable[..., None]]) -> None: + self._handlers = handlers + + def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + self._handlers.append(handler) + return self + + def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + self._handlers.remove(handler) + return self + + +class RecordUpdateListener: + def update_record( # pylint: disable=no-self-use + self, zc: 'Zeroconf', now: float, record: DNSRecord + ) -> None: + """Update a single record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + """ + raise RuntimeError("update_record is deprecated and will be removed in a future version.") + + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Update multiple records in one shot. + + All records that are received in a single packet are passed + to update_records. + + This implementation is a compatiblity shim to ensure older code + that uses RecordUpdateListener as a base class will continue to + get calls to update_record. This method will raise + NotImplementedError in a future version. + + At this point the cache will not have the new records + """ + for record in records: + self.update_record(zc, now, record) + + def update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + """ + + +class _ServiceBrowserBase(RecordUpdateListener): + """Base class for ServiceBrowser.""" + + def __init__( + self, + zc: 'Zeroconf', + type_: Union[str, list], + handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, + listener: Optional['ServiceListener'] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + """Creates a browser for a specific type""" + assert handlers or listener, 'You need to specify at least one handler' + self.types = set(type_ if isinstance(type_, list) else [type_]) # type: Set[str] + for check_type_ in self.types: + if not check_type_.endswith(service_type_name(check_type_, strict=False)): + raise BadTypeInNameException + self.zc = zc + self.addr = addr + self.port = port + self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) + self._services = { + check_type_: {} for check_type_ in self.types + } # type: Dict[str, Dict[str, DNSRecord]] + current_time = current_time_millis() + self._next_time = {check_type_: current_time for check_type_ in self.types} + self._delay = {check_type_: delay for check_type_ in self.types} + self._pending_handlers = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] + self._handlers_to_call = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] + + self._service_state_changed = Signal() + + self.done = False + + if hasattr(handlers, 'add_service'): + listener = cast('ServiceListener', handlers) + handlers = None + + handlers = cast(List[Callable[..., None]], handlers or []) + + if listener: + + def on_change( + zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange + ) -> None: + assert listener is not None + args = (zeroconf, service_type, name) + if state_change is ServiceStateChange.Added: + listener.add_service(*args) + elif state_change is ServiceStateChange.Removed: + listener.remove_service(*args) + elif state_change is ServiceStateChange.Updated: + if hasattr(listener, 'update_service'): + listener.update_service(*args) + else: + warnings.warn( + "%r has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory." % (listener,), + FutureWarning, + ) + else: + raise NotImplementedError(state_change) + + handlers.append(on_change) + + for h in handlers: + self.service_state_changed.register_handler(h) + + @property + def service_state_changed(self) -> SignalRegistrationInterface: + return self._service_state_changed.registration_interface + + def _record_matching_type(self, record: DNSRecord) -> Optional[str]: + """Return the type if the record matches one of the types we are browsing.""" + return next((type_ for type_ in self.types if record.name.endswith(type_)), None) + + def _enqueue_callback( + self, + state_change: ServiceStateChange, + type_: str, + name: str, + ) -> None: + # Code to ensure we only do a single update message + # Precedence is; Added, Remove, Update + key = (name, type_) + if ( + state_change is ServiceStateChange.Added + or ( + state_change is ServiceStateChange.Removed + and self._pending_handlers.get(key) != ServiceStateChange.Added + ) + or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) + ): + self._pending_handlers[key] = state_change + + def _process_record_update( + self, + zc: 'Zeroconf', + now: float, + record: DNSRecord, + ) -> None: + """Process a single record update from a batch of updates.""" + expired = record.is_expired(now) + + if isinstance(record, DNSPointer): + if record.name not in self.types: + return + service_key = record.alias.lower() + services_by_type = self._services[record.name] + old_record = services_by_type.get(service_key) + if old_record is None: + services_by_type[service_key] = record + self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) + elif expired: + del services_by_type[service_key] + self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) + else: + old_record.reset_ttl(record) + expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + if expires < self._next_time[record.name]: + self._next_time[record.name] = expires + return + + # If its expired or already exists in the cache it cannot be updated. + if expired or self.zc.cache.get(record): + return + + if isinstance(record, DNSAddress): + # Only trigger an updated event if the address is new + if record.address in set( + service.address + for service in zc.cache.entries_with_name(record.name) + if isinstance(service, DNSAddress) + ): + return + + # Iterate through the DNSCache and callback any services that use this address + for service in self.zc.cache.entries_with_server(record.name): + type_ = self._record_matching_type(service) + if type_: + self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) + break + + return + + type_ = self._record_matching_type(record) + if type_: + self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) + + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Callback invoked by Zeroconf when new information arrives. + + Updates information required by browser in the Zeroconf cache. + + Ensures that there is are no unecessary duplicates in the list. + """ + for record in records: + self._process_record_update(zc, now, record) + + def update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + """ + self._handlers_to_call.update(self._pending_handlers) + self._pending_handlers.clear() + + def cancel(self) -> None: + """Cancel the browser.""" + self.done = True + self.zc.remove_listener(self) + + def run(self) -> None: + """Run the browser.""" + questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] + self.zc.add_listener(self, questions) + + def generate_ready_queries(self) -> Optional[DNSOutgoing]: + """Generate the service browser query for any type that is due.""" + out = None + now = current_time_millis() + + if min(self._next_time.values()) > now: + return out + + for type_, due in self._next_time.items(): + if due > now: + continue + + if out is None: + out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) + out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) + + for record in self._services[type_].values(): + if not record.is_stale(now): + out.add_answer_at_time(record, now) + + self._next_time[type_] = now + self._delay[type_] + self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + return out + + def _seconds_to_wait(self) -> Optional[float]: + """Returns the number of seconds to wait for the next event.""" + # If there are handlers to call + # we want to process them right away + if self._handlers_to_call: + return None + + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + + if next_time <= now: + return None + + return millis_to_seconds(next_time - now) + + +class ServiceBrowser(_ServiceBrowserBase, threading.Thread): + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__( + self, + zc: 'Zeroconf', + type_: Union[str, list], + handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, + listener: Optional['ServiceListener'] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + threading.Thread.__init__(self) + super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + self.daemon = True + self.start() + self.name = "zeroconf-ServiceBrowser-%s-%s" % ( + '-'.join([type_[:-7] for type_ in self.types]), + getattr(self, 'native_id', self.ident), + ) + + def cancel(self) -> None: + """Cancel the browser.""" + super().cancel() + self.join() + + def run(self) -> None: + """Run the browser thread.""" + super().run() + while True: + timeout = self._seconds_to_wait() + if timeout: + with self.zc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + self.zc.condition.wait(timeout) + + if self.zc.done or self.done: + return + + out = self.generate_ready_queries() + if out: + self.zc.send(out, addr=self.addr, port=self.port) + + if not self._handlers_to_call: + continue + + (name_type, state_change) = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) + + +class ServiceInfo(RecordUpdateListener): + """Service information. + + Constructor parameters are as follows: + + * `type_`: fully qualified service type name + * `name`: fully qualified service name + * `port`: port that the service runs on + * `weight`: weight of the service + * `priority`: priority of the service + * `properties`: dictionary of properties (or a bytes object holding the contents of the `text` field). + converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to + value-less attributes. + * `server`: fully qualified name for service host (defaults to name) + * `host_ttl`: ttl used for A/SRV records + * `other_ttl`: ttl used for PTR/TXT records + * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, + or in parsed form as text; at most one of those parameters can be provided) + + """ + + text = b'' + + def __init__( + self, + type_: str, + name: str, + port: Optional[int] = None, + weight: int = 0, + priority: int = 0, + properties: Union[bytes, Dict] = b'', + server: Optional[str] = None, + host_ttl: int = _DNS_HOST_TTL, + other_ttl: int = _DNS_OTHER_TTL, + *, + addresses: Optional[List[bytes]] = None, + parsed_addresses: Optional[List[str]] = None + ) -> None: + # Accept both none, or one, but not both. + if addresses is not None and parsed_addresses is not None: + raise TypeError("addresses and parsed_addresses cannot be provided together") + if not type_.endswith(service_type_name(name, strict=False)): + raise BadTypeInNameException + self.type = type_ + self.name = name + self.key = name.lower() + if addresses is not None: + self._addresses = addresses + elif parsed_addresses is not None: + self._addresses = [_encode_address(a) for a in parsed_addresses] + else: + self._addresses = [] + # This results in an ugly error when registering, better check now + invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] + if invalid: + raise TypeError( + 'Addresses must be bytes, got %s. Hint: convert string addresses ' + 'with socket.inet_pton' % invalid + ) + self.port = port + self.weight = weight + self.priority = priority + if server: + self.server = server + else: + self.server = name + self.server_key = self.server.lower() + self._properties = {} # type: Dict + self._set_properties(properties) + self.host_ttl = host_ttl + self.other_ttl = other_ttl + + @property + def addresses(self) -> List[bytes]: + """IPv4 addresses of this service. + + Only IPv4 addresses are returned for backward compatibility. + Use :meth:`addresses_by_version` or :meth:`parsed_addresses` to + include IPv6 addresses as well. + """ + return self.addresses_by_version(IPVersion.V4Only) + + @addresses.setter + def addresses(self, value: List[bytes]) -> None: + """Replace the addresses list. + + This replaces all currently stored addresses, both IPv4 and IPv6. + """ + self._addresses = value + + @property + def properties(self) -> Dict: + """If properties were set in the constructor this property returns the original dictionary + of type `Dict[Union[bytes, str], Any]`. + + If properties are coming from the network, after decoding a TXT record, the keys are always + bytes and the values are either bytes, if there was a value, even empty, or `None`, if there + was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. + """ + return self._properties + + def addresses_by_version(self, version: IPVersion) -> List[bytes]: + """List addresses matching IP version.""" + if version == IPVersion.V4Only: + return [addr for addr in self._addresses if not _is_v6_address(addr)] + if version == IPVersion.V6Only: + return list(filter(_is_v6_address, self._addresses)) + return self._addresses + + def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + """List addresses in their parsed string form.""" + result = self.addresses_by_version(version) + return [ + socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) + for addr in result + ] + + def _set_properties(self, properties: Union[bytes, Dict]) -> None: + """Sets properties and text of this info from a dictionary""" + if isinstance(properties, dict): + self._properties = properties + list_ = [] + result = b'' + for key, value in properties.items(): + if isinstance(key, str): + key = key.encode('utf-8') + + record = key + if value is not None: + if not isinstance(value, bytes): + value = str(value).encode('utf-8') + record += b'=' + value + list_.append(record) + for item in list_: + result = b''.join((result, int2byte(len(item)), item)) + self.text = result + else: + self.text = properties + + def _set_text(self, text: bytes) -> None: + """Sets properties and text given a text field""" + self.text = text + result = {} # type: Dict + end = len(text) + index = 0 + strs = [] + while index < end: + length = text[index] + index += 1 + strs.append(text[index : index + length]) + index += length + + for s in strs: + parts = s.split(b'=', 1) + try: + key, value = parts # type: Tuple[bytes, Optional[bytes]] + except ValueError: + # No equals sign at all + key = s + value = None + + # Only update non-existent properties + if key and result.get(key) is None: + result[key] = value + + self._properties = result + + def get_name(self) -> str: + """Name accessor""" + return self.name[: len(self.name) - len(self.type) - 1] + + def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: + """Updates service information from a DNS record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + """ + if record is not None: + self.update_records(zc, now, [record]) + + def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Updates service information from a DNS record.""" + update_addresses = False + for record in records: + if isinstance(record, DNSService): + update_addresses = True + self._process_record(record, now) + + # Only update addresses if the DNSService (.server) has changed + if not update_addresses: + return + + for record in self._get_address_records_from_cache(zc): + self._process_record(record, now) + + def _process_record(self, record: DNSRecord, now: float) -> None: + if record.is_expired(now): + return + + if isinstance(record, DNSAddress): + if record.key == self.server_key and record.address not in self._addresses: + self._addresses.append(record.address) + return + + if isinstance(record, DNSService): + if record.key != self.key: + return + self.name = record.name + self.server = record.server + self.server_key = record.server.lower() + self.port = record.port + self.weight = record.weight + self.priority = record.priority + return + + if isinstance(record, DNSText): + if record.key == self.key: + self._set_text(record.text) + + def dns_addresses(self, override_ttl: Optional[int] = None) -> List[DNSAddress]: + """Return matching DNSAddress from ServiceInfo.""" + return [ + DNSAddress( + self.server, + _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + address, + ) + for address in self._addresses + ] + + def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: + """Return DNSPointer from ServiceInfo.""" + return DNSPointer( + self.type, + _TYPE_PTR, + _CLASS_IN, + override_ttl if override_ttl is not None else self.other_ttl, + self.name, + ) + + def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: + """Return DNSService from ServiceInfo.""" + return DNSService( + self.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + self.priority, + self.weight, + cast(int, self.port), + self.server, + ) + + def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: + """Return DNSText from ServiceInfo.""" + return DNSText( + self.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.other_ttl, + self.text, + ) + + def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: + """Get the address records from the cache.""" + address_records = [] + cached_a_record = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) + if cached_a_record: + address_records.append(cached_a_record) + address_records.extend(zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) + return address_records + + def load_from_cache(self, zc: 'Zeroconf') -> bool: + """Populate the service info from the cache.""" + now = current_time_millis() + record_updates = [] + cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) + if cached_srv_record: + # If there is a srv record, A and AAAA will already + # be called and we do not want to do it twice + record_updates.append(cached_srv_record) + else: + record_updates.extend(self._get_address_records_from_cache(zc)) + cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) + if cached_txt_record: + record_updates.append(cached_txt_record) + self.update_records(zc, now, record_updates) + return self._is_complete + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return not (self.text is None or not self._addresses) + + def request(self, zc: 'Zeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + if self.load_from_cache(zc): + return True + + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + last = now + timeout + try: + # Do not set a question on the listener to preload from cache + # since we just checked it above in load_from_cache + zc.add_listener(self, None) + while not self._is_complete: + if last <= now: + return False + if next_ <= now: + out = self.generate_request_query(zc, now) + if not out.questions: + return True + zc.send(out) + next_ = now + delay + delay *= 2 + + zc.wait(min(next_, last) - now) + now = current_time_millis() + finally: + zc.remove_listener(self) + + return True + + def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: + """Generate the request query.""" + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) + return out + + def __eq__(self, other: object) -> bool: + """Tests equality of service name""" + return isinstance(other, ServiceInfo) and other.name == self.name + + def __repr__(self) -> str: + """String representation""" + return '%s(%s)' % ( + type(self).__name__, + ', '.join( + '%s=%r' % (name, getattr(self, name)) + for name in ( + 'type', + 'name', + 'addresses', + 'port', + 'weight', + 'priority', + 'server', + 'properties', + ) + ), + ) From bf0e867ead1e48e05a27fe8db69900d9dc387ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 15:05:04 -1000 Subject: [PATCH 0288/1433] Relocate core functions into zeroconf.core (#547) --- tests/test_core.py | 63 ++++ tests/test_init.py | 23 -- zeroconf/__init__.py | 837 +---------------------------------------- zeroconf/aio.py | 11 +- zeroconf/core.py | 878 +++++++++++++++++++++++++++++++++++++++++++ zeroconf/services.py | 10 + 6 files changed, 960 insertions(+), 862 deletions(-) create mode 100644 tests/test_core.py create mode 100644 zeroconf/core.py diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 000000000..5535ab59d --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf.core """ + +import itertools +import logging +import threading +import time +import unittest +import unittest.mock + + +import pytest +import zeroconf as r +from zeroconf import core + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads = frozenset(threading.enumerate()) - threads_before + assert not threads + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestReaper(unittest.TestCase): + @unittest.mock.patch.object(core, "_CACHE_CLEANUP_INTERVAL", 10) + def test_reaper(self): + zeroconf = core.Zeroconf(interfaces=['127.0.0.1']) + cache = zeroconf.cache + original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + zeroconf.cache.add(record_with_10s_ttl) + zeroconf.cache.add(record_with_1s_ttl) + entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + time.sleep(1) + with zeroconf.engine.condition: + zeroconf.engine._notify() + time.sleep(0.1) + entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + zeroconf.close() + assert entries != original_entries + assert entries_with_cache != original_entries + assert record_with_10s_ttl in entries + assert record_with_1s_ttl not in entries diff --git a/tests/test_init.py b/tests/test_init.py index a740cab40..99424a541 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1235,29 +1235,6 @@ def test_cache_empty_multiple_calls_does_not_throw(self): assert 'a' not in cache.cache -class TestReaper(unittest.TestCase): - @unittest.mock.patch.object(r, "_CACHE_CLEANUP_INTERVAL", 10) - def test_reaper(self): - zeroconf = Zeroconf(interfaces=['127.0.0.1']) - cache = zeroconf.cache - original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') - zeroconf.cache.add(record_with_10s_ttl) - zeroconf.cache.add(record_with_1s_ttl) - entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - time.sleep(1) - with zeroconf.engine.condition: - zeroconf.engine._notify() - time.sleep(0.1) - entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - zeroconf.close() - assert entries != original_entries - assert entries_with_cache != original_entries - assert record_with_10s_ttl in entries - assert record_with_1s_ttl not in entries - - class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8faba3041..ba473415c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -20,16 +20,9 @@ USA """ -import errno -import itertools -import platform -import select -import socket import sys -import threading import time -from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, Type, Union, cast +from typing import Optional, Union from typing import Set, Tuple # noqa # used in type hints from .const import ( # noqa # import needed for backwards compat @@ -83,6 +76,7 @@ _TYPE_TXT, _UNREGISTER_TIME, ) +from .core import NotifyListener, ServiceRegistry, Zeroconf # noqa # import needed for backwards compat from .dns import ( # noqa # import needed for backwards compat DNSAddress, DNSCache, @@ -105,8 +99,9 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) -from .logger import QuietLogger, log +from .logger import QuietLogger, log # noqa # import needed for backwards compat from .services import ( # noqa # import needed for backwards compat + instance_name_from_service_info, Signal, SignalRegistrationInterface, RecordUpdateListener, @@ -114,7 +109,7 @@ ServiceBrowser, ServiceInfo, ) -from .utils.name import service_type_name +from .utils.name import service_type_name # noqa # import needed for backwards compat from .utils.net import ( # noqa # import needed for backwards compat add_multicast_member, can_send_to, @@ -130,7 +125,7 @@ get_all_addresses, ) from .utils.struct import int2byte # noqa # import needed for backwards compat -from .utils.time import current_time_millis, millis_to_seconds +from .utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' @@ -160,169 +155,9 @@ ) -# utility functions - - -def instance_name_from_service_info(info: "ServiceInfo") -> str: - """Calculate the instance name from the ServiceInfo.""" - # This is kind of funky because of the subtype based tests - # need to make subtypes a first class citizen - service_name = service_type_name(info.name) - if not info.type.endswith(service_name): - raise BadTypeInNameException - return info.name[: -len(service_name) - 1] - - # implementation classes -class Engine(threading.Thread): - - """An engine wraps read access to sockets, allowing objects that - need to receive data from sockets to be called back when the - sockets are ready. - - A reader needs a handle_read() method, which is called when the socket - it is interested in is ready for reading. - - Writers are not implemented here, because we only send short - packets. - """ - - def __init__(self, zc: 'Zeroconf') -> None: - threading.Thread.__init__(self) - self.daemon = True - self.zc = zc - self.readers = {} # type: Dict[socket.socket, Listener] - self.timeout = 5 - self.condition = threading.Condition() - self.socketpair = socket.socketpair() - self._last_cache_cleanup = 0.0 - self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) - - def run(self) -> None: - while not self.zc.done: - try: - rr, _wr, _er = select.select([*self.readers.keys(), self.socketpair[0]], [], [], self.timeout) - - if self.zc.done: - return - - for socket_ in rr: - reader = self.readers.get(socket_) - if reader: - reader.handle_read(socket_) - - if self.socketpair[0] in rr: - # Clear the socket's buffer - self.socketpair[0].recv(128) - - except (select.error, socket.error) as e: - # If the socket was closed by another thread, during - # shutdown, ignore it and exit - if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: - raise - - now = current_time_millis() - if now - self._last_cache_cleanup >= _CACHE_CLEANUP_INTERVAL: - self._last_cache_cleanup = now - self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) - self.zc.record_manager.updates_complete() - - self.socketpair[0].close() - self.socketpair[1].close() - - def _notify(self) -> None: - self.condition.notify() - try: - self.socketpair[1].send(b'x') - except socket.error: - # The socketpair may already be closed during shutdown, ignore it - if not self.zc.done: - raise - - def add_reader(self, reader: 'Listener', socket_: socket.socket) -> None: - with self.condition: - self.readers[socket_] = reader - self._notify() - - def del_reader(self, socket_: socket.socket) -> None: - with self.condition: - del self.readers[socket_] - self._notify() - - -class Listener(QuietLogger): - - """A Listener is used by this module to listen on the multicast - group to which DNS messages are sent, allowing the implementation - to cache information as it arrives. - - It requires registration with an Engine object in order to have - the read() method called when a socket is available for reading.""" - - def __init__(self, zc: 'Zeroconf') -> None: - self.zc = zc - self.data = None # type: Optional[bytes] - - def handle_read(self, socket_: socket.socket) -> None: - try: - data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) - except Exception: # pylint: disable=broad-except - self.log_exception_warning('Error reading from socket %d', socket_.fileno()) - return - - if self.data == data: - log.debug( - 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', - addr, - port, - socket_.fileno(), - len(data), - data, - ) - return - - self.data = data - msg = DNSIncoming(data) - if msg.valid: - log.debug( - 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', - addr, - port, - socket_.fileno(), - msg, - len(data), - data, - ) - else: - log.debug( - 'Received from %r:%r (socket %d): (%d bytes) [%r]', - addr, - port, - socket_.fileno(), - len(data), - data, - ) - - if not msg.valid: - pass - - elif msg.is_query(): - # Always multicast responses - if port == _MDNS_PORT: - self.zc.handle_query(msg, None, _MDNS_PORT) - - # If it's not a multicast query, reply via unicast - # and multicast - elif port == _DNS_PORT: - self.zc.handle_query(msg, addr, port) - self.zc.handle_query(msg, None, _MDNS_PORT) - - else: - self.zc.handle_response(msg) - - class ServiceListener: def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() @@ -334,14 +169,6 @@ def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() -class NotifyListener: - """Receive notifications Zeroconf.notify_all is called.""" - - def notify_all(self) -> None: - """Called when Zeroconf.notify_all is called.""" - raise NotImplementedError() - - class ZeroconfServiceTypes(ServiceListener): """ Return all of the advertised services on any local networks @@ -393,655 +220,3 @@ def find( local_zc.close() return tuple(sorted(listener.found_services)) - - -class ServiceRegistry: - """A registry to keep track of services. - - This class exists to ensure services can - be safely added and removed with thread - safety. - """ - - def __init__( - self, - ) -> None: - """Create the ServiceRegistry class.""" - self.services = {} # type: Dict[str, ServiceInfo] - self.types = {} # type: Dict[str, List] - self.servers = {} # type: Dict[str, List] - self._lock = threading.Lock() # add and remove services thread safe - - def add(self, info: ServiceInfo) -> None: - """Add a new service to the registry.""" - - with self._lock: - self._add(info) - - def remove(self, info: ServiceInfo) -> None: - """Remove a new service from the registry.""" - - with self._lock: - self._remove(info) - - def update(self, info: ServiceInfo) -> None: - """Update new service in the registry.""" - - with self._lock: - self._remove(info) - self._add(info) - - def get_service_infos(self) -> List[ServiceInfo]: - """Return all ServiceInfo.""" - return list(self.services.values()) - - def get_info_name(self, name: str) -> Optional[ServiceInfo]: - """Return all ServiceInfo for the name.""" - return self.services.get(name) - - def get_types(self) -> List[str]: - """Return all types.""" - return list(self.types.keys()) - - def get_infos_type(self, type_: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching type.""" - return self._get_by_index("types", type_) - - def get_infos_server(self, server: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching server.""" - return self._get_by_index("servers", server) - - def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching the index.""" - service_infos = [] - - for name in getattr(self, attr).get(key, [])[:]: - info = self.services.get(name) - # Since we do not get under a lock since it would be - # a performance issue, its possible - # the service can be unregistered during the get - # so we must check if info is None - if info is not None: - service_infos.append(info) - - return service_infos - - def _add(self, info: ServiceInfo) -> None: - """Add a new service under the lock.""" - lower_name = info.name.lower() - if lower_name in self.services: - raise ServiceNameAlreadyRegistered - - self.services[lower_name] = info - self.types.setdefault(info.type, []).append(lower_name) - self.servers.setdefault(info.server, []).append(lower_name) - - def _remove(self, info: ServiceInfo) -> None: - """Remove a service under the lock.""" - lower_name = info.name.lower() - old_service_info = self.services[lower_name] - self.types[old_service_info.type].remove(lower_name) - self.servers[old_service_info.server].remove(lower_name) - del self.services[lower_name] - - -class QueryHandler: - """Query the ServiceRegistry.""" - - def __init__(self, registry: ServiceRegistry): - """Init the query handler.""" - self.registry = registry - - def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: - """Provide an answer to a service type enumeration query. - - https://datatracker.ietf.org/doc/html/rfc6763#section-9 - """ - for stype in self.registry.get_types(): - out.add_answer( - msg, - DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - - def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name.lower()): - out.add_answer(msg, service.dns_pointer()) - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer(service.dns_service()) - out.add_additional_answer(service.dns_text()) - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a query any query other then PTR. - - Add answer(s) for A, AAAA, SRV, or TXT queries. - """ - name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(name_to_find) # type: ignore - if service is None: - return - - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: - """Deal with incoming query packets. Provides a response if possible.""" - if unicast: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) - for question in msg.questions: - out.add_question(question) - else: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._answer_service_type_enumeration_query(msg, out) - else: - self._answer_ptr_query(msg, out, question) - continue - - self._answer_non_ptr_query(msg, out, question) - - if out is not None and out.answers: - out.id = msg.id - return out - - return None - - -class RecordManager: - """Process records into the cache and notify listeners.""" - - def __init__(self, zeroconf: 'Zeroconf') -> None: - """Init the record manager.""" - self.zc = zeroconf - self.cache = zeroconf.cache - self.listeners: List[RecordUpdateListener] = [] - - def updates(self, now: float, rec: List[DNSRecord]) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called before the cache is updated. - """ - for listener in self.listeners: - listener.update_records(self.zc, now, rec) - - def updates_complete(self) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called after the cache is updated. - """ - for listener in self.listeners: - listener.update_records_complete() - self.zc.notify_all() - - def updates_from_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - updates: List[DNSRecord] = [] - address_adds: List[DNSAddress] = [] - other_adds: List[DNSRecord] = [] - removes: List[DNSRecord] = [] - now = current_time_millis() - for record in msg.answers: - - updated = True - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # rfc6762#section-10.2 para 2 - # Since unique is set, all old records with that name, rrtype, - # and rrclass that were received more than one second ago are declared - # invalid, and marked to expire from the cache in one second. - for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): - if entry == record: - updated = False - if record.created - entry.created > 1000 and entry not in msg.answers: - removes.append(entry) - - expired = record.is_expired(now) - maybe_entry = self.cache.get(record) - if not expired: - if maybe_entry is not None: - maybe_entry.reset_ttl(record) - else: - if isinstance(record, DNSAddress): - address_adds.append(record) - else: - other_adds.append(record) - if updated: - updates.append(record) - elif maybe_entry is not None: - updates.append(record) - removes.append(record) - - if not updates and not address_adds and not other_adds and not removes: - return - - self.updates(now, updates) - # The cache adds must be processed AFTER we trigger - # the updates since we compare existing data - # with the new data and updating the cache - # ahead of update_record will cause listeners - # to miss changes - # - # We must process address adds before non-addresses - # otherwise a fetch of ServiceInfo may miss an address - # because it thinks the cache is complete - # - # The cache is processed under the context manager to ensure - # that any ServiceBrowser that is going to call - # zc.get_service_info will see the cached value - # but ONLY after all the record updates have been - # processsed. - self.cache.add_records(itertools.chain(address_adds, other_adds)) - # Removes are processed last since - # ServiceInfo could generate an un-needed query - # because the data was not yet populated. - self.cache.remove_records(removes) - self.updates_complete() - - def add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] - ) -> None: - """Adds a listener for a given question. The listener will have - its update_record method called when information is available to - answer the question(s).""" - self.listeners.append(listener) - - if question is not None: - now = current_time_millis() - records = [] - questions = [question] if isinstance(question, DNSQuestion) else question - for single_question in questions: - for record in self.cache.entries_with_name(single_question.name): - if single_question.answered_by(record) and not record.is_expired(now): - records.append(record) - if records: - listener.update_records(self.zc, now, records) - listener.update_records_complete() - - self.zc.notify_all() - - def remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener.""" - try: - self.listeners.remove(listener) - self.zc.notify_all() - except ValueError as e: - log.exception('Failed to remove listener: %r', e) - - -class Zeroconf(QuietLogger): - - """Implementation of Zeroconf Multicast DNS Service Discovery - - Supports registration, unregistration, queries and browsing. - """ - - def __init__( - self, - interfaces: InterfacesType = InterfaceChoice.All, - unicast: bool = False, - ip_version: Optional[IPVersion] = None, - apple_p2p: bool = False, - ) -> None: - """Creates an instance of the Zeroconf class, establishing - multicast communications, listening and reaping threads. - - :param interfaces: :class:`InterfaceChoice` or a list of IP addresses - (IPv4 and IPv6) and interface indexes (IPv6 only). - - IPv6 notes for non-POSIX systems: - * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` - on Python versions before 3.8. - - Also listening on loopback (``::1``) doesn't work, use a real address. - :param ip_version: IP versions to support. If `choice` is a list, the default is detected - from it. Otherwise defaults to V4 only for backward compatibility. - :param apple_p2p: use AWDL interface (only macOS) - """ - if ip_version is None: - ip_version = autodetect_ip_version(interfaces) - - # hook for threads - self._GLOBAL_DONE = False - self.unicast = unicast - - if apple_p2p and not platform.system() == 'Darwin': - raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') - - self._listen_socket, self._respond_sockets = create_sockets( - interfaces, unicast, ip_version, apple_p2p=apple_p2p - ) - log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) - self.multi_socket = unicast or interfaces is not InterfaceChoice.Default - - self._notify_listeners = [] # type: List[NotifyListener] - self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] - self.registry = ServiceRegistry() - self.query_handler = QueryHandler(self.registry) - self.cache = DNSCache() - self.record_manager = RecordManager(self) - - self.condition = threading.Condition() - - self.engine = Engine(self) - self.listener = Listener(self) - if not unicast: - self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) - if self.multi_socket: - for s in self._respond_sockets: - self.engine.add_reader(self.listener, s) - # Start the engine only after all - # the readers have been added to avoid - # missing any packets that are on the wire - self.engine.start() - - @property - def done(self) -> bool: - return self._GLOBAL_DONE - - @property - def listeners(self) -> List[RecordUpdateListener]: - return self.record_manager.listeners - - def wait(self, timeout: float) -> None: - """Calling thread waits for a given number of milliseconds or - until notified.""" - with self.condition: - self.condition.wait(millis_to_seconds(timeout)) - - def notify_all(self) -> None: - """Notifies all waiting threads""" - with self.condition: - self.condition.notify_all() - for listener in self._notify_listeners: - listener.notify_all() - - def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: - """Returns network's service information for a particular - name and type, or None if no service matches by the timeout, - which defaults to 3 seconds.""" - info = ServiceInfo(type_, name) - if info.request(self, timeout): - return info - return None - - def add_notify_listener(self, listener: NotifyListener) -> None: - """Adds a listener to receive notify_all events.""" - self._notify_listeners.append(listener) - - def remove_notify_listener(self, listener: NotifyListener) -> None: - """Removes a listener from the set that is currently listening.""" - self._notify_listeners.remove(listener) - - def add_service_listener(self, type_: str, listener: ServiceListener) -> None: - """Adds a listener for a particular service type. This object - will then have its add_service and remove_service methods called when - services of that type become available and unavailable.""" - self.remove_service_listener(listener) - self.browsers[listener] = ServiceBrowser(self, type_, listener) - - def remove_service_listener(self, listener: ServiceListener) -> None: - """Removes a listener from the set that is currently listening.""" - if listener in self.browsers: - self.browsers[listener].cancel() - del self.browsers[listener] - - def remove_all_service_listeners(self) -> None: - """Removes a listener from the set that is currently listening.""" - for listener in list(self.browsers): - self.remove_service_listener(listener) - - def register_service( - self, - info: ServiceInfo, - ttl: Optional[int] = None, - allow_name_change: bool = False, - cooperating_responders: bool = False, - ) -> None: - """Registers service information to the network with a default TTL. - Zeroconf will then respond to requests for information for that - service. The name of the service may be changed if needed to make - it unique on the network. Additionally multiple cooperating responders - can register the same service on the network for resilience - (if you want this behavior set `cooperating_responders` to `True`).""" - if ttl is not None: - # ttl argument is used to maintain backward compatibility - # Setting TTLs via ServiceInfo is preferred - info.host_ttl = ttl - info.other_ttl = ttl - self.check_service(info, allow_name_change, cooperating_responders) - self.registry.add(info) - self._broadcast_service(info, _REGISTER_TIME, None) - - def update_service(self, info: ServiceInfo) -> None: - """Registers service information to the network with a default TTL. - Zeroconf will then respond to requests for information for that - service.""" - - self.registry.update(info) - self._broadcast_service(info, _REGISTER_TIME, None) - - def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: - """Send a broadcasts to announce a service at intervals.""" - now = current_time_millis() - next_time = now - i = 0 - while i < 3: - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - - self.send_service_broadcast(info, ttl) - i += 1 - next_time += interval - - def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None: - """Send a broadcast to announce a service.""" - self.send(self.generate_service_broadcast(info, ttl)) - - def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> DNSOutgoing: - """Generate a broadcast to announce a service.""" - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - self._add_broadcast_answer(out, info, ttl) - return out - - def send_service_query(self, info: ServiceInfo) -> None: - """Send a query to lookup a service.""" - self.send(self.generate_service_query(info)) - - def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use - """Generate a query to lookup a service.""" - out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) - out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) - out.add_authorative_answer(info.dns_pointer()) - return out - - def _add_broadcast_answer( # pylint: disable=no-self-use - self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int] - ) -> None: - """Add answers to broadcast a service.""" - other_ttl = info.other_ttl if override_ttl is None else override_ttl - host_ttl = info.host_ttl if override_ttl is None else override_ttl - out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) - out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) - out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) - for dns_address in info.dns_addresses(override_ttl=host_ttl): - out.add_answer_at_time(dns_address, 0) - - def unregister_service(self, info: ServiceInfo) -> None: - """Unregister a service.""" - self.registry.remove(info) - self._broadcast_service(info, _UNREGISTER_TIME, 0) - - def unregister_all_services(self) -> None: - """Unregister all registered services.""" - service_infos = self.registry.get_service_infos() - if not service_infos: - return - now = current_time_millis() - next_time = now - i = 0 - while i < 3: - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - for info in service_infos: - self._add_broadcast_answer(out, info, 0) - self.send(out) - i += 1 - next_time += _UNREGISTER_TIME - - def check_service( - self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False - ) -> None: - """Checks the network for a unique service name, modifying the - ServiceInfo passed in if it is not unique.""" - instance_name = instance_name_from_service_info(info) - if cooperating_responders: - return - next_instance_number = 2 - next_time = now = current_time_millis() - i = 0 - while i < 3: - # check for a name conflict - while self.cache.current_entry_with_name_and_alias(info.type, info.name): - if not allow_name_change: - raise NonUniqueNameException - - # change the name and look for a conflict - info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) - next_instance_number += 1 - service_type_name(info.name) - next_time = now - i = 0 - - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - - self.send_service_query(info) - i += 1 - next_time += _CHECK_TIME - - def add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] - ) -> None: - """Adds a listener for a given question. The listener will have - its update_record method called when information is available to - answer the question(s).""" - self.record_manager.add_listener(listener, question) - - def remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener.""" - self.record_manager.remove_listener(listener) - - def handle_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - self.record_manager.updates_from_response(msg) - - def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: - """Deal with incoming query packets. Provides a response if - possible.""" - out = self.query_handler.response(msg, port != _MDNS_PORT) - if out: - self.send(out, addr, port) - - def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: - """Sends an outgoing packet.""" - packets = out.packets() - packet_num = 0 - for packet in packets: - packet_num += 1 - if len(packet) > _MAX_MSG_ABSOLUTE: - self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) - return - log.debug('Sending (%d bytes #%d) %r as %r...', len(packet), packet_num, out, packet) - for s in self._respond_sockets: - if self._GLOBAL_DONE: - return - try: - if addr is None: - real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR - elif not can_send_to(s, addr): - continue - else: - real_addr = addr - bytes_sent = s.sendto(packet, 0, (real_addr, port)) - except OSError as exc: - if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: - # with IPv6 we don't have a reliable way to determine if an interface actually has - # IPV6 support, so we have to try and ignore errors. - continue - # on send errors, log the exception and keep going - self.log_exception_warning('Error sending through socket %d', s.fileno()) - except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions - # on send errors, log the exception and keep going - self.log_exception_warning('Error sending through socket %d', s.fileno()) - else: - if bytes_sent != len(packet): - self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) - - def close(self) -> None: - """Ends the background threads, and prevent this instance from - servicing further queries.""" - if self._GLOBAL_DONE: - return - # remove service listeners - self.remove_all_service_listeners() - self.unregister_all_services() - self._GLOBAL_DONE = True - - # shutdown recv socket and thread - if not self.unicast: - self.engine.del_reader(cast(socket.socket, self._listen_socket)) - cast(socket.socket, self._listen_socket).close() - if self.multi_socket: - for s in self._respond_sockets: - self.engine.del_reader(s) - self.engine.join() - # shutdown the rest - self.notify_all() - for s in self._respond_sockets: - s.close() - - def __enter__(self) -> 'Zeroconf': - return self - - def __exit__( # pylint: disable=useless-return - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: - self.close() - return None diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 9a23be930..82e861998 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,16 +26,11 @@ from types import TracebackType # noqa # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Type, Union -from . import ( - DNSOutgoing, - NotifyListener, - ServiceInfo, - Zeroconf, - _ServiceBrowserBase, - instance_name_from_service_info, -) from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME +from .core import NotifyListener, Zeroconf +from .dns import DNSOutgoing from .exceptions import NonUniqueNameException +from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from .utils.aio import wait_condition_or_timeout from .utils.net import IPVersion, InterfaceChoice, InterfacesType from .utils.time import current_time_millis, millis_to_seconds diff --git a/zeroconf/core.py b/zeroconf/core.py new file mode 100644 index 000000000..8e5ab6114 --- /dev/null +++ b/zeroconf/core.py @@ -0,0 +1,878 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import errno +import itertools +import platform +import select +import socket +import threading +from types import TracebackType # noqa # used in type hints +from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, cast + +from .const import ( + _CACHE_CLEANUP_INTERVAL, + _CHECK_TIME, + _CLASS_IN, + _DNS_OTHER_TTL, + _DNS_PORT, + _FLAGS_AA, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _MAX_MSG_ABSOLUTE, + _MDNS_ADDR, + _MDNS_ADDR6, + _MDNS_PORT, + _REGISTER_TIME, + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_A, + _TYPE_ANY, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, + _UNREGISTER_TIME, +) +from .dns import DNSAddress, DNSCache, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from .exceptions import NonUniqueNameException, ServiceNameAlreadyRegistered +from .logger import QuietLogger, log +from .services import RecordUpdateListener, ServiceBrowser, ServiceInfo, instance_name_from_service_info +from .utils.name import service_type_name +from .utils.net import ( + IPVersion, + InterfaceChoice, + InterfacesType, + autodetect_ip_version, + can_send_to, + create_sockets, +) +from .utils.time import current_time_millis, millis_to_seconds + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from . import ServiceListener # pylint: disable=cyclic-import + + +class NotifyListener: + """Receive notifications Zeroconf.notify_all is called.""" + + def notify_all(self) -> None: + """Called when Zeroconf.notify_all is called.""" + raise NotImplementedError() + + +class Engine(threading.Thread): + + """An engine wraps read access to sockets, allowing objects that + need to receive data from sockets to be called back when the + sockets are ready. + + A reader needs a handle_read() method, which is called when the socket + it is interested in is ready for reading. + + Writers are not implemented here, because we only send short + packets. + """ + + def __init__(self, zc: 'Zeroconf') -> None: + threading.Thread.__init__(self) + self.daemon = True + self.zc = zc + self.readers = {} # type: Dict[socket.socket, Listener] + self.timeout = 5 + self.condition = threading.Condition() + self.socketpair = socket.socketpair() + self._last_cache_cleanup = 0.0 + self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) + + def run(self) -> None: + while not self.zc.done: + try: + rr, _wr, _er = select.select([*self.readers.keys(), self.socketpair[0]], [], [], self.timeout) + + if self.zc.done: + return + + for socket_ in rr: + reader = self.readers.get(socket_) + if reader: + reader.handle_read(socket_) + + if self.socketpair[0] in rr: + # Clear the socket's buffer + self.socketpair[0].recv(128) + + except (select.error, socket.error) as e: + # If the socket was closed by another thread, during + # shutdown, ignore it and exit + if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: + raise + + now = current_time_millis() + if now - self._last_cache_cleanup >= _CACHE_CLEANUP_INTERVAL: + self._last_cache_cleanup = now + self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) + self.zc.record_manager.updates_complete() + + self.socketpair[0].close() + self.socketpair[1].close() + + def _notify(self) -> None: + self.condition.notify() + try: + self.socketpair[1].send(b'x') + except socket.error: + # The socketpair may already be closed during shutdown, ignore it + if not self.zc.done: + raise + + def add_reader(self, reader: 'Listener', socket_: socket.socket) -> None: + with self.condition: + self.readers[socket_] = reader + self._notify() + + def del_reader(self, socket_: socket.socket) -> None: + with self.condition: + del self.readers[socket_] + self._notify() + + +class Listener(QuietLogger): + + """A Listener is used by this module to listen on the multicast + group to which DNS messages are sent, allowing the implementation + to cache information as it arrives. + + It requires registration with an Engine object in order to have + the read() method called when a socket is available for reading.""" + + def __init__(self, zc: 'Zeroconf') -> None: + self.zc = zc + self.data = None # type: Optional[bytes] + + def handle_read(self, socket_: socket.socket) -> None: + try: + data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) + except Exception: # pylint: disable=broad-except + self.log_exception_warning('Error reading from socket %d', socket_.fileno()) + return + + if self.data == data: + log.debug( + 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', + addr, + port, + socket_.fileno(), + len(data), + data, + ) + return + + self.data = data + msg = DNSIncoming(data) + if msg.valid: + log.debug( + 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', + addr, + port, + socket_.fileno(), + msg, + len(data), + data, + ) + else: + log.debug( + 'Received from %r:%r (socket %d): (%d bytes) [%r]', + addr, + port, + socket_.fileno(), + len(data), + data, + ) + + if not msg.valid: + pass + + elif msg.is_query(): + # Always multicast responses + if port == _MDNS_PORT: + self.zc.handle_query(msg, None, _MDNS_PORT) + + # If it's not a multicast query, reply via unicast + # and multicast + elif port == _DNS_PORT: + self.zc.handle_query(msg, addr, port) + self.zc.handle_query(msg, None, _MDNS_PORT) + + else: + self.zc.handle_response(msg) + + +class ServiceRegistry: + """A registry to keep track of services. + + This class exists to ensure services can + be safely added and removed with thread + safety. + """ + + def __init__( + self, + ) -> None: + """Create the ServiceRegistry class.""" + self.services = {} # type: Dict[str, ServiceInfo] + self.types = {} # type: Dict[str, List] + self.servers = {} # type: Dict[str, List] + self._lock = threading.Lock() # add and remove services thread safe + + def add(self, info: ServiceInfo) -> None: + """Add a new service to the registry.""" + + with self._lock: + self._add(info) + + def remove(self, info: ServiceInfo) -> None: + """Remove a new service from the registry.""" + + with self._lock: + self._remove(info) + + def update(self, info: ServiceInfo) -> None: + """Update new service in the registry.""" + + with self._lock: + self._remove(info) + self._add(info) + + def get_service_infos(self) -> List[ServiceInfo]: + """Return all ServiceInfo.""" + return list(self.services.values()) + + def get_info_name(self, name: str) -> Optional[ServiceInfo]: + """Return all ServiceInfo for the name.""" + return self.services.get(name) + + def get_types(self) -> List[str]: + """Return all types.""" + return list(self.types.keys()) + + def get_infos_type(self, type_: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching type.""" + return self._get_by_index("types", type_) + + def get_infos_server(self, server: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching server.""" + return self._get_by_index("servers", server) + + def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching the index.""" + service_infos = [] + + for name in getattr(self, attr).get(key, [])[:]: + info = self.services.get(name) + # Since we do not get under a lock since it would be + # a performance issue, its possible + # the service can be unregistered during the get + # so we must check if info is None + if info is not None: + service_infos.append(info) + + return service_infos + + def _add(self, info: ServiceInfo) -> None: + """Add a new service under the lock.""" + lower_name = info.name.lower() + if lower_name in self.services: + raise ServiceNameAlreadyRegistered + + self.services[lower_name] = info + self.types.setdefault(info.type, []).append(lower_name) + self.servers.setdefault(info.server, []).append(lower_name) + + def _remove(self, info: ServiceInfo) -> None: + """Remove a service under the lock.""" + lower_name = info.name.lower() + old_service_info = self.services[lower_name] + self.types[old_service_info.type].remove(lower_name) + self.servers[old_service_info.server].remove(lower_name) + del self.services[lower_name] + + +class QueryHandler: + """Query the ServiceRegistry.""" + + def __init__(self, registry: ServiceRegistry): + """Init the query handler.""" + self.registry = registry + + def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: + """Provide an answer to a service type enumeration query. + + https://datatracker.ietf.org/doc/html/rfc6763#section-9 + """ + for stype in self.registry.get_types(): + out.add_answer( + msg, + DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + ), + ) + + def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a PTR query.""" + for service in self.registry.get_infos_type(question.name.lower()): + out.add_answer(msg, service.dns_pointer()) + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer(service.dns_service()) + out.add_additional_answer(service.dns_text()) + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a query any query other then PTR. + + Add answer(s) for A, AAAA, SRV, or TXT queries. + """ + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): + for dns_address in service.dns_addresses(): + out.add_answer(msg, dns_address) + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + return + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, service.dns_service()) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, service.dns_text()) + if question.type == _TYPE_SRV: + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: + """Deal with incoming query packets. Provides a response if possible.""" + if unicast: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) + for question in msg.questions: + out.add_question(question) + else: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + for question in msg.questions: + if question.type == _TYPE_PTR: + if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + self._answer_service_type_enumeration_query(msg, out) + else: + self._answer_ptr_query(msg, out, question) + continue + + self._answer_non_ptr_query(msg, out, question) + + if out is not None and out.answers: + out.id = msg.id + return out + + return None + + +class RecordManager: + """Process records into the cache and notify listeners.""" + + def __init__(self, zeroconf: 'Zeroconf') -> None: + """Init the record manager.""" + self.zc = zeroconf + self.cache = zeroconf.cache + self.listeners: List[RecordUpdateListener] = [] + + def updates(self, now: float, rec: List[DNSRecord]) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called before the cache is updated. + """ + for listener in self.listeners: + listener.update_records(self.zc, now, rec) + + def updates_complete(self) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called after the cache is updated. + """ + for listener in self.listeners: + listener.update_records_complete() + self.zc.notify_all() + + def updates_from_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + updates: List[DNSRecord] = [] + address_adds: List[DNSAddress] = [] + other_adds: List[DNSRecord] = [] + removes: List[DNSRecord] = [] + now = current_time_millis() + for record in msg.answers: + + updated = True + + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): + if entry == record: + updated = False + if record.created - entry.created > 1000 and entry not in msg.answers: + removes.append(entry) + + expired = record.is_expired(now) + maybe_entry = self.cache.get(record) + if not expired: + if maybe_entry is not None: + maybe_entry.reset_ttl(record) + else: + if isinstance(record, DNSAddress): + address_adds.append(record) + else: + other_adds.append(record) + if updated: + updates.append(record) + elif maybe_entry is not None: + updates.append(record) + removes.append(record) + + if not updates and not address_adds and not other_adds and not removes: + return + + self.updates(now, updates) + # The cache adds must be processed AFTER we trigger + # the updates since we compare existing data + # with the new data and updating the cache + # ahead of update_record will cause listeners + # to miss changes + # + # We must process address adds before non-addresses + # otherwise a fetch of ServiceInfo may miss an address + # because it thinks the cache is complete + # + # The cache is processed under the context manager to ensure + # that any ServiceBrowser that is going to call + # zc.get_service_info will see the cached value + # but ONLY after all the record updates have been + # processsed. + self.cache.add_records(itertools.chain(address_adds, other_adds)) + # Removes are processed last since + # ServiceInfo could generate an un-needed query + # because the data was not yet populated. + self.cache.remove_records(removes) + self.updates_complete() + + def add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s).""" + self.listeners.append(listener) + + if question is not None: + now = current_time_millis() + records = [] + questions = [question] if isinstance(question, DNSQuestion) else question + for single_question in questions: + for record in self.cache.entries_with_name(single_question.name): + if single_question.answered_by(record) and not record.is_expired(now): + records.append(record) + if records: + listener.update_records(self.zc, now, records) + listener.update_records_complete() + + self.zc.notify_all() + + def remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener.""" + try: + self.listeners.remove(listener) + self.zc.notify_all() + except ValueError as e: + log.exception('Failed to remove listener: %r', e) + + +class Zeroconf(QuietLogger): + + """Implementation of Zeroconf Multicast DNS Service Discovery + + Supports registration, unregistration, queries and browsing. + """ + + def __init__( + self, + interfaces: InterfacesType = InterfaceChoice.All, + unicast: bool = False, + ip_version: Optional[IPVersion] = None, + apple_p2p: bool = False, + ) -> None: + """Creates an instance of the Zeroconf class, establishing + multicast communications, listening and reaping threads. + + :param interfaces: :class:`InterfaceChoice` or a list of IP addresses + (IPv4 and IPv6) and interface indexes (IPv6 only). + + IPv6 notes for non-POSIX systems: + * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` + on Python versions before 3.8. + + Also listening on loopback (``::1``) doesn't work, use a real address. + :param ip_version: IP versions to support. If `choice` is a list, the default is detected + from it. Otherwise defaults to V4 only for backward compatibility. + :param apple_p2p: use AWDL interface (only macOS) + """ + if ip_version is None: + ip_version = autodetect_ip_version(interfaces) + + # hook for threads + self._GLOBAL_DONE = False + self.unicast = unicast + + if apple_p2p and not platform.system() == 'Darwin': + raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') + + self._listen_socket, self._respond_sockets = create_sockets( + interfaces, unicast, ip_version, apple_p2p=apple_p2p + ) + log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) + self.multi_socket = unicast or interfaces is not InterfaceChoice.Default + + self._notify_listeners = [] # type: List[NotifyListener] + self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] + self.registry = ServiceRegistry() + self.query_handler = QueryHandler(self.registry) + self.cache = DNSCache() + self.record_manager = RecordManager(self) + + self.condition = threading.Condition() + + self.engine = Engine(self) + self.listener = Listener(self) + if not unicast: + self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) + if self.multi_socket: + for s in self._respond_sockets: + self.engine.add_reader(self.listener, s) + # Start the engine only after all + # the readers have been added to avoid + # missing any packets that are on the wire + self.engine.start() + + @property + def done(self) -> bool: + return self._GLOBAL_DONE + + @property + def listeners(self) -> List[RecordUpdateListener]: + return self.record_manager.listeners + + def wait(self, timeout: float) -> None: + """Calling thread waits for a given number of milliseconds or + until notified.""" + with self.condition: + self.condition.wait(millis_to_seconds(timeout)) + + def notify_all(self) -> None: + """Notifies all waiting threads""" + with self.condition: + self.condition.notify_all() + for listener in self._notify_listeners: + listener.notify_all() + + def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: + """Returns network's service information for a particular + name and type, or None if no service matches by the timeout, + which defaults to 3 seconds.""" + info = ServiceInfo(type_, name) + if info.request(self, timeout): + return info + return None + + def add_notify_listener(self, listener: NotifyListener) -> None: + """Adds a listener to receive notify_all events.""" + self._notify_listeners.append(listener) + + def remove_notify_listener(self, listener: NotifyListener) -> None: + """Removes a listener from the set that is currently listening.""" + self._notify_listeners.remove(listener) + + def add_service_listener(self, type_: str, listener: 'ServiceListener') -> None: + """Adds a listener for a particular service type. This object + will then have its add_service and remove_service methods called when + services of that type become available and unavailable.""" + self.remove_service_listener(listener) + self.browsers[listener] = ServiceBrowser(self, type_, listener) + + def remove_service_listener(self, listener: 'ServiceListener') -> None: + """Removes a listener from the set that is currently listening.""" + if listener in self.browsers: + self.browsers[listener].cancel() + del self.browsers[listener] + + def remove_all_service_listeners(self) -> None: + """Removes a listener from the set that is currently listening.""" + for listener in list(self.browsers): + self.remove_service_listener(listener) + + def register_service( + self, + info: ServiceInfo, + ttl: Optional[int] = None, + allow_name_change: bool = False, + cooperating_responders: bool = False, + ) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. The name of the service may be changed if needed to make + it unique on the network. Additionally multiple cooperating responders + can register the same service on the network for resilience + (if you want this behavior set `cooperating_responders` to `True`).""" + if ttl is not None: + # ttl argument is used to maintain backward compatibility + # Setting TTLs via ServiceInfo is preferred + info.host_ttl = ttl + info.other_ttl = ttl + self.check_service(info, allow_name_change, cooperating_responders) + self.registry.add(info) + self._broadcast_service(info, _REGISTER_TIME, None) + + def update_service(self, info: ServiceInfo) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service.""" + + self.registry.update(info) + self._broadcast_service(info, _REGISTER_TIME, None) + + def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + """Send a broadcasts to announce a service at intervals.""" + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + + self.send_service_broadcast(info, ttl) + i += 1 + next_time += interval + + def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None: + """Send a broadcast to announce a service.""" + self.send(self.generate_service_broadcast(info, ttl)) + + def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> DNSOutgoing: + """Generate a broadcast to announce a service.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + self._add_broadcast_answer(out, info, ttl) + return out + + def send_service_query(self, info: ServiceInfo) -> None: + """Send a query to lookup a service.""" + self.send(self.generate_service_query(info)) + + def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use + """Generate a query to lookup a service.""" + out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) + out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) + out.add_authorative_answer(info.dns_pointer()) + return out + + def _add_broadcast_answer( # pylint: disable=no-self-use + self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int] + ) -> None: + """Add answers to broadcast a service.""" + other_ttl = info.other_ttl if override_ttl is None else override_ttl + host_ttl = info.host_ttl if override_ttl is None else override_ttl + out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) + out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) + out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) + for dns_address in info.dns_addresses(override_ttl=host_ttl): + out.add_answer_at_time(dns_address, 0) + + def unregister_service(self, info: ServiceInfo) -> None: + """Unregister a service.""" + self.registry.remove(info) + self._broadcast_service(info, _UNREGISTER_TIME, 0) + + def unregister_all_services(self) -> None: + """Unregister all registered services.""" + service_infos = self.registry.get_service_infos() + if not service_infos: + return + now = current_time_millis() + next_time = now + i = 0 + while i < 3: + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + for info in service_infos: + self._add_broadcast_answer(out, info, 0) + self.send(out) + i += 1 + next_time += _UNREGISTER_TIME + + def check_service( + self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False + ) -> None: + """Checks the network for a unique service name, modifying the + ServiceInfo passed in if it is not unique.""" + instance_name = instance_name_from_service_info(info) + if cooperating_responders: + return + next_instance_number = 2 + next_time = now = current_time_millis() + i = 0 + while i < 3: + # check for a name conflict + while self.cache.current_entry_with_name_and_alias(info.type, info.name): + if not allow_name_change: + raise NonUniqueNameException + + # change the name and look for a conflict + info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) + next_instance_number += 1 + service_type_name(info.name) + next_time = now + i = 0 + + if now < next_time: + self.wait(next_time - now) + now = current_time_millis() + continue + + self.send_service_query(info) + i += 1 + next_time += _CHECK_TIME + + def add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s).""" + self.record_manager.add_listener(listener, question) + + def remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener.""" + self.record_manager.remove_listener(listener) + + def handle_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + self.record_manager.updates_from_response(msg) + + def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: + """Deal with incoming query packets. Provides a response if + possible.""" + out = self.query_handler.response(msg, port != _MDNS_PORT) + if out: + self.send(out, addr, port) + + def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + """Sends an outgoing packet.""" + packets = out.packets() + packet_num = 0 + for packet in packets: + packet_num += 1 + if len(packet) > _MAX_MSG_ABSOLUTE: + self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) + return + log.debug('Sending (%d bytes #%d) %r as %r...', len(packet), packet_num, out, packet) + for s in self._respond_sockets: + if self._GLOBAL_DONE: + return + try: + if addr is None: + real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + elif not can_send_to(s, addr): + continue + else: + real_addr = addr + bytes_sent = s.sendto(packet, 0, (real_addr, port)) + except OSError as exc: + if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: + # with IPv6 we don't have a reliable way to determine if an interface actually has + # IPV6 support, so we have to try and ignore errors. + continue + # on send errors, log the exception and keep going + self.log_exception_warning('Error sending through socket %d', s.fileno()) + except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions + # on send errors, log the exception and keep going + self.log_exception_warning('Error sending through socket %d', s.fileno()) + else: + if bytes_sent != len(packet): + self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) + + def close(self) -> None: + """Ends the background threads, and prevent this instance from + servicing further queries.""" + if self._GLOBAL_DONE: + return + # remove service listeners + self.remove_all_service_listeners() + self.unregister_all_services() + self._GLOBAL_DONE = True + + # shutdown recv socket and thread + if not self.unicast: + self.engine.del_reader(cast(socket.socket, self._listen_socket)) + cast(socket.socket, self._listen_socket).close() + if self.multi_socket: + for s in self._respond_sockets: + self.engine.del_reader(s) + self.engine.join() + # shutdown the rest + self.notify_all() + for s in self._respond_sockets: + s.close() + + def __enter__(self) -> 'Zeroconf': + return self + + def __exit__( # pylint: disable=useless-return + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + self.close() + return None diff --git a/zeroconf/services.py b/zeroconf/services.py index 5c61741e8..6b47355fb 100644 --- a/zeroconf/services.py +++ b/zeroconf/services.py @@ -65,6 +65,16 @@ ) +def instance_name_from_service_info(info: "ServiceInfo") -> str: + """Calculate the instance name from the ServiceInfo.""" + # This is kind of funky because of the subtype based tests + # need to make subtypes a first class citizen + service_name = service_type_name(info.name) + if not info.type.endswith(service_name): + raise BadTypeInNameException + return info.name[: -len(service_name) - 1] + + class Signal: def __init__(self) -> None: self._handlers = [] # type: List[Callable[..., None]] From c8a0a71c31252bbc4a242701bc786eb419e1a8e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 15:28:08 -1000 Subject: [PATCH 0289/1433] Move ServiceStateChange to zeroconf.services (#548) --- zeroconf/__init__.py | 2 +- zeroconf/services.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ba473415c..0cfd50292 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -108,6 +108,7 @@ _ServiceBrowserBase, ServiceBrowser, ServiceInfo, + ServiceStateChange, ) from .utils.name import service_type_name # noqa # import needed for backwards compat from .utils.net import ( # noqa # import needed for backwards compat @@ -118,7 +119,6 @@ get_all_addresses_v6, InterfaceChoice, InterfacesType, - ServiceStateChange, IPVersion, _is_v6_address, _encode_address, diff --git a/zeroconf/services.py b/zeroconf/services.py index 6b47355fb..b0ad93d6d 100644 --- a/zeroconf/services.py +++ b/zeroconf/services.py @@ -20,6 +20,7 @@ USA """ +import enum import socket import threading import warnings @@ -50,7 +51,6 @@ from .utils.name import service_type_name from .utils.net import ( IPVersion, - ServiceStateChange, _encode_address, _is_v6_address, ) @@ -65,6 +65,13 @@ ) +@enum.unique +class ServiceStateChange(enum.Enum): + Added = 1 + Removed = 2 + Updated = 3 + + def instance_name_from_service_info(info: "ServiceInfo") -> str: """Calculate the instance name from the ServiceInfo.""" # This is kind of funky because of the subtype based tests From 4086fb4304b0653153865306e46c865c90137922 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 15:35:49 -1000 Subject: [PATCH 0290/1433] Move the ServiceRegistry into its own module (#549) --- zeroconf/__init__.py | 3 +- zeroconf/core.py | 93 +------------- .../{services.py => services/__init__.py} | 16 +-- zeroconf/services/registry.py | 118 ++++++++++++++++++ 4 files changed, 130 insertions(+), 100 deletions(-) rename zeroconf/{services.py => services/__init__.py} (98%) create mode 100644 zeroconf/services/registry.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 0cfd50292..c4533bab2 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -76,7 +76,7 @@ _TYPE_TXT, _UNREGISTER_TIME, ) -from .core import NotifyListener, ServiceRegistry, Zeroconf # noqa # import needed for backwards compat +from .core import NotifyListener, Zeroconf # noqa # import needed for backwards compat from .dns import ( # noqa # import needed for backwards compat DNSAddress, DNSCache, @@ -110,6 +110,7 @@ ServiceInfo, ServiceStateChange, ) +from .services.registry import ServiceRegistry # noqa # import needed for backwards compat from .utils.name import service_type_name # noqa # import needed for backwards compat from .utils.net import ( # noqa # import needed for backwards compat add_multicast_member, diff --git a/zeroconf/core.py b/zeroconf/core.py index 8e5ab6114..2b53f4c64 100644 --- a/zeroconf/core.py +++ b/zeroconf/core.py @@ -52,9 +52,10 @@ _UNREGISTER_TIME, ) from .dns import DNSAddress, DNSCache, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord -from .exceptions import NonUniqueNameException, ServiceNameAlreadyRegistered +from .exceptions import NonUniqueNameException from .logger import QuietLogger, log from .services import RecordUpdateListener, ServiceBrowser, ServiceInfo, instance_name_from_service_info +from .services.registry import ServiceRegistry from .utils.name import service_type_name from .utils.net import ( IPVersion, @@ -226,96 +227,6 @@ def handle_read(self, socket_: socket.socket) -> None: self.zc.handle_response(msg) -class ServiceRegistry: - """A registry to keep track of services. - - This class exists to ensure services can - be safely added and removed with thread - safety. - """ - - def __init__( - self, - ) -> None: - """Create the ServiceRegistry class.""" - self.services = {} # type: Dict[str, ServiceInfo] - self.types = {} # type: Dict[str, List] - self.servers = {} # type: Dict[str, List] - self._lock = threading.Lock() # add and remove services thread safe - - def add(self, info: ServiceInfo) -> None: - """Add a new service to the registry.""" - - with self._lock: - self._add(info) - - def remove(self, info: ServiceInfo) -> None: - """Remove a new service from the registry.""" - - with self._lock: - self._remove(info) - - def update(self, info: ServiceInfo) -> None: - """Update new service in the registry.""" - - with self._lock: - self._remove(info) - self._add(info) - - def get_service_infos(self) -> List[ServiceInfo]: - """Return all ServiceInfo.""" - return list(self.services.values()) - - def get_info_name(self, name: str) -> Optional[ServiceInfo]: - """Return all ServiceInfo for the name.""" - return self.services.get(name) - - def get_types(self) -> List[str]: - """Return all types.""" - return list(self.types.keys()) - - def get_infos_type(self, type_: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching type.""" - return self._get_by_index("types", type_) - - def get_infos_server(self, server: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching server.""" - return self._get_by_index("servers", server) - - def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: - """Return all ServiceInfo matching the index.""" - service_infos = [] - - for name in getattr(self, attr).get(key, [])[:]: - info = self.services.get(name) - # Since we do not get under a lock since it would be - # a performance issue, its possible - # the service can be unregistered during the get - # so we must check if info is None - if info is not None: - service_infos.append(info) - - return service_infos - - def _add(self, info: ServiceInfo) -> None: - """Add a new service under the lock.""" - lower_name = info.name.lower() - if lower_name in self.services: - raise ServiceNameAlreadyRegistered - - self.services[lower_name] = info - self.types.setdefault(info.type, []).append(lower_name) - self.servers.setdefault(info.server, []).append(lower_name) - - def _remove(self, info: ServiceInfo) -> None: - """Remove a service under the lock.""" - lower_name = info.name.lower() - old_service_info = self.services[lower_name] - self.types[old_service_info.type].remove(lower_name) - self.servers[old_service_info.server].remove(lower_name) - del self.services[lower_name] - - class QueryHandler: """Query the ServiceRegistry.""" diff --git a/zeroconf/services.py b/zeroconf/services/__init__.py similarity index 98% rename from zeroconf/services.py rename to zeroconf/services/__init__.py index b0ad93d6d..f9c5c87b9 100644 --- a/zeroconf/services.py +++ b/zeroconf/services/__init__.py @@ -27,7 +27,7 @@ from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast -from .const import ( +from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, _CLASS_IN, @@ -46,20 +46,20 @@ _TYPE_SRV, _TYPE_TXT, ) -from .dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText -from .exceptions import BadTypeInNameException -from .utils.name import service_type_name -from .utils.net import ( +from ..dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from ..exceptions import BadTypeInNameException +from ..utils.name import service_type_name +from ..utils.net import ( IPVersion, _encode_address, _is_v6_address, ) -from .utils.struct import int2byte -from .utils.time import current_time_millis, millis_to_seconds +from ..utils.struct import int2byte +from ..utils.time import current_time_millis, millis_to_seconds if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from . import ( # pylint: disable=cyclic-import + from .. import ( # pylint: disable=cyclic-import ServiceListener, Zeroconf, ) diff --git a/zeroconf/services/registry.py b/zeroconf/services/registry.py new file mode 100644 index 000000000..19d4ba46f --- /dev/null +++ b/zeroconf/services/registry.py @@ -0,0 +1,118 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import threading +from typing import Dict, List, Optional + + +from ..exceptions import ServiceNameAlreadyRegistered +from ..services import ServiceInfo + + +class ServiceRegistry: + """A registry to keep track of services. + + This class exists to ensure services can + be safely added and removed with thread + safety. + """ + + def __init__( + self, + ) -> None: + """Create the ServiceRegistry class.""" + self.services = {} # type: Dict[str, ServiceInfo] + self.types = {} # type: Dict[str, List] + self.servers = {} # type: Dict[str, List] + self._lock = threading.Lock() # add and remove services thread safe + + def add(self, info: ServiceInfo) -> None: + """Add a new service to the registry.""" + + with self._lock: + self._add(info) + + def remove(self, info: ServiceInfo) -> None: + """Remove a new service from the registry.""" + + with self._lock: + self._remove(info) + + def update(self, info: ServiceInfo) -> None: + """Update new service in the registry.""" + + with self._lock: + self._remove(info) + self._add(info) + + def get_service_infos(self) -> List[ServiceInfo]: + """Return all ServiceInfo.""" + return list(self.services.values()) + + def get_info_name(self, name: str) -> Optional[ServiceInfo]: + """Return all ServiceInfo for the name.""" + return self.services.get(name) + + def get_types(self) -> List[str]: + """Return all types.""" + return list(self.types.keys()) + + def get_infos_type(self, type_: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching type.""" + return self._get_by_index("types", type_) + + def get_infos_server(self, server: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching server.""" + return self._get_by_index("servers", server) + + def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: + """Return all ServiceInfo matching the index.""" + service_infos = [] + + for name in getattr(self, attr).get(key, [])[:]: + info = self.services.get(name) + # Since we do not get under a lock since it would be + # a performance issue, its possible + # the service can be unregistered during the get + # so we must check if info is None + if info is not None: + service_infos.append(info) + + return service_infos + + def _add(self, info: ServiceInfo) -> None: + """Add a new service under the lock.""" + lower_name = info.name.lower() + if lower_name in self.services: + raise ServiceNameAlreadyRegistered + + self.services[lower_name] = info + self.types.setdefault(info.type, []).append(lower_name) + self.servers.setdefault(info.server, []).append(lower_name) + + def _remove(self, info: ServiceInfo) -> None: + """Remove a service under the lock.""" + lower_name = info.name.lower() + old_service_info = self.services[lower_name] + self.types[old_service_info.type].remove(lower_name) + self.servers[old_service_info.server].remove(lower_name) + del self.services[lower_name] From ffdc9887ede1f867c155743b344efc53e0ceee42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 15:46:21 -1000 Subject: [PATCH 0291/1433] Move ServiceListener to zeroconf.services (#550) --- zeroconf/__init__.py | 12 +----------- zeroconf/core.py | 22 ++++++++++++---------- zeroconf/services/__init__.py | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index c4533bab2..625f7f9d2 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -108,6 +108,7 @@ _ServiceBrowserBase, ServiceBrowser, ServiceInfo, + ServiceListener, ServiceStateChange, ) from .services.registry import ServiceRegistry # noqa # import needed for backwards compat @@ -159,17 +160,6 @@ # implementation classes -class ServiceListener: - def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - class ZeroconfServiceTypes(ServiceListener): """ Return all of the advertised services on any local networks diff --git a/zeroconf/core.py b/zeroconf/core.py index 2b53f4c64..fd1edc55b 100644 --- a/zeroconf/core.py +++ b/zeroconf/core.py @@ -27,7 +27,7 @@ import socket import threading from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, TYPE_CHECKING, Type, Union, cast +from typing import Dict, List, Optional, Type, Union, cast from .const import ( _CACHE_CLEANUP_INTERVAL, @@ -54,7 +54,13 @@ from .dns import DNSAddress, DNSCache, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from .exceptions import NonUniqueNameException from .logger import QuietLogger, log -from .services import RecordUpdateListener, ServiceBrowser, ServiceInfo, instance_name_from_service_info +from .services import ( + RecordUpdateListener, + ServiceBrowser, + ServiceInfo, + ServiceListener, + instance_name_from_service_info, +) from .services.registry import ServiceRegistry from .utils.name import service_type_name from .utils.net import ( @@ -67,10 +73,6 @@ ) from .utils.time import current_time_millis, millis_to_seconds -if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from . import ServiceListener # pylint: disable=cyclic-import - class NotifyListener: """Receive notifications Zeroconf.notify_all is called.""" @@ -481,8 +483,8 @@ def __init__( log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) self.multi_socket = unicast or interfaces is not InterfaceChoice.Default - self._notify_listeners = [] # type: List[NotifyListener] - self.browsers = {} # type: Dict[ServiceListener, ServiceBrowser] + self._notify_listeners: List[NotifyListener] = [] + self.browsers: Dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() self.query_handler = QueryHandler(self.registry) self.cache = DNSCache() @@ -540,14 +542,14 @@ def remove_notify_listener(self, listener: NotifyListener) -> None: """Removes a listener from the set that is currently listening.""" self._notify_listeners.remove(listener) - def add_service_listener(self, type_: str, listener: 'ServiceListener') -> None: + def add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when services of that type become available and unavailable.""" self.remove_service_listener(listener) self.browsers[listener] = ServiceBrowser(self, type_, listener) - def remove_service_listener(self, listener: 'ServiceListener') -> None: + def remove_service_listener(self, listener: ServiceListener) -> None: """Removes a listener from the set that is currently listening.""" if listener in self.browsers: self.browsers[listener].cancel() diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index f9c5c87b9..59526ad00 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -59,10 +59,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .. import ( # pylint: disable=cyclic-import - ServiceListener, - Zeroconf, - ) + from .. import Zeroconf # pylint: disable=cyclic-import @enum.unique @@ -82,6 +79,17 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: return info.name[: -len(service_name) - 1] +class ServiceListener: + def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + raise NotImplementedError() + + class Signal: def __init__(self) -> None: self._handlers = [] # type: List[Callable[..., None]] From 5b489e5b15ff89a0ffc000ccfeab2a8af346a65e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 15:58:58 -1000 Subject: [PATCH 0292/1433] Move QueryHandler and RecordManager handlers into zeroconf.handlers (#551) --- zeroconf/core.py | 219 +----------------------------------- zeroconf/handlers.py | 258 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 217 deletions(-) create mode 100644 zeroconf/handlers.py diff --git a/zeroconf/core.py b/zeroconf/core.py index fd1edc55b..f432c4112 100644 --- a/zeroconf/core.py +++ b/zeroconf/core.py @@ -21,7 +21,6 @@ """ import errno -import itertools import platform import select import socket @@ -33,7 +32,6 @@ _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, _CLASS_IN, - _DNS_OTHER_TTL, _DNS_PORT, _FLAGS_AA, _FLAGS_QR_QUERY, @@ -43,16 +41,12 @@ _MDNS_ADDR6, _MDNS_PORT, _REGISTER_TIME, - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_A, - _TYPE_ANY, _TYPE_PTR, - _TYPE_SRV, - _TYPE_TXT, _UNREGISTER_TIME, ) -from .dns import DNSAddress, DNSCache, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from .dns import DNSCache, DNSIncoming, DNSOutgoing, DNSQuestion from .exceptions import NonUniqueNameException +from .handlers import QueryHandler, RecordManager from .logger import QuietLogger, log from .services import ( RecordUpdateListener, @@ -229,215 +223,6 @@ def handle_read(self, socket_: socket.socket) -> None: self.zc.handle_response(msg) -class QueryHandler: - """Query the ServiceRegistry.""" - - def __init__(self, registry: ServiceRegistry): - """Init the query handler.""" - self.registry = registry - - def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: - """Provide an answer to a service type enumeration query. - - https://datatracker.ietf.org/doc/html/rfc6763#section-9 - """ - for stype in self.registry.get_types(): - out.add_answer( - msg, - DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - - def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name.lower()): - out.add_answer(msg, service.dns_pointer()) - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer(service.dns_service()) - out.add_additional_answer(service.dns_text()) - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a query any query other then PTR. - - Add answer(s) for A, AAAA, SRV, or TXT queries. - """ - name_to_find = question.name.lower() - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(name_to_find) # type: ignore - if service is None: - return - - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: - """Deal with incoming query packets. Provides a response if possible.""" - if unicast: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) - for question in msg.questions: - out.add_question(question) - else: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._answer_service_type_enumeration_query(msg, out) - else: - self._answer_ptr_query(msg, out, question) - continue - - self._answer_non_ptr_query(msg, out, question) - - if out is not None and out.answers: - out.id = msg.id - return out - - return None - - -class RecordManager: - """Process records into the cache and notify listeners.""" - - def __init__(self, zeroconf: 'Zeroconf') -> None: - """Init the record manager.""" - self.zc = zeroconf - self.cache = zeroconf.cache - self.listeners: List[RecordUpdateListener] = [] - - def updates(self, now: float, rec: List[DNSRecord]) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called before the cache is updated. - """ - for listener in self.listeners: - listener.update_records(self.zc, now, rec) - - def updates_complete(self) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called after the cache is updated. - """ - for listener in self.listeners: - listener.update_records_complete() - self.zc.notify_all() - - def updates_from_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - updates: List[DNSRecord] = [] - address_adds: List[DNSAddress] = [] - other_adds: List[DNSRecord] = [] - removes: List[DNSRecord] = [] - now = current_time_millis() - for record in msg.answers: - - updated = True - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # rfc6762#section-10.2 para 2 - # Since unique is set, all old records with that name, rrtype, - # and rrclass that were received more than one second ago are declared - # invalid, and marked to expire from the cache in one second. - for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): - if entry == record: - updated = False - if record.created - entry.created > 1000 and entry not in msg.answers: - removes.append(entry) - - expired = record.is_expired(now) - maybe_entry = self.cache.get(record) - if not expired: - if maybe_entry is not None: - maybe_entry.reset_ttl(record) - else: - if isinstance(record, DNSAddress): - address_adds.append(record) - else: - other_adds.append(record) - if updated: - updates.append(record) - elif maybe_entry is not None: - updates.append(record) - removes.append(record) - - if not updates and not address_adds and not other_adds and not removes: - return - - self.updates(now, updates) - # The cache adds must be processed AFTER we trigger - # the updates since we compare existing data - # with the new data and updating the cache - # ahead of update_record will cause listeners - # to miss changes - # - # We must process address adds before non-addresses - # otherwise a fetch of ServiceInfo may miss an address - # because it thinks the cache is complete - # - # The cache is processed under the context manager to ensure - # that any ServiceBrowser that is going to call - # zc.get_service_info will see the cached value - # but ONLY after all the record updates have been - # processsed. - self.cache.add_records(itertools.chain(address_adds, other_adds)) - # Removes are processed last since - # ServiceInfo could generate an un-needed query - # because the data was not yet populated. - self.cache.remove_records(removes) - self.updates_complete() - - def add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] - ) -> None: - """Adds a listener for a given question. The listener will have - its update_record method called when information is available to - answer the question(s).""" - self.listeners.append(listener) - - if question is not None: - now = current_time_millis() - records = [] - questions = [question] if isinstance(question, DNSQuestion) else question - for single_question in questions: - for record in self.cache.entries_with_name(single_question.name): - if single_question.answered_by(record) and not record.is_expired(now): - records.append(record) - if records: - listener.update_records(self.zc, now, records) - listener.update_records_complete() - - self.zc.notify_all() - - def remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener.""" - try: - self.listeners.remove(listener) - self.zc.notify_all() - except ValueError as e: - log.exception('Failed to remove listener: %r', e) - - class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery diff --git a/zeroconf/handlers.py b/zeroconf/handlers.py new file mode 100644 index 000000000..000bc9083 --- /dev/null +++ b/zeroconf/handlers.py @@ -0,0 +1,258 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import itertools +from typing import List, Optional, TYPE_CHECKING, Union + +from .const import ( + _CLASS_IN, + _DNS_OTHER_TTL, + _FLAGS_AA, + _FLAGS_QR_RESPONSE, + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_A, + _TYPE_ANY, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) +from .dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from .logger import log +from .services import ( + RecordUpdateListener, +) +from .services.registry import ServiceRegistry +from .utils.time import current_time_millis + + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from .core import Zeroconf # pylint: disable=cyclic-import + + +class QueryHandler: + """Query the ServiceRegistry.""" + + def __init__(self, registry: ServiceRegistry): + """Init the query handler.""" + self.registry = registry + + def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: + """Provide an answer to a service type enumeration query. + + https://datatracker.ietf.org/doc/html/rfc6763#section-9 + """ + for stype in self.registry.get_types(): + out.add_answer( + msg, + DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + ), + ) + + def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a PTR query.""" + for service in self.registry.get_infos_type(question.name.lower()): + out.add_answer(msg, service.dns_pointer()) + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + out.add_additional_answer(service.dns_service()) + out.add_additional_answer(service.dns_text()) + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: + """Answer a query any query other then PTR. + + Add answer(s) for A, AAAA, SRV, or TXT queries. + """ + name_to_find = question.name.lower() + # Answer A record queries for any service addresses we know + if question.type in (_TYPE_A, _TYPE_ANY): + for service in self.registry.get_infos_server(name_to_find): + for dns_address in service.dns_addresses(): + out.add_answer(msg, dns_address) + + service = self.registry.get_info_name(name_to_find) # type: ignore + if service is None: + return + + if question.type in (_TYPE_SRV, _TYPE_ANY): + out.add_answer(msg, service.dns_service()) + if question.type in (_TYPE_TXT, _TYPE_ANY): + out.add_answer(msg, service.dns_text()) + if question.type == _TYPE_SRV: + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) + + def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: + """Deal with incoming query packets. Provides a response if possible.""" + if unicast: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) + for question in msg.questions: + out.add_question(question) + else: + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + + for question in msg.questions: + if question.type == _TYPE_PTR: + if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + self._answer_service_type_enumeration_query(msg, out) + else: + self._answer_ptr_query(msg, out, question) + continue + + self._answer_non_ptr_query(msg, out, question) + + if out is not None and out.answers: + out.id = msg.id + return out + + return None + + +class RecordManager: + """Process records into the cache and notify listeners.""" + + def __init__(self, zeroconf: 'Zeroconf') -> None: + """Init the record manager.""" + self.zc = zeroconf + self.cache = zeroconf.cache + self.listeners: List[RecordUpdateListener] = [] + + def updates(self, now: float, rec: List[DNSRecord]) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called before the cache is updated. + """ + for listener in self.listeners: + listener.update_records(self.zc, now, rec) + + def updates_complete(self) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called after the cache is updated. + """ + for listener in self.listeners: + listener.update_records_complete() + self.zc.notify_all() + + def updates_from_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + updates: List[DNSRecord] = [] + address_adds: List[DNSAddress] = [] + other_adds: List[DNSRecord] = [] + removes: List[DNSRecord] = [] + now = current_time_millis() + for record in msg.answers: + + updated = True + + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): + if entry == record: + updated = False + if record.created - entry.created > 1000 and entry not in msg.answers: + removes.append(entry) + + expired = record.is_expired(now) + maybe_entry = self.cache.get(record) + if not expired: + if maybe_entry is not None: + maybe_entry.reset_ttl(record) + else: + if isinstance(record, DNSAddress): + address_adds.append(record) + else: + other_adds.append(record) + if updated: + updates.append(record) + elif maybe_entry is not None: + updates.append(record) + removes.append(record) + + if not updates and not address_adds and not other_adds and not removes: + return + + self.updates(now, updates) + # The cache adds must be processed AFTER we trigger + # the updates since we compare existing data + # with the new data and updating the cache + # ahead of update_record will cause listeners + # to miss changes + # + # We must process address adds before non-addresses + # otherwise a fetch of ServiceInfo may miss an address + # because it thinks the cache is complete + # + # The cache is processed under the context manager to ensure + # that any ServiceBrowser that is going to call + # zc.get_service_info will see the cached value + # but ONLY after all the record updates have been + # processsed. + self.cache.add_records(itertools.chain(address_adds, other_adds)) + # Removes are processed last since + # ServiceInfo could generate an un-needed query + # because the data was not yet populated. + self.cache.remove_records(removes) + self.updates_complete() + + def add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s).""" + self.listeners.append(listener) + + if question is not None: + now = current_time_millis() + records = [] + questions = [question] if isinstance(question, DNSQuestion) else question + for single_question in questions: + for record in self.cache.entries_with_name(single_question.name): + if single_question.answered_by(record) and not record.is_expired(now): + records.append(record) + if records: + listener.update_records(self.zc, now, records) + listener.update_records_complete() + + self.zc.notify_all() + + def remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener.""" + try: + self.listeners.remove(listener) + self.zc.notify_all() + except ValueError as e: + log.exception('Failed to remove listener: %r', e) From e7fb4e5fb2a6b2163b143a63e2a9e8c5d1eca482 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 16:08:56 -1000 Subject: [PATCH 0293/1433] Add recipe for TYPE_CHECKING to .coveragerc (#552) --- .coveragerc | 4 ++++ zeroconf/services/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..56ef8a32a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index 59526ad00..92a9b0dc5 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -59,7 +59,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .. import Zeroconf # pylint: disable=cyclic-import + from ..core import Zeroconf # pylint: disable=cyclic-import @enum.unique From e50b62bb633916d5b84df7bcf7a804c9e3ef7fc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 16:15:39 -1000 Subject: [PATCH 0294/1433] Move ZeroconfServiceTypes to zeroconf.services.types (#553) --- zeroconf/__init__.py | 60 +--------------------------- zeroconf/services/types.py | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 59 deletions(-) create mode 100644 zeroconf/services/types.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 625f7f9d2..8242c4e1e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -21,9 +21,6 @@ """ import sys -import time -from typing import Optional, Union -from typing import Set, Tuple # noqa # used in type hints from .const import ( # noqa # import needed for backwards compat _BROWSER_BACKOFF_LIMIT, @@ -112,6 +109,7 @@ ServiceStateChange, ) from .services.registry import ServiceRegistry # noqa # import needed for backwards compat +from .services.types import ZeroconfServiceTypes # noqa # import needed for backwards compat from .utils.name import service_type_name # noqa # import needed for backwards compat from .utils.net import ( # noqa # import needed for backwards compat add_multicast_member, @@ -155,59 +153,3 @@ If you need support for Python 3.5 please use version 0.28.0 ''' ) - - -# implementation classes - - -class ZeroconfServiceTypes(ServiceListener): - """ - Return all of the advertised services on any local networks - """ - - def __init__(self) -> None: - """Keep track of found services in a set.""" - self.found_services = set() # type: Set[str] - - def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - """Service added.""" - self.found_services.add(name) - - def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - """Service updated.""" - - def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: - """Service removed.""" - - @classmethod - def find( - cls, - zc: Optional['Zeroconf'] = None, - timeout: Union[int, float] = 5, - interfaces: InterfacesType = InterfaceChoice.All, - ip_version: Optional[IPVersion] = None, - ) -> Tuple[str, ...]: - """ - Return all of the advertised services on any local networks. - - :param zc: Zeroconf() instance. Pass in if already have an - instance running or if non-default interfaces are needed - :param timeout: seconds to wait for any responses - :param interfaces: interfaces to listen on. - :param ip_version: IP protocol version to use. - :return: tuple of service type strings - """ - local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) - listener = cls() - browser = ServiceBrowser(local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener) - - # wait for responses - time.sleep(timeout) - - browser.cancel() - - # close down anything we opened - if zc is None: - local_zc.close() - - return tuple(sorted(listener.found_services)) diff --git a/zeroconf/services/types.py b/zeroconf/services/types.py new file mode 100644 index 000000000..e27defff8 --- /dev/null +++ b/zeroconf/services/types.py @@ -0,0 +1,82 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import time +from typing import Optional, Set, Tuple, Union + +from ..const import _SERVICE_TYPE_ENUMERATION_NAME +from ..core import Zeroconf +from ..services import ServiceBrowser, ServiceListener +from ..utils.net import IPVersion, InterfaceChoice, InterfacesType + + +class ZeroconfServiceTypes(ServiceListener): + """ + Return all of the advertised services on any local networks + """ + + def __init__(self) -> None: + """Keep track of found services in a set.""" + self.found_services: Set[str] = set() + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + """Service added.""" + self.found_services.add(name) + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + """Service updated.""" + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + """Service removed.""" + + @classmethod + def find( + cls, + zc: Optional[Zeroconf] = None, + timeout: Union[int, float] = 5, + interfaces: InterfacesType = InterfaceChoice.All, + ip_version: Optional[IPVersion] = None, + ) -> Tuple[str, ...]: + """ + Return all of the advertised services on any local networks. + + :param zc: Zeroconf() instance. Pass in if already have an + instance running or if non-default interfaces are needed + :param timeout: seconds to wait for any responses + :param interfaces: interfaces to listen on. + :param ip_version: IP protocol version to use. + :return: tuple of service type strings + """ + local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) + listener = cls() + browser = ServiceBrowser(local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener) + + # wait for responses + time.sleep(timeout) + + browser.cancel() + + # close down anything we opened + if zc is None: + local_zc.close() + + return tuple(sorted(listener.found_services)) From 3dfda644efef83640e80876e4fe7da10e87b5990 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 16:36:05 -1000 Subject: [PATCH 0295/1433] Add missing coverage for ipv6 network utils (#555) --- tests/utils/test_net.py | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/utils/test_net.py diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py new file mode 100644 index 000000000..d4b829c20 --- /dev/null +++ b/tests/utils/test_net.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for zeroconf.utils.net.""" +from unittest.mock import Mock, patch + +import ifaddr +import pytest + +from zeroconf.utils import net as netutils + + +def _generate_mock_adapters(): + mock_lo0 = Mock(spec=ifaddr.Adapter) + mock_lo0.nice_name = "lo0" + mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] + mock_lo0.index = 0 + mock_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.index = 1 + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] + mock_eth1.index = 2 + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + mock_vtun0.index = 3 + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +def test_ip6_to_address_and_index(): + """Test we can extract from mocked adapters.""" + adapters = _generate_mock_adapters() + assert netutils.ip6_to_address_and_index(adapters, "2001:db8::") == (('2001:db8::', 1, 1), 1) + with pytest.raises(RuntimeError): + assert netutils.ip6_to_address_and_index(adapters, "2005:db8::") + + +def test_interface_index_to_ip6_address(): + """Test we can extract from mocked adapters.""" + adapters = _generate_mock_adapters() + assert netutils.interface_index_to_ip6_address(adapters, 1) == ('2001:db8::', 1, 1) + with pytest.raises(RuntimeError): + assert netutils.interface_index_to_ip6_address(adapters, 6) + + +def test_ip6_addresses_to_indexes(): + """Test we can extract from mocked adapters.""" + interfaces = [1] + with patch("zeroconf.utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): + assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] + + interfaces = ['2001:db8::'] + with patch("zeroconf.utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): + assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] From 3d69656c4e5fbd8f90d54826877a04120d5ec951 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 16:58:09 -1000 Subject: [PATCH 0296/1433] Fix invalid typing in ServiceInfo._set_text (#554) --- zeroconf/services/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index 92a9b0dc5..cd84971d0 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -586,10 +586,11 @@ def _set_text(self, text: bytes) -> None: strs.append(text[index : index + length]) index += length + key: bytes + value: Optional[bytes] for s in strs: - parts = s.split(b'=', 1) try: - key, value = parts # type: Tuple[bytes, Optional[bytes]] + key, value = s.split(b'=', 1) except ValueError: # No equals sign at all key = s From 715cd9a1d208139862e6d9d718114e1e472efd28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:00:11 -1000 Subject: [PATCH 0297/1433] Relocate some of the services tests to test_services (#556) --- tests/__init__.py | 8 + tests/conftest.py | 18 ++ tests/test_aio.py | 18 ++ tests/test_asyncio.py | 19 +- tests/test_core.py | 9 - tests/test_init.py | 502 +---------------------------------------- tests/test_services.py | 501 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 554 insertions(+), 521 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/__init__.py b/tests/__init__.py index 2ef4b15b1..f924adf27 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -19,3 +19,11 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from zeroconf.core import Zeroconf +from zeroconf.dns import DNSIncoming + + +def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: + """Inject a DNSIncoming response.""" + zc.handle_response(msg) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..c05c4b9b7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" conftest for zeroconf tests. """ + +import threading + +import pytest + + +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads = frozenset(threading.enumerate()) - threads_before + assert not threads diff --git a/tests/test_aio.py b/tests/test_aio.py index b50e5bc70..48a6ccc42 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -6,6 +6,7 @@ import asyncio import socket +import threading import unittest.mock import pytest @@ -23,6 +24,23 @@ from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads_after = frozenset(threading.enumerate()) + non_executor_threads = frozenset( + [ + thread + for thread in threads_after + if "asyncio" not in thread.name and "ThreadPoolExecutor" not in thread.name + ] + ) + threads = non_executor_threads - threads_before + assert not threads + + @pytest.mark.asyncio async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index ee8f80538..bf4d887ea 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -4,12 +4,29 @@ """Unit tests for asyncio.py.""" - import pytest +import threading from zeroconf.asyncio import AsyncZeroconf +@pytest.fixture(autouse=True) +def verify_threads_ended(): + """Verify that the threads are not running after the test.""" + threads_before = frozenset(threading.enumerate()) + yield + threads_after = frozenset(threading.enumerate()) + non_executor_threads = frozenset( + [ + thread + for thread in threads_after + if "asyncio" not in thread.name and "ThreadPoolExecutor" not in thread.name + ] + ) + threads = non_executor_threads - threads_before + assert not threads + + @pytest.mark.asyncio async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" diff --git a/tests/test_core.py b/tests/test_core.py index 5535ab59d..40c993b1c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,15 +20,6 @@ original_logging_level = logging.NOTSET -@pytest.fixture(autouse=True) -def verify_threads_ended(): - """Verify that the threads are not running after the test.""" - threads_before = frozenset(threading.enumerate()) - yield - threads = frozenset(threading.enumerate()) - threads_before - assert not threads - - def setup_module(): global original_logging_level original_logging_level = log.level diff --git a/tests/test_init.py b/tests/test_init.py index 99424a541..b69699ccf 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -26,29 +26,20 @@ import zeroconf as r from zeroconf import ( DNSHinfo, - DNSIncoming, DNSText, ServiceBrowser, ServiceInfo, - ServiceStateChange, Zeroconf, ZeroconfServiceTypes, _EXPIRE_REFRESH_TIME_PERCENT, ) +from . import _inject_response + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET -@pytest.fixture(autouse=True) -def verify_threads_ended(): - """Verify that the threads are not running after the test.""" - threads_before = frozenset(threading.enumerate()) - yield - threads = frozenset(threading.enumerate()) - threads_before - assert not threads - - def setup_module(): global original_logging_level original_logging_level = log.level @@ -60,11 +51,6 @@ def teardown_module(): log.setLevel(original_logging_level) -def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: - """Inject a DNSIncoming response.""" - zc.handle_response(msg) - - @lru_cache(maxsize=None) def has_working_ipv6(): """Return True if if the system can bind an IPv6 address.""" @@ -1703,490 +1689,6 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi zeroconf.close() -class TestServiceInfo(unittest.TestCase): - def test_get_name(self): - """Verify the name accessor can strip the type.""" - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - assert info.get_name() == "name" - - def test_service_info_rejects_non_matching_updates(self): - """Verify records with the wrong name are rejected.""" - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - ttl = 120 - now = r.current_time_millis() - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - # Verify backwards compatiblity with calling with None - info.update_record(zc, now, None) - # Matching updates - info.update_record( - zc, - now, - r.DNSText( - service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - info.update_record( - zc, - now, - r.DNSService( - service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), - ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' - new_address = socket.inet_aton("10.0.1.3") - info.update_record( - zc, - now, - r.DNSAddress( - 'ASH-2.local.', - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - new_address, - ), - ) - assert new_address in info.addresses - # Non-matching updates - info.update_record( - zc, - now, - r.DNSText( - "incorrect.name.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - info.update_record( - zc, - now, - r.DNSService( - "incorrect.name.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), - ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' - new_address = socket.inet_aton("10.0.1.4") - info.update_record( - zc, - now, - r.DNSAddress( - "incorrect.name.", - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - new_address, - ), - ) - assert new_address not in info.addresses - zc.close() - - def test_service_info_rejects_expired_records(self): - """Verify records that are expired are rejected.""" - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - ttl = 120 - now = r.current_time_millis() - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - # Matching updates - info.update_record( - zc, - now, - r.DNSText( - service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - # Expired record - expired_record = r.DNSText( - service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', - ) - expired_record.created = 1000 - expired_record._expiration_time = 1000 - info.update_record(zc, now, expired_record) - assert info.properties[b"ci"] == b"2" - zc.close() - - def test_get_info_partial(self): - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - - service_info = None - send_event = Event() - service_info_event = Event() - - last_sent = None # type: Optional[r.DNSOutgoing] - - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): - """Sends an outgoing packet.""" - nonlocal last_sent - - last_sent = out - send_event.set() - - # monkey patch the zeroconf send - setattr(zc, "send", send) - - def mock_incoming_msg(records) -> r.DNSIncoming: - - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packet()) - - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() - - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for SRV, A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text)] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 3 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSService( - service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 2 - assert r.DNSQuestion(service_server, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_server, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions - last_sent = None - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSAddress( - service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None - - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() - - def test_get_info_single(self): - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - - service_info = None - send_event = Event() - service_info_event = Event() - - last_sent = None # type: Optional[r.DNSOutgoing] - - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): - """Sends an outgoing packet.""" - nonlocal last_sent - - last_sent = out - send_event.set() - - # monkey patch the zeroconf send - setattr(zc, "send", send) - - def mock_incoming_msg(records) -> r.DNSIncoming: - - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packet()) - - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() - - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text - ), - r.DNSService( - service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - r.DNSAddress( - service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ), - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None - - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() - - -class TestServiceBrowserMultipleTypes(unittest.TestCase): - def test_update_record(self): - - service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] - service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] - - service_added_count = 0 - service_removed_count = 0 - service_add_event = Event() - service_removed_event = Event() - - class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: - nonlocal service_added_count - service_added_count += 1 - if service_added_count == 3: - service_add_event.set() - - def remove_service(self, zc, type_, name) -> None: - nonlocal service_removed_count - service_removed_count += 1 - if service_removed_count == 3: - service_removed_event.set() - - def mock_incoming_msg( - service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int - ) -> r.DNSIncoming: - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 - ) - return r.DNSIncoming(generated.packet()) - - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) - - try: - wait_time = 3 - - # all three services added - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), - ) - zeroconf.wait(100) - - called_with_refresh_time_check = False - - def _mock_get_expiration_time(self, percent): - nonlocal called_with_refresh_time_check - if percent == _EXPIRE_REFRESH_TIME_PERCENT: - called_with_refresh_time_check = True - return 0 - return self.created + (percent * self.ttl * 10) - - # Set an expire time that will force a refresh - with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), - ) - # Add the last record after updating the first one - # to ensure the service_add_event only gets set - # after the update - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), - ) - service_add_event.wait(wait_time) - assert called_with_refresh_time_check is True - assert service_added_count == 3 - assert service_removed_count == 0 - - # all three services removed - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), - ) - service_removed_event.wait(wait_time) - assert service_added_count == 3 - assert service_removed_count == 3 - - finally: - assert len(zeroconf.listeners) == 1 - service_browser.cancel() - assert len(zeroconf.listeners) == 0 - zeroconf.remove_all_service_listeners() - zeroconf.close() - - def test_multiple_addresses(): type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ diff --git a/tests/test_services.py b/tests/test_services.py index d931d5c02..7cf476b42 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -8,30 +8,25 @@ import socket import threading import time +import unittest from threading import Event import pytest import zeroconf as r import zeroconf.services as s -from zeroconf import ( +from zeroconf.core import Zeroconf +from zeroconf.services import ( ServiceBrowser, ServiceInfo, ServiceStateChange, - Zeroconf, ) -log = logging.getLogger('zeroconf') -original_logging_level = logging.NOTSET +from . import _inject_response -@pytest.fixture(autouse=True) -def verify_threads_ended(): - """Verify that the threads are not running after the test.""" - threads_before = frozenset(threading.enumerate()) - yield - threads = frozenset(threading.enumerate()) - threads_before - assert not threads +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET def setup_module(): @@ -45,6 +40,490 @@ def teardown_module(): log.setLevel(original_logging_level) +class TestServiceInfo(unittest.TestCase): + def test_get_name(self): + """Verify the name accessor can strip the type.""" + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + assert info.get_name() == "name" + + def test_service_info_rejects_non_matching_updates(self): + """Verify records with the wrong name are rejected.""" + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Verify backwards compatiblity with calling with None + info.update_record(zc, now, None) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.3") + info.update_record( + zc, + now, + r.DNSAddress( + 'ASH-2.local.', + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address in info.addresses + # Non-matching updates + info.update_record( + zc, + now, + r.DNSText( + "incorrect.name.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + "incorrect.name.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.4") + info.update_record( + zc, + now, + r.DNSAddress( + "incorrect.name.", + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address not in info.addresses + zc.close() + + def test_service_info_rejects_expired_records(self): + """Verify records that are expired are rejected.""" + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + # Expired record + expired_record = r.DNSText( + service_name, + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ) + expired_record.created = 1000 + expired_record._expiration_time = 1000 + info.update_record(zc, now, expired_record) + assert info.properties[b"ci"] == b"2" + zc.close() + + def test_get_info_partial(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # monkey patch the zeroconf send + setattr(zc, "send", send) + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packet()) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for SRV, A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text)] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 3 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 2 + assert r.DNSQuestion(service_server, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + last_sent = None + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + def test_get_info_single(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # monkey patch the zeroconf send + setattr(zc, "send", send) + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packet()) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSText( + service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text + ), + r.DNSService( + service_name, + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ), + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + +class TestServiceBrowserMultipleTypes(unittest.TestCase): + def test_update_record(self): + + service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] + service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] + + service_added_count = 0 + service_removed_count = 0 + service_add_event = Event() + service_removed_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added_count + service_added_count += 1 + if service_added_count == 3: + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_removed_count + service_removed_count += 1 + if service_removed_count == 3: + service_removed_event.set() + + def mock_incoming_msg( + service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int + ) -> r.DNSIncoming: + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + ) + return r.DNSIncoming(generated.packet()) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) + + try: + wait_time = 3 + + # all three services added + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), + ) + zeroconf.wait(100) + + called_with_refresh_time_check = False + + def _mock_get_expiration_time(self, percent): + nonlocal called_with_refresh_time_check + if percent == r._EXPIRE_REFRESH_TIME_PERCENT: + called_with_refresh_time_check = True + return 0 + return self.created + (percent * self.ttl * 10) + + # Set an expire time that will force a refresh + with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + ) + # Add the last record after updating the first one + # to ensure the service_add_event only gets set + # after the update + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), + ) + service_add_event.wait(wait_time) + assert called_with_refresh_time_check is True + assert service_added_count == 3 + assert service_removed_count == 0 + + # all three services removed + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), + ) + service_removed_event.wait(wait_time) + assert service_added_count == 3 + assert service_removed_count == 3 + + finally: + assert len(zeroconf.listeners) == 1 + service_browser.cancel() + assert len(zeroconf.listeners) == 0 + zeroconf.remove_all_service_listeners() + zeroconf.close() + + def test_backoff(): got_query = Event() From f0d99e2e68791376a8517254338c708a3244f178 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:13:01 -1000 Subject: [PATCH 0298/1433] Relocate dns tests to test_dns (#557) --- tests/test_dns.py | 416 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 387 ----------------------------------------- 2 files changed, 416 insertions(+), 387 deletions(-) create mode 100644 tests/test_dns.py diff --git a/tests/test_dns.py b/tests/test_dns.py new file mode 100644 index 000000000..10ab36b2f --- /dev/null +++ b/tests/test_dns.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf.py """ + +import copy +import logging +import socket +import struct +import time +import unittest +import unittest.mock +from typing import Dict, cast # noqa # used in type hints + +import zeroconf as r +from zeroconf import ( + DNSHinfo, + DNSText, + ServiceInfo, +) + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestDunder(unittest.TestCase): + def test_dns_text_repr(self): + # There was an issue on Python 3 that prevented DNSText's repr + # from working when the text was longer than 10 bytes + text = DNSText('irrelevant', 0, 0, 0, b'12345678901') + repr(text) + + text = DNSText('irrelevant', 0, 0, 0, b'123') + repr(text) + + def test_dns_hinfo_repr_eq(self): + hinfo = DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os') + assert hinfo == hinfo + repr(hinfo) + + def test_dns_pointer_repr(self): + pointer = r.DNSPointer('irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') + repr(pointer) + + def test_dns_address_repr(self): + address = r.DNSAddress('irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + assert repr(address).endswith("b'a'") + + address_ipv4 = r.DNSAddress( + 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') + ) + assert repr(address_ipv4).endswith('127.0.0.1') + + address_ipv6 = r.DNSAddress( + 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') + ) + assert repr(address_ipv6).endswith('::1') + + def test_dns_question_repr(self): + question = r.DNSQuestion('irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) + repr(question) + assert not question != question + + def test_dns_service_repr(self): + service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, 'a') + repr(service) + + def test_dns_record_abc(self): + record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + self.assertRaises(r.AbstractMethodException, record.__eq__, record) + self.assertRaises(r.AbstractMethodException, record.write, None) + + def test_dns_record_reset_ttl(self): + record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + time.sleep(1) + record2 = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + now = r.current_time_millis() + + assert record.created != record2.created + assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) + + record.reset_ttl(record2) + + assert record.ttl == record2.ttl + assert record.created == record2.created + assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) + + def test_service_info_dunder(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + b'', + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + assert not info != info + repr(info) + + def test_service_info_text_properties_not_given(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + info = ServiceInfo( + type_=type_, + name=registration_name, + addresses=[socket.inet_aton("10.0.1.2")], + port=80, + server="ash-2.local.", + ) + + assert isinstance(info.text, bytes) + repr(info) + + def test_dns_outgoing_repr(self): + dns_outgoing = r.DNSOutgoing(r._FLAGS_QR_QUERY) + repr(dns_outgoing) + + +class PacketGeneration(unittest.TestCase): + def test_parse_own_packet_simple(self): + generated = r.DNSOutgoing(0) + r.DNSIncoming(generated.packet()) + + def test_parse_own_packet_simple_unicast(self): + generated = r.DNSOutgoing(0, False) + r.DNSIncoming(generated.packet()) + + def test_parse_own_packet_flags(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + r.DNSIncoming(generated.packet()) + + def test_parse_own_packet_question(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated.add_question(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) + r.DNSIncoming(generated.packet()) + + def test_parse_own_packet_response(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + 0, + ) + parsed = r.DNSIncoming(generated.packet()) + assert len(generated.answers) == 1 + assert len(generated.answers) == len(parsed.answers) + + def test_match_question(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + parsed = r.DNSIncoming(generated.packet()) + assert len(generated.questions) == 1 + assert len(generated.questions) == len(parsed.questions) + assert question == parsed.questions[0] + + def test_suppress_answer(self): + query_generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + query_generated.add_question(question) + answer1 = r.DNSService( + "testname1.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ) + staleanswer2 = r.DNSService( + "testname2.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL / 2, + 0, + 0, + 80, + "foo.local.", + ) + answer2 = r.DNSService( + "testname2.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ) + query_generated.add_answer_at_time(answer1, 0) + query_generated.add_answer_at_time(staleanswer2, 0) + query = r.DNSIncoming(query_generated.packet()) + + # Should be suppressed + response = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + response.add_answer(query, answer1) + assert len(response.answers) == 0 + + # Should not be suppressed, TTL in query is too short + response.add_answer(query, answer2) + assert len(response.answers) == 1 + + # Should not be suppressed, name is different + tmp = copy.copy(answer1) + tmp.key = "testname3.local." + tmp.name = "testname3.local." + response.add_answer(query, tmp) + assert len(response.answers) == 2 + + # Should not be suppressed, type is different + tmp = copy.copy(answer1) + tmp.type = r._TYPE_A + response.add_answer(query, tmp) + assert len(response.answers) == 3 + + # Should not be suppressed, class is different + tmp = copy.copy(answer1) + tmp.class_ = r._CLASS_NONE + response.add_answer(query, tmp) + assert len(response.answers) == 4 + + # ::TODO:: could add additional tests for DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService + + def test_dns_hinfo(self): + generated = r.DNSOutgoing(0) + generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) + parsed = r.DNSIncoming(generated.packet()) + answer = cast(r.DNSHinfo, parsed.answers[0]) + assert answer.cpu == u'cpu' + assert answer.os == u'os' + + generated = r.DNSOutgoing(0) + generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) + self.assertRaises(r.NamePartTooLongException, generated.packet) + + def test_many_questions(self): + """Test many questions get seperated into multiple packets.""" + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + questions = [] + for i in range(100): + question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 100 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) < r._MAX_MSG_TYPICAL + assert len(packets[1]) < r._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 85 + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 15 + + def test_only_one_answer_can_by_large(self): + """Test that only the first answer in each packet can be large. + + https://datatracker.ietf.org/doc/html/rfc6762#section-17 + """ + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + query = r.DNSIncoming(r.DNSOutgoing(r._FLAGS_QR_QUERY).packet()) + for i in range(3): + generated.add_answer( + query, + r.DNSText( + "zoom._hap._tcp.local.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + 1200, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, + ), + ) + generated.add_answer( + query, + r.DNSService( + "testname1.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + ) + assert len(generated.answers) == 4 + + packets = generated.packets() + assert len(packets) == 4 + assert len(packets[0]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[0]) > r._MAX_MSG_TYPICAL + + assert len(packets[1]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[1]) > r._MAX_MSG_TYPICAL + + assert len(packets[2]) <= r._MAX_MSG_ABSOLUTE + assert len(packets[2]) > r._MAX_MSG_TYPICAL + + assert len(packets[3]) <= r._MAX_MSG_TYPICAL + + for packet in packets: + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + def test_questions_do_not_end_up_every_packet(self): + """Test that questions are not sent again when multiple packets are needed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + Sometimes a Multicast DNS querier will already have too many answers + to fit in the Known-Answer Section of its query packets.... It MUST + immediately follow the packet with another query packet containing no + questions and as many more Known-Answer records as will fit. + """ + + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + for i in range(35): + question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + generated.add_question(question) + answer = r.DNSService( + f"testname{i}.local.", + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + 80, + f"foo{i}.local.", + ) + generated.add_answer_at_time(answer, 0) + + assert len(generated.questions) == 35 + assert len(generated.answers) == 35 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) <= r._MAX_MSG_TYPICAL + assert len(packets[1]) <= r._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 35 + assert len(parsed1.answers) == 33 + + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert len(parsed2.answers) == 2 + + +class PacketForm(unittest.TestCase): + def test_transaction_id(self): + """ID must be zero in a DNS-SD packet""" + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + bytes = generated.packet() + id = bytes[0] << 8 | bytes[1] + assert id == 0 + + def test_query_header_bits(self): + generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + bytes = generated.packet() + flags = bytes[2] << 8 | bytes[3] + assert flags == 0x0 + + def test_response_header_bits(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + bytes = generated.packet() + flags = bytes[2] << 8 | bytes[3] + assert flags == 0x8000 + + def test_numbers(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + bytes = generated.packet() + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + assert num_questions == 0 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 + + def test_numbers_questions(self): + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + for i in range(10): + generated.add_question(question) + bytes = generated.packet() + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + assert num_questions == 10 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 diff --git a/tests/test_init.py b/tests/test_init.py index b69699ccf..6d8aca8da 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,14 +4,10 @@ """ Unit tests for zeroconf.py """ -import copy import errno -import itertools import logging import os import socket -import struct -import threading import time import unittest import unittest.mock @@ -25,13 +21,11 @@ import zeroconf as r from zeroconf import ( - DNSHinfo, DNSText, ServiceBrowser, ServiceInfo, Zeroconf, ZeroconfServiceTypes, - _EXPIRE_REFRESH_TIME_PERCENT, ) from . import _inject_response @@ -79,387 +73,6 @@ def _clear_cache(zc): zc.cache.remove(record) -class TestDunder(unittest.TestCase): - def test_dns_text_repr(self): - # There was an issue on Python 3 that prevented DNSText's repr - # from working when the text was longer than 10 bytes - text = DNSText('irrelevant', 0, 0, 0, b'12345678901') - repr(text) - - text = DNSText('irrelevant', 0, 0, 0, b'123') - repr(text) - - def test_dns_hinfo_repr_eq(self): - hinfo = DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os') - assert hinfo == hinfo - repr(hinfo) - - def test_dns_pointer_repr(self): - pointer = r.DNSPointer('irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') - repr(pointer) - - def test_dns_address_repr(self): - address = r.DNSAddress('irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - assert repr(address).endswith("b'a'") - - address_ipv4 = r.DNSAddress( - 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') - ) - assert repr(address_ipv4).endswith('127.0.0.1') - - address_ipv6 = r.DNSAddress( - 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') - ) - assert repr(address_ipv6).endswith('::1') - - def test_dns_question_repr(self): - question = r.DNSQuestion('irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) - repr(question) - assert not question != question - - def test_dns_service_repr(self): - service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, 'a') - repr(service) - - def test_dns_record_abc(self): - record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) - self.assertRaises(r.AbstractMethodException, record.__eq__, record) - self.assertRaises(r.AbstractMethodException, record.write, None) - - def test_dns_record_reset_ttl(self): - record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) - time.sleep(1) - record2 = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) - now = r.current_time_millis() - - assert record.created != record2.created - assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) - - record.reset_ttl(record2) - - assert record.ttl == record2.ttl - assert record.created == record2.created - assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) - - def test_service_info_dunder(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - b'', - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - - assert not info != info - repr(info) - - def test_service_info_text_properties_not_given(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - info = ServiceInfo( - type_=type_, - name=registration_name, - addresses=[socket.inet_aton("10.0.1.2")], - port=80, - server="ash-2.local.", - ) - - assert isinstance(info.text, bytes) - repr(info) - - def test_dns_outgoing_repr(self): - dns_outgoing = r.DNSOutgoing(r._FLAGS_QR_QUERY) - repr(dns_outgoing) - - -class PacketGeneration(unittest.TestCase): - def test_parse_own_packet_simple(self): - generated = r.DNSOutgoing(0) - r.DNSIncoming(generated.packet()) - - def test_parse_own_packet_simple_unicast(self): - generated = r.DNSOutgoing(0, False) - r.DNSIncoming(generated.packet()) - - def test_parse_own_packet_flags(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - r.DNSIncoming(generated.packet()) - - def test_parse_own_packet_question(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - generated.add_question(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) - r.DNSIncoming(generated.packet()) - - def test_parse_own_packet_response(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSService( - "æøå.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - 0, - ) - parsed = r.DNSIncoming(generated.packet()) - assert len(generated.answers) == 1 - assert len(generated.answers) == len(parsed.answers) - - def test_match_question(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - generated.add_question(question) - parsed = r.DNSIncoming(generated.packet()) - assert len(generated.questions) == 1 - assert len(generated.questions) == len(parsed.questions) - assert question == parsed.questions[0] - - def test_suppress_answer(self): - query_generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - query_generated.add_question(question) - answer1 = r.DNSService( - "testname1.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ) - staleanswer2 = r.DNSService( - "testname2.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL / 2, - 0, - 0, - 80, - "foo.local.", - ) - answer2 = r.DNSService( - "testname2.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ) - query_generated.add_answer_at_time(answer1, 0) - query_generated.add_answer_at_time(staleanswer2, 0) - query = r.DNSIncoming(query_generated.packet()) - - # Should be suppressed - response = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - response.add_answer(query, answer1) - assert len(response.answers) == 0 - - # Should not be suppressed, TTL in query is too short - response.add_answer(query, answer2) - assert len(response.answers) == 1 - - # Should not be suppressed, name is different - tmp = copy.copy(answer1) - tmp.key = "testname3.local." - tmp.name = "testname3.local." - response.add_answer(query, tmp) - assert len(response.answers) == 2 - - # Should not be suppressed, type is different - tmp = copy.copy(answer1) - tmp.type = r._TYPE_A - response.add_answer(query, tmp) - assert len(response.answers) == 3 - - # Should not be suppressed, class is different - tmp = copy.copy(answer1) - tmp.class_ = r._CLASS_NONE - response.add_answer(query, tmp) - assert len(response.answers) == 4 - - # ::TODO:: could add additional tests for DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService - - def test_dns_hinfo(self): - generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) - parsed = r.DNSIncoming(generated.packet()) - answer = cast(r.DNSHinfo, parsed.answers[0]) - assert answer.cpu == u'cpu' - assert answer.os == u'os' - - generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) - self.assertRaises(r.NamePartTooLongException, generated.packet) - - def test_many_questions(self): - """Test many questions get seperated into multiple packets.""" - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - questions = [] - for i in range(100): - question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) - generated.add_question(question) - questions.append(question) - assert len(generated.questions) == 100 - - packets = generated.packets() - assert len(packets) == 2 - assert len(packets[0]) < r._MAX_MSG_TYPICAL - assert len(packets[1]) < r._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert len(parsed1.questions) == 85 - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 15 - - def test_only_one_answer_can_by_large(self): - """Test that only the first answer in each packet can be large. - - https://datatracker.ietf.org/doc/html/rfc6762#section-17 - """ - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - query = r.DNSIncoming(r.DNSOutgoing(r._FLAGS_QR_QUERY).packet()) - for i in range(3): - generated.add_answer( - query, - r.DNSText( - "zoom._hap._tcp.local.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - 1200, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, - ), - ) - generated.add_answer( - query, - r.DNSService( - "testname1.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - ) - assert len(generated.answers) == 4 - - packets = generated.packets() - assert len(packets) == 4 - assert len(packets[0]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[0]) > r._MAX_MSG_TYPICAL - - assert len(packets[1]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[1]) > r._MAX_MSG_TYPICAL - - assert len(packets[2]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[2]) > r._MAX_MSG_TYPICAL - - assert len(packets[3]) <= r._MAX_MSG_TYPICAL - - for packet in packets: - parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 - - def test_questions_do_not_end_up_every_packet(self): - """Test that questions are not sent again when multiple packets are needed. - - https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - Sometimes a Multicast DNS querier will already have too many answers - to fit in the Known-Answer Section of its query packets.... It MUST - immediately follow the packet with another query packet containing no - questions and as many more Known-Answer records as will fit. - """ - - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - for i in range(35): - question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) - generated.add_question(question) - answer = r.DNSService( - f"testname{i}.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - 80, - f"foo{i}.local.", - ) - generated.add_answer_at_time(answer, 0) - - assert len(generated.questions) == 35 - assert len(generated.answers) == 35 - - packets = generated.packets() - assert len(packets) == 2 - assert len(packets[0]) <= r._MAX_MSG_TYPICAL - assert len(packets[1]) <= r._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert len(parsed1.questions) == 35 - assert len(parsed1.answers) == 33 - - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 0 - assert len(parsed2.answers) == 2 - - -class PacketForm(unittest.TestCase): - def test_transaction_id(self): - """ID must be zero in a DNS-SD packet""" - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - bytes = generated.packet() - id = bytes[0] << 8 | bytes[1] - assert id == 0 - - def test_query_header_bits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - bytes = generated.packet() - flags = bytes[2] << 8 | bytes[3] - assert flags == 0x0 - - def test_response_header_bits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - bytes = generated.packet() - flags = bytes[2] << 8 | bytes[3] - assert flags == 0x8000 - - def test_numbers(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - bytes = generated.packet() - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - assert num_questions == 0 - assert num_answers == 0 - assert num_authorities == 0 - assert num_additionals == 0 - - def test_numbers_questions(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) - for i in range(10): - generated.add_question(question) - bytes = generated.packet() - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - assert num_questions == 10 - assert num_answers == 0 - assert num_authorities == 0 - assert num_additionals == 0 - - class Names(unittest.TestCase): def test_long_name(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) From 18b9d0a8bd07c0a0d2923763a5f131905c31e0df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:16:37 -1000 Subject: [PATCH 0299/1433] Relocate additional dns tests to test_dns (#558) --- tests/test_dns.py | 220 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 220 --------------------------------------------- 2 files changed, 220 insertions(+), 220 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 10ab36b2f..8117339d7 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -414,3 +414,223 @@ def test_numbers_questions(self): assert num_answers == 0 assert num_authorities == 0 assert num_additionals == 0 + + +def test_dns_compression_rollback_for_corruption(): + """Verify rolling back does not lead to dns compression corruption.""" + out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + r.DNSService( + record["name"], # type: ignore + r._TYPE_SRV, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSText( + record["name"], # type: ignore + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSAddress( + record["name"], # type: ignore + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + for packet in out.packets(): + # Verify we can process the packets we created to + # ensure there is no corruption with the dns compression + incoming = r.DNSIncoming(packet) + assert incoming.valid is True + + +def test_tc_bit_in_query_packet(): + """Verify the TC bit is set when known answers exceed the packet size.""" + out = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + type_ = "_hap._tcp.local." + out.add_question(r.DNSQuestion(type_, r._TYPE_PTR, r._CLASS_IN)) + + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert first_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert second_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.valid is True + + +def test_tc_bit_not_set_in_answer_packet(): + """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" + out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + r._TYPE_TXT, + r._CLASS_IN | r._CLASS_UNIQUE, + r._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert first_packet.flags & r._FLAGS_TC == 0 + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert second_packet.flags & r._FLAGS_TC == 0 + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.valid is True diff --git a/tests/test_init.py b/tests/test_init.py index 6d8aca8da..159fd0321 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1406,226 +1406,6 @@ def test_ptr_optimization(): zc.close() -def test_dns_compression_rollback_for_corruption(): - """Verify rolling back does not lead to dns compression corruption.""" - out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) - address = socket.inet_pton(socket.AF_INET, "192.168.208.5") - - additionals = [ - { - "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", - "address": address, - "port": 51832, - "text": b"\x13md=HASS Bridge" - b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=L0m/aQ==", - }, - { - "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", - "address": address, - "port": 51834, - "text": b"\x13md=HASS Bridge" - b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b2CnzQ==", - }, - { - "name": "Master Bed TV CEDB27._hap._tcp.local.", - "address": address, - "port": 51830, - "text": b"\x10md=Master Bed" - b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=CVj1kw==", - }, - { - "name": "Living Room TV 921B77._hap._tcp.local.", - "address": address, - "port": 51833, - "text": b"\x11md=Living Room" - b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=qU77SQ==", - }, - { - "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", - "address": address, - "port": 51829, - "text": b"\x13md=HASS Bridge" - b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b0QZlg==", - }, - { - "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", - "address": address, - "port": 51837, - "text": b"\x13md=HASS Bridge" - b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=ahAISA==", - }, - { - "name": "FrontdoorCamera 8941D1._hap._tcp.local.", - "address": address, - "port": 54898, - "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" - b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", - }, - { - "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - "address": address, - "port": 51836, - "text": b"\x13md=HASS Bridge" - b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=6fLM5A==", - }, - { - "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", - "address": address, - "port": 51838, - "text": b"\x13md=HASS Bridge" - b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=u3bdfw==", - }, - { - "name": "Snooze Room TV 6B89B0._hap._tcp.local.", - "address": address, - "port": 51835, - "text": b"\x11md=Snooze Room" - b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=xNTqsg==", - }, - { - "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", - "address": address, - "port": 54811, - "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" - b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", - }, - { - "name": "HASS Bridge OS95 39C053._hap._tcp.local.", - "address": address, - "port": 51831, - "text": b"\x13md=HASS Bridge" - b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" - b"\x04sf=0\x0bsh=Xfe5LQ==", - }, - ] - - out.add_answer_at_time( - DNSText( - "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - for record in additionals: - out.add_additional_answer( - r.DNSService( - record["name"], # type: ignore - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - 0, - 0, - record["port"], # type: ignore - record["name"], # type: ignore - ) - ) - out.add_additional_answer( - r.DNSText( - record["name"], # type: ignore - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, - record["text"], # type: ignore - ) - ) - out.add_additional_answer( - r.DNSAddress( - record["name"], # type: ignore - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, - record["address"], # type: ignore - ) - ) - - for packet in out.packets(): - # Verify we can process the packets we created to - # ensure there is no corruption with the dns compression - incoming = r.DNSIncoming(packet) - assert incoming.valid is True - - -def test_tc_bit_in_query_packet(): - """Verify the TC bit is set when known answers exceed the packet size.""" - out = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) - type_ = "_hap._tcp.local." - out.add_question(r.DNSQuestion(type_, r._TYPE_PTR, r._CLASS_IN)) - - for i in range(30): - out.add_answer_at_time( - DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - packets = out.packets() - assert len(packets) == 3 - - first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & r._FLAGS_TC == r._FLAGS_TC - assert first_packet.valid is True - - second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & r._FLAGS_TC == r._FLAGS_TC - assert second_packet.valid is True - - third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & r._FLAGS_TC == 0 - assert third_packet.valid is True - - -def test_tc_bit_not_set_in_answer_packet(): - """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" - out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) - for i in range(30): - out.add_answer_at_time( - DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - packets = out.packets() - assert len(packets) == 3 - - first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & r._FLAGS_TC == 0 - assert first_packet.valid is True - - second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & r._FLAGS_TC == 0 - assert second_packet.valid is True - - third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & r._FLAGS_TC == 0 - assert third_packet.valid is True - - @pytest.mark.parametrize( "errno,expected_result", [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], From eb37f089579fdc5a405dbc2f0ce5620cf9d1b011 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:25:45 -1000 Subject: [PATCH 0300/1433] Move additional tests to test_core (#559) --- tests/__init__.py | 35 +++++++++ tests/test_core.py | 163 +++++++++++++++++++++++++++++++++++++- tests/test_init.py | 191 +-------------------------------------------- 3 files changed, 197 insertions(+), 192 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index f924adf27..6399dbefe 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,6 +20,13 @@ USA """ +import socket +from functools import lru_cache + + +import ifaddr + + from zeroconf.core import Zeroconf from zeroconf.dns import DNSIncoming @@ -27,3 +34,31 @@ def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: """Inject a DNSIncoming response.""" zc.handle_response(msg) + + +@lru_cache(maxsize=None) +def has_working_ipv6(): + """Return True if if the system can bind an IPv6 address.""" + if not socket.has_ipv6: + return False + + try: + sock = socket.socket(socket.AF_INET6) + sock.bind(('::1', 0)) + except Exception: + return False + finally: + if sock: + sock.close() + + for iface in ifaddr.get_adapters(): + for addr in iface.ips: + if addr.is_IPv6 and iface.index is not None: + return True + return False + + +def _clear_cache(zc): + for name in zc.cache.names(): + for record in zc.cache.entries_with_name(name): + zc.cache.remove(record) diff --git a/tests/test_core.py b/tests/test_core.py index 40c993b1c..0d63a58c7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,16 +6,18 @@ import itertools import logging -import threading +import os +import socket import time import unittest import unittest.mock +from typing import cast - -import pytest import zeroconf as r from zeroconf import core +from . import has_working_ipv6, _inject_response + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -52,3 +54,158 @@ def test_reaper(self): assert entries_with_cache != original_entries assert record_with_10s_ttl in entries assert record_with_1s_ttl not in entries + + +class Framework(unittest.TestCase): + def test_launch_and_close(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) + rv.close() + + def test_launch_and_close_context_manager(self): + with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: + assert rv.done is False + assert rv.done is True + + with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: + assert rv.done is False + assert rv.done is True + + def test_launch_and_close_unicast(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, unicast=True) + rv.close() + + def test_close_multiple_times(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) + rv.close() + rv.close() + + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + def test_launch_and_close_v4_v6(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) + rv.close() + + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + def test_launch_and_close_v6_only(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) + rv.close() + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) + rv.close() + + def test_handle_response(self): + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + ttl = 120 + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + + if service_state_change == r.ServiceStateChange.Updated: + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + return r.DNSIncoming(generated.packet()) + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + + generated.add_answer_at_time( + r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + ) + generated.add_answer_at_time( + r.DNSService( + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + + return r.DNSIncoming(generated.packet()) + + def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + """Mock an incoming message for the case where the packet is split.""" + ttl = 120 + generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + r._TYPE_A, + r._CLASS_IN | r._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + ), + 0, + ) + return r.DNSIncoming(generated.packet()) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-2.local.' + service_text = b'path=/~paulsm/' + service_address = '10.0.1.2' + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + + try: + # service added + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' + all_dns_text = zeroconf.cache.get_all_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert [dns_text] == all_dns_text + + # https://tools.ietf.org/html/rfc6762#section-10.2 + # Instead of merging this new record additively into the cache in addition + # to any previous records with the same name, rrtype, and rrclass, + # all old records with that name, rrtype, and rrclass that were received + # more than one second ago are declared invalid, + # and marked to expire from the cache in one second. + time.sleep(1.1) + + # service updated. currently only text record can be updated + service_text = b'path=/~humingchun/' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' + + time.sleep(1.1) + + # The split message only has a SRV and A record. + # This should not evict TXT records from the cache + _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) + time.sleep(1.1) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is not None + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' + + # service removed + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) + dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + assert dns_text is None + + finally: + zeroconf.close() diff --git a/tests/test_init.py b/tests/test_init.py index 159fd0321..7ec3b6582 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,24 +11,20 @@ import time import unittest import unittest.mock -from functools import lru_cache from threading import Event -from typing import Dict, Optional, cast # noqa # used in type hints - -import ifaddr +from typing import Dict, Optional # noqa # used in type hints import pytest import zeroconf as r from zeroconf import ( - DNSText, ServiceBrowser, ServiceInfo, Zeroconf, ZeroconfServiceTypes, ) -from . import _inject_response +from . import has_working_ipv6, _clear_cache, _inject_response log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -45,34 +41,6 @@ def teardown_module(): log.setLevel(original_logging_level) -@lru_cache(maxsize=None) -def has_working_ipv6(): - """Return True if if the system can bind an IPv6 address.""" - if not socket.has_ipv6: - return False - - try: - sock = socket.socket(socket.AF_INET6) - sock.bind(('::1', 0)) - except Exception: - return False - finally: - if sock: - sock.close() - - for iface in ifaddr.get_adapters(): - for addr in iface.ips: - if addr.is_IPv6 and iface.index is not None: - return True - return False - - -def _clear_cache(zc): - for name in zc.cache.names(): - for record in zc.cache.entries_with_name(name): - zc.cache.remove(record) - - class Names(unittest.TestCase): def test_long_name(self): generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) @@ -279,161 +247,6 @@ def generate_host(zc, host_name, type_): zc.send(out) -class Framework(unittest.TestCase): - def test_launch_and_close(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) - rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) - rv.close() - - def test_launch_and_close_context_manager(self): - with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: - assert rv.done is False - assert rv.done is True - - with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: - assert rv.done is False - assert rv.done is True - - def test_launch_and_close_unicast(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) - rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, unicast=True) - rv.close() - - def test_close_multiple_times(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) - rv.close() - rv.close() - - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_launch_and_close_v4_v6(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) - rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) - rv.close() - - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_launch_and_close_v6_only(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) - rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) - rv.close() - - def test_handle_response(self): - def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - ttl = 120 - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - - if service_state_change == r.ServiceStateChange.Updated: - generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 - ) - return r.DNSIncoming(generated.packet()) - - if service_state_change == r.ServiceStateChange.Removed: - ttl = 0 - - generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 - ) - generated.add_answer_at_time( - r.DNSService( - service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server - ), - 0, - ) - generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 - ) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - socket.inet_aton(service_address), - ), - 0, - ) - - return r.DNSIncoming(generated.packet()) - - def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - """Mock an incoming message for the case where the packet is split.""" - ttl = 120 - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - ttl, - socket.inet_aton(service_address), - ), - 0, - ) - generated.add_answer_at_time( - r.DNSService( - service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server - ), - 0, - ) - return r.DNSIncoming(generated.packet()) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-2.local.' - service_text = b'path=/~paulsm/' - service_address = '10.0.1.2' - - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - - try: - # service added - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) - assert dns_text is not None - assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' - all_dns_text = zeroconf.cache.get_all_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) - assert [dns_text] == all_dns_text - - # https://tools.ietf.org/html/rfc6762#section-10.2 - # Instead of merging this new record additively into the cache in addition - # to any previous records with the same name, rrtype, and rrclass, - # all old records with that name, rrtype, and rrclass that were received - # more than one second ago are declared invalid, - # and marked to expire from the cache in one second. - time.sleep(1.1) - - # service updated. currently only text record can be updated - service_text = b'path=/~humingchun/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) - assert dns_text is not None - assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' - - time.sleep(1.1) - - # The split message only has a SRV and A record. - # This should not evict TXT records from the cache - _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) - time.sleep(1.1) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) - assert dns_text is not None - assert cast(DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' - - # service removed - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) - assert dns_text is None - - finally: - zeroconf.close() - - class Exceptions(unittest.TestCase): browser = None # type: Zeroconf From b5d848de1ed95c55f8c262bcf0811248818da901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:33:50 -1000 Subject: [PATCH 0301/1433] Move exceptions tests to test_exceptions (#560) --- tests/test_exceptions.py | 156 +++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 126 ------------------------------- 2 files changed, 156 insertions(+), 126 deletions(-) create mode 100644 tests/test_exceptions.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 000000000..c85da0454 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf.exceptions """ + +import logging +import unittest +import unittest.mock + +import zeroconf as r +from zeroconf import ( + ServiceInfo, + Zeroconf, +) + + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class Exceptions(unittest.TestCase): + + browser = None # type: Zeroconf + + @classmethod + def setUpClass(cls): + cls.browser = Zeroconf(interfaces=['127.0.0.1']) + + @classmethod + def tearDownClass(cls): + cls.browser.close() + del cls.browser + + def test_bad_service_info_name(self): + self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") + + def test_bad_service_names(self): + bad_names_to_try = ( + '', + 'local', + '_tcp.local.', + '_udp.local.', + '._udp.local.', + '_@._tcp.local.', + '_A@._tcp.local.', + '_x--x._tcp.local.', + '_-x._udp.local.', + '_x-._tcp.local.', + '_22._udp.local.', + '_2-2._tcp.local.', + '_1234567890-abcde._udp.local.', + '\x00._x._udp.local.', + ) + for name in bad_names_to_try: + self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) + + def test_bad_local_names_for_get_service_info(self): + bad_names_to_try = ( + 'homekitdev._nothttp._tcp.local.', + 'homekitdev._http._udp.local.', + ) + for name in bad_names_to_try: + self.assertRaises( + r.BadTypeInNameException, self.browser.get_service_info, '_http._tcp.local.', name + ) + + def test_good_instance_names(self): + assert r.service_type_name('.._x._tcp.local.') == '_x._tcp.local.' + assert r.service_type_name('x.sub._http._tcp.local.') == '_http._tcp.local.' + assert ( + r.service_type_name('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') + == '_http._tcp.local.' + ) + + def test_good_instance_names_without_protocol(self): + good_names_to_try = ( + "Rachio-C73233.local.", + 'YeelightColorBulb-3AFD.local.', + 'YeelightTunableBulb-7220.local.', + "AlexanderHomeAssistant 74651D.local.", + 'iSmartGate-152.local.', + 'MyQ-FGA.local.', + 'lutron-02c4392a.local.', + 'WICED-hap-3E2734.local.', + 'MyHost.local.', + 'MyHost.sub.local.', + ) + for name in good_names_to_try: + assert r.service_type_name(name, strict=False) == 'local.' + + for name in good_names_to_try: + # Raises without strict=False + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) + + def test_bad_types(self): + bad_names_to_try = ( + '._x._tcp.local.', + 'a' * 64 + '._sub._http._tcp.local.', + 'a' * 62 + u'â._sub._http._tcp.local.', + ) + for name in bad_names_to_try: + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) + + def test_bad_sub_types(self): + bad_names_to_try = ( + '_sub._http._tcp.local.', + '._sub._http._tcp.local.', + '\x7f._sub._http._tcp.local.', + '\x1f._sub._http._tcp.local.', + ) + for name in bad_names_to_try: + self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) + + def test_good_service_names(self): + good_names_to_try = ( + ('_x._tcp.local.', '_x._tcp.local.'), + ('_x._udp.local.', '_x._udp.local.'), + ('_12345-67890-abc._udp.local.', '_12345-67890-abc._udp.local.'), + ('x._sub._http._tcp.local.', '_http._tcp.local.'), + ('a' * 63 + '._sub._http._tcp.local.', '_http._tcp.local.'), + ('a' * 61 + u'â._sub._http._tcp.local.', '_http._tcp.local.'), + ) + + for name, result in good_names_to_try: + assert r.service_type_name(name) == result + + assert r.service_type_name('_one_two._tcp.local.', strict=False) == '_one_two._tcp.local.' + + def test_invalid_addresses(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + bad = ('127.0.0.1', '::1', 42) + for addr in bad: + self.assertRaisesRegex( + TypeError, + 'Addresses must be bytes', + ServiceInfo, + type_, + registration_name, + port=80, + addresses=[addr], + ) diff --git a/tests/test_init.py b/tests/test_init.py index 7ec3b6582..f64443f04 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -247,132 +247,6 @@ def generate_host(zc, host_name, type_): zc.send(out) -class Exceptions(unittest.TestCase): - - browser = None # type: Zeroconf - - @classmethod - def setUpClass(cls): - cls.browser = Zeroconf(interfaces=['127.0.0.1']) - - @classmethod - def tearDownClass(cls): - cls.browser.close() - del cls.browser - - def test_bad_service_info_name(self): - self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") - - def test_bad_service_names(self): - bad_names_to_try = ( - '', - 'local', - '_tcp.local.', - '_udp.local.', - '._udp.local.', - '_@._tcp.local.', - '_A@._tcp.local.', - '_x--x._tcp.local.', - '_-x._udp.local.', - '_x-._tcp.local.', - '_22._udp.local.', - '_2-2._tcp.local.', - '_1234567890-abcde._udp.local.', - '\x00._x._udp.local.', - ) - for name in bad_names_to_try: - self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) - - def test_bad_local_names_for_get_service_info(self): - bad_names_to_try = ( - 'homekitdev._nothttp._tcp.local.', - 'homekitdev._http._udp.local.', - ) - for name in bad_names_to_try: - self.assertRaises( - r.BadTypeInNameException, self.browser.get_service_info, '_http._tcp.local.', name - ) - - def test_good_instance_names(self): - assert r.service_type_name('.._x._tcp.local.') == '_x._tcp.local.' - assert r.service_type_name('x.sub._http._tcp.local.') == '_http._tcp.local.' - assert ( - r.service_type_name('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') - == '_http._tcp.local.' - ) - - def test_good_instance_names_without_protocol(self): - good_names_to_try = ( - "Rachio-C73233.local.", - 'YeelightColorBulb-3AFD.local.', - 'YeelightTunableBulb-7220.local.', - "AlexanderHomeAssistant 74651D.local.", - 'iSmartGate-152.local.', - 'MyQ-FGA.local.', - 'lutron-02c4392a.local.', - 'WICED-hap-3E2734.local.', - 'MyHost.local.', - 'MyHost.sub.local.', - ) - for name in good_names_to_try: - assert r.service_type_name(name, strict=False) == 'local.' - - for name in good_names_to_try: - # Raises without strict=False - self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) - - def test_bad_types(self): - bad_names_to_try = ( - '._x._tcp.local.', - 'a' * 64 + '._sub._http._tcp.local.', - 'a' * 62 + u'â._sub._http._tcp.local.', - ) - for name in bad_names_to_try: - self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) - - def test_bad_sub_types(self): - bad_names_to_try = ( - '_sub._http._tcp.local.', - '._sub._http._tcp.local.', - '\x7f._sub._http._tcp.local.', - '\x1f._sub._http._tcp.local.', - ) - for name in bad_names_to_try: - self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) - - def test_good_service_names(self): - good_names_to_try = ( - ('_x._tcp.local.', '_x._tcp.local.'), - ('_x._udp.local.', '_x._udp.local.'), - ('_12345-67890-abc._udp.local.', '_12345-67890-abc._udp.local.'), - ('x._sub._http._tcp.local.', '_http._tcp.local.'), - ('a' * 63 + '._sub._http._tcp.local.', '_http._tcp.local.'), - ('a' * 61 + u'â._sub._http._tcp.local.', '_http._tcp.local.'), - ) - - for name, result in good_names_to_try: - assert r.service_type_name(name) == result - - assert r.service_type_name('_one_two._tcp.local.', strict=False) == '_one_two._tcp.local.' - - def test_invalid_addresses(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - bad = ('127.0.0.1', '::1', 42) - for addr in bad: - self.assertRaisesRegex( - TypeError, - 'Addresses must be bytes', - ServiceInfo, - type_, - registration_name, - port=80, - addresses=[addr], - ) - - class TestDnsIncoming(unittest.TestCase): def test_incoming_exception_handling(self): generated = r.DNSOutgoing(0) From ae1ce092de7eb4797da0f56e9eb8e538c95a8cc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:43:22 -1000 Subject: [PATCH 0302/1433] Move additional dns tests to test_dns (#561) --- tests/test_dns.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 68 ---------------------------------------------- 2 files changed, 68 insertions(+), 68 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 8117339d7..de0e4932e 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -416,6 +416,74 @@ def test_numbers_questions(self): assert num_additionals == 0 +class TestDnsIncoming(unittest.TestCase): + def test_incoming_exception_handling(self): + generated = r.DNSOutgoing(0) + packet = generated.packet() + packet = packet[:8] + b'deadbeef' + packet[8:] + parsed = r.DNSIncoming(packet) + parsed = r.DNSIncoming(packet) + assert parsed.valid is False + + def test_incoming_unknown_type(self): + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + generated.add_additional_answer(answer) + packet = generated.packet() + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 0 + assert parsed.is_query() != parsed.is_response() + + def test_incoming_ipv6(self): + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + packed = socket.inet_pton(socket.AF_INET6, addr) + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN | r._CLASS_UNIQUE, 1, packed) + generated.add_additional_answer(answer) + packet = generated.packet() + parsed = r.DNSIncoming(packet) + record = parsed.answers[0] + assert isinstance(record, r.DNSAddress) + assert record.address == packed + + +class TestDNSCache(unittest.TestCase): + def test_order(self): + record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + entry = r.DNSEntry('a', r._TYPE_SOA, r._CLASS_IN) + cached_record = cache.get(entry) + assert cached_record == record2 + + def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): + record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache + + def test_cache_empty_multiple_calls_does_not_throw(self): + record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + # Ensure multiple removes does not throw + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache + + def test_dns_compression_rollback_for_corruption(): """Verify rolling back does not lead to dns compression corruption.""" out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) diff --git a/tests/test_init.py b/tests/test_init.py index f64443f04..1ca4f0c11 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -247,37 +247,6 @@ def generate_host(zc, host_name, type_): zc.send(out) -class TestDnsIncoming(unittest.TestCase): - def test_incoming_exception_handling(self): - generated = r.DNSOutgoing(0) - packet = generated.packet() - packet = packet[:8] + b'deadbeef' + packet[8:] - parsed = r.DNSIncoming(packet) - parsed = r.DNSIncoming(packet) - assert parsed.valid is False - - def test_incoming_unknown_type(self): - generated = r.DNSOutgoing(0) - answer = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - generated.add_additional_answer(answer) - packet = generated.packet() - parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 0 - assert parsed.is_query() != parsed.is_response() - - def test_incoming_ipv6(self): - addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - packed = socket.inet_pton(socket.AF_INET6, addr) - generated = r.DNSOutgoing(0) - answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN | r._CLASS_UNIQUE, 1, packed) - generated.add_additional_answer(answer) - packet = generated.packet() - parsed = r.DNSIncoming(packet) - record = parsed.answers[0] - assert isinstance(record, r.DNSAddress) - assert record.address == packed - - class TestRegistrar(unittest.TestCase): def test_ttl(self): @@ -484,43 +453,6 @@ def test_lookups(self): assert registry.get_types() == [type_] -class TestDNSCache(unittest.TestCase): - def test_order(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - entry = r.DNSEntry('a', r._TYPE_SOA, r._CLASS_IN) - cached_record = cache.get(entry) - assert cached_record == record2 - - def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) - assert 'a' not in cache.cache - - def test_cache_empty_multiple_calls_does_not_throw(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) - # Ensure multiple removes does not throw - cache.remove(record1) - cache.remove(record2) - assert 'a' not in cache.cache - - class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): From 7807fa0dfdab20d950c446f17b7233a8c65cbab1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 17:46:38 -1000 Subject: [PATCH 0303/1433] Update setup.py for utils and services (#562) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ac24ca7ff..c1c0da340 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', package_data={"zeroconf": ["py.typed"]}, - packages=["zeroconf"], + packages=["zeroconf", "zeroconf.services", "zeroconf.utils"], platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, From a8420cde192647486eba4da4e54df9d0fe65adba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 23:28:23 -1000 Subject: [PATCH 0304/1433] Removed protected imports from zeroconf namespace (#567) - These protected items are not intended to be part of the public API --- tests/test_aio.py | 15 +-- tests/test_core.py | 61 ++++++++---- tests/test_dns.py | 211 +++++++++++++++++++++-------------------- tests/test_init.py | 130 ++++++++++++++----------- tests/test_services.py | 113 ++++++++++++---------- zeroconf/__init__.py | 54 ----------- 6 files changed, 291 insertions(+), 293 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 48a6ccc42..b1be151d7 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -11,17 +11,12 @@ import pytest -from zeroconf import ( - BadTypeInNameException, - NonUniqueNameException, - ServiceInfo, - ServiceListener, - ServiceNameAlreadyRegistered, - Zeroconf, - _LISTENER_TIME, - current_time_millis, -) from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf +from zeroconf.core import Zeroconf +from zeroconf.const import _LISTENER_TIME +from zeroconf.exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered +from zeroconf.services import ServiceInfo, ServiceListener +from zeroconf.utils.time import current_time_millis @pytest.fixture(autouse=True) diff --git a/tests/test_core.py b/tests/test_core.py index 0d63a58c7..0d2a2a064 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ import zeroconf as r from zeroconf import core +from zeroconf import const from . import has_working_ipv6, _inject_response @@ -39,8 +40,8 @@ def test_reaper(self): zeroconf = core.Zeroconf(interfaces=['127.0.0.1']) cache = zeroconf.cache original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - record_with_10s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') zeroconf.cache.add(record_with_10s_ttl) zeroconf.cache.add(record_with_1s_ttl) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) @@ -102,11 +103,18 @@ def test_launch_and_close_v6_only(self): def test_handle_response(self): def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: ttl = 120 - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) if service_state_change == r.ServiceStateChange.Updated: generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ), + 0, ) return r.DNSIncoming(generated.packet()) @@ -114,22 +122,32 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ttl = 0 generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 ) generated.add_answer_at_time( r.DNSService( - service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, ), 0, ) generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + r.DNSText( + service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + ), + 0, ) generated.add_answer_at_time( r.DNSAddress( service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_aton(service_address), ), @@ -141,12 +159,12 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: """Mock an incoming message for the case where the packet is split.""" ttl = 120 - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( r.DNSAddress( service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_aton(service_address), ), @@ -154,7 +172,14 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS ) generated.add_answer_at_time( r.DNSService( - service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, ), 0, ) @@ -171,10 +196,10 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS try: # service added _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' - all_dns_text = zeroconf.cache.get_all_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + all_dns_text = zeroconf.cache.get_all_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert [dns_text] == all_dns_text # https://tools.ietf.org/html/rfc6762#section-10.2 @@ -188,7 +213,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # service updated. currently only text record can be updated service_text = b'path=/~humingchun/' _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' @@ -198,13 +223,13 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # This should not evict TXT records from the cache _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) time.sleep(1.1) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' # service removed _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - dns_text = zeroconf.cache.get_by_details(service_name, r._TYPE_TXT, r._CLASS_IN) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is None finally: diff --git a/tests/test_dns.py b/tests/test_dns.py index de0e4932e..db41693d6 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -14,6 +14,7 @@ from typing import Dict, cast # noqa # used in type hints import zeroconf as r +from zeroconf import const from zeroconf import ( DNSHinfo, DNSText, @@ -46,46 +47,48 @@ def test_dns_text_repr(self): repr(text) def test_dns_hinfo_repr_eq(self): - hinfo = DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os') + hinfo = DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os') assert hinfo == hinfo repr(hinfo) def test_dns_pointer_repr(self): - pointer = r.DNSPointer('irrelevant', r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, '123') + pointer = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') repr(pointer) def test_dns_address_repr(self): - address = r.DNSAddress('irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + address = r.DNSAddress('irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, b'a') assert repr(address).endswith("b'a'") address_ipv4 = r.DNSAddress( - 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') + 'irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') ) assert repr(address_ipv4).endswith('127.0.0.1') address_ipv6 = r.DNSAddress( - 'irrelevant', r._TYPE_SOA, r._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') + 'irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') ) assert repr(address_ipv6).endswith('::1') def test_dns_question_repr(self): - question = r.DNSQuestion('irrelevant', r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE) + question = r.DNSQuestion('irrelevant', const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE) repr(question) assert not question != question def test_dns_service_repr(self): - service = r.DNSService('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL, 0, 0, 80, 'a') + service = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a' + ) repr(service) def test_dns_record_abc(self): - record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) self.assertRaises(r.AbstractMethodException, record.__eq__, record) self.assertRaises(r.AbstractMethodException, record.write, None) def test_dns_record_reset_ttl(self): - record = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) time.sleep(1) - record2 = r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + record2 = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) now = r.current_time_millis() assert record.created != record2.created @@ -131,7 +134,7 @@ def test_service_info_text_properties_not_given(self): repr(info) def test_dns_outgoing_repr(self): - dns_outgoing = r.DNSOutgoing(r._FLAGS_QR_QUERY) + dns_outgoing = r.DNSOutgoing(const._FLAGS_QR_QUERY) repr(dns_outgoing) @@ -145,22 +148,22 @@ def test_parse_own_packet_simple_unicast(self): r.DNSIncoming(generated.packet()) def test_parse_own_packet_flags(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) r.DNSIncoming(generated.packet()) def test_parse_own_packet_question(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - generated.add_question(r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN)) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) r.DNSIncoming(generated.packet()) def test_parse_own_packet_response(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( r.DNSService( "æøå.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, 80, @@ -173,8 +176,8 @@ def test_parse_own_packet_response(self): assert len(generated.answers) == len(parsed.answers) def test_match_question(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) parsed = r.DNSIncoming(generated.packet()) assert len(generated.questions) == 1 @@ -182,14 +185,14 @@ def test_match_question(self): assert question == parsed.questions[0] def test_suppress_answer(self): - query_generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + query_generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) query_generated.add_question(question) answer1 = r.DNSService( "testname1.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, 80, @@ -197,9 +200,9 @@ def test_suppress_answer(self): ) staleanswer2 = r.DNSService( "testname2.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL / 2, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL / 2, 0, 0, 80, @@ -207,9 +210,9 @@ def test_suppress_answer(self): ) answer2 = r.DNSService( "testname2.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, 80, @@ -220,7 +223,7 @@ def test_suppress_answer(self): query = r.DNSIncoming(query_generated.packet()) # Should be suppressed - response = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) response.add_answer(query, answer1) assert len(response.answers) == 0 @@ -237,13 +240,13 @@ def test_suppress_answer(self): # Should not be suppressed, type is different tmp = copy.copy(answer1) - tmp.type = r._TYPE_A + tmp.type = const._TYPE_A response.add_answer(query, tmp) assert len(response.answers) == 3 # Should not be suppressed, class is different tmp = copy.copy(answer1) - tmp.class_ = r._CLASS_NONE + tmp.class_ = const._CLASS_NONE response.add_answer(query, tmp) assert len(response.answers) == 4 @@ -251,30 +254,30 @@ def test_suppress_answer(self): def test_dns_hinfo(self): generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'os')) + generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) parsed = r.DNSIncoming(generated.packet()) answer = cast(r.DNSHinfo, parsed.answers[0]) assert answer.cpu == u'cpu' assert answer.os == u'os' generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', r._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) + generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) self.assertRaises(r.NamePartTooLongException, generated.packet) def test_many_questions(self): """Test many questions get seperated into multiple packets.""" - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for i in range(100): - question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) questions.append(question) assert len(generated.questions) == 100 packets = generated.packets() assert len(packets) == 2 - assert len(packets[0]) < r._MAX_MSG_TYPICAL - assert len(packets[1]) < r._MAX_MSG_TYPICAL + assert len(packets[0]) < const._MAX_MSG_TYPICAL + assert len(packets[1]) < const._MAX_MSG_TYPICAL parsed1 = r.DNSIncoming(packets[0]) assert len(parsed1.questions) == 85 @@ -286,15 +289,15 @@ def test_only_one_answer_can_by_large(self): https://datatracker.ietf.org/doc/html/rfc6762#section-17 """ - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - query = r.DNSIncoming(r.DNSOutgoing(r._FLAGS_QR_QUERY).packet()) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packet()) for i in range(3): generated.add_answer( query, r.DNSText( "zoom._hap._tcp.local.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, 1200, b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, ), @@ -303,9 +306,9 @@ def test_only_one_answer_can_by_large(self): query, r.DNSService( "testname1.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, 80, @@ -316,16 +319,16 @@ def test_only_one_answer_can_by_large(self): packets = generated.packets() assert len(packets) == 4 - assert len(packets[0]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[0]) > r._MAX_MSG_TYPICAL + assert len(packets[0]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[0]) > const._MAX_MSG_TYPICAL - assert len(packets[1]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[1]) > r._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[1]) > const._MAX_MSG_TYPICAL - assert len(packets[2]) <= r._MAX_MSG_ABSOLUTE - assert len(packets[2]) > r._MAX_MSG_TYPICAL + assert len(packets[2]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[2]) > const._MAX_MSG_TYPICAL - assert len(packets[3]) <= r._MAX_MSG_TYPICAL + assert len(packets[3]) <= const._MAX_MSG_TYPICAL for packet in packets: parsed = r.DNSIncoming(packet) @@ -341,15 +344,15 @@ def test_questions_do_not_end_up_every_packet(self): questions and as many more Known-Answer records as will fit. """ - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) for i in range(35): - question = r.DNSQuestion(f"testname{i}.local.", r._TYPE_SRV, r._CLASS_IN) + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) answer = r.DNSService( f"testname{i}.local.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, 80, @@ -362,8 +365,8 @@ def test_questions_do_not_end_up_every_packet(self): packets = generated.packets() assert len(packets) == 2 - assert len(packets[0]) <= r._MAX_MSG_TYPICAL - assert len(packets[1]) <= r._MAX_MSG_TYPICAL + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL parsed1 = r.DNSIncoming(packets[0]) assert len(parsed1.questions) == 35 @@ -377,25 +380,25 @@ def test_questions_do_not_end_up_every_packet(self): class PacketForm(unittest.TestCase): def test_transaction_id(self): """ID must be zero in a DNS-SD packet""" - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) bytes = generated.packet() id = bytes[0] << 8 | bytes[1] assert id == 0 def test_query_header_bits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_QUERY) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) bytes = generated.packet() flags = bytes[2] << 8 | bytes[3] assert flags == 0x0 def test_response_header_bits(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) bytes = generated.packet() flags = bytes[2] << 8 | bytes[3] assert flags == 0x8000 def test_numbers(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) bytes = generated.packet() (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) assert num_questions == 0 @@ -404,8 +407,8 @@ def test_numbers(self): assert num_additionals == 0 def test_numbers_questions(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("testname.local.", r._TYPE_SRV, r._CLASS_IN) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) for i in range(10): generated.add_question(question) bytes = generated.packet() @@ -427,7 +430,7 @@ def test_incoming_exception_handling(self): def test_incoming_unknown_type(self): generated = r.DNSOutgoing(0) - answer = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') + answer = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') generated.add_additional_answer(answer) packet = generated.packet() parsed = r.DNSIncoming(packet) @@ -438,7 +441,7 @@ def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) generated = r.DNSOutgoing(0) - answer = r.DNSAddress('domain', r._TYPE_AAAA, r._CLASS_IN | r._CLASS_UNIQUE, 1, packed) + answer = r.DNSAddress('domain', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) generated.add_additional_answer(answer) packet = generated.packet() parsed = r.DNSIncoming(packet) @@ -449,18 +452,18 @@ def test_incoming_ipv6(self): class TestDNSCache(unittest.TestCase): def test_order(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() cache.add(record1) cache.add(record2) - entry = r.DNSEntry('a', r._TYPE_SOA, r._CLASS_IN) + entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) cached_record = cache.get(entry) assert cached_record == record2 def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() cache.add(record1) cache.add(record2) @@ -470,8 +473,8 @@ def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): assert 'a' not in cache.cache def test_cache_empty_multiple_calls_does_not_throw(self): - record1 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', r._TYPE_SOA, r._CLASS_IN, 1, b'b') + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() cache.add(record1) cache.add(record2) @@ -486,7 +489,7 @@ def test_cache_empty_multiple_calls_does_not_throw(self): def test_dns_compression_rollback_for_corruption(): """Verify rolling back does not lead to dns compression corruption.""" - out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) address = socket.inet_pton(socket.AF_INET, "192.168.208.5") additionals = [ @@ -589,9 +592,9 @@ def test_dns_compression_rollback_for_corruption(): out.add_answer_at_time( DNSText( "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', ), @@ -602,9 +605,9 @@ def test_dns_compression_rollback_for_corruption(): out.add_additional_answer( r.DNSService( record["name"], # type: ignore - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, 0, 0, record["port"], # type: ignore @@ -614,18 +617,18 @@ def test_dns_compression_rollback_for_corruption(): out.add_additional_answer( r.DNSText( record["name"], # type: ignore - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, record["text"], # type: ignore ) ) out.add_additional_answer( r.DNSAddress( record["name"], # type: ignore - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_HOST_TTL, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, record["address"], # type: ignore ) ) @@ -639,17 +642,17 @@ def test_dns_compression_rollback_for_corruption(): def test_tc_bit_in_query_packet(): """Verify the TC bit is set when known answers exceed the packet size.""" - out = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) type_ = "_hap._tcp.local." - out.add_question(r.DNSQuestion(type_, r._TYPE_PTR, r._CLASS_IN)) + out.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN)) for i in range(30): out.add_answer_at_time( DNSText( ("HASS Bridge W9DN %s._hap._tcp.local." % i), - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', ), @@ -660,28 +663,28 @@ def test_tc_bit_in_query_packet(): assert len(packets) == 3 first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert first_packet.flags & const._FLAGS_TC == const._FLAGS_TC assert first_packet.valid is True second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & r._FLAGS_TC == r._FLAGS_TC + assert second_packet.flags & const._FLAGS_TC == const._FLAGS_TC assert second_packet.valid is True third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.flags & const._FLAGS_TC == 0 assert third_packet.valid is True def test_tc_bit_not_set_in_answer_packet(): """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" - out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) for i in range(30): out.add_answer_at_time( DNSText( ("HASS Bridge W9DN %s._hap._tcp.local." % i), - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, - r._DNS_OTHER_TTL, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', ), @@ -692,13 +695,13 @@ def test_tc_bit_not_set_in_answer_packet(): assert len(packets) == 3 first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & r._FLAGS_TC == 0 + assert first_packet.flags & const._FLAGS_TC == 0 assert first_packet.valid is True second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & r._FLAGS_TC == 0 + assert second_packet.flags & const._FLAGS_TC == 0 assert second_packet.valid is True third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & r._FLAGS_TC == 0 + assert third_packet.flags & const._FLAGS_TC == 0 assert third_packet.valid is True diff --git a/tests/test_init.py b/tests/test_init.py index 1ca4f0c11..f89f786ea 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -17,12 +17,7 @@ import pytest import zeroconf as r -from zeroconf import ( - ServiceBrowser, - ServiceInfo, - Zeroconf, - ZeroconfServiceTypes, -) +from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, ZeroconfServiceTypes, const from . import has_working_ipv6, _clear_cache, _inject_response @@ -43,38 +38,38 @@ def teardown_module(): class Names(unittest.TestCase): def test_long_name(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) question = r.DNSQuestion( - "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", r._TYPE_SRV, r._CLASS_IN + "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", const._TYPE_SRV, const._CLASS_IN ) generated.add_question(question) r.DNSIncoming(generated.packet()) def test_exceedingly_long_name(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) name = "%slocal." % ("part." * 1000) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) r.DNSIncoming(generated.packet()) def test_extra_exceedingly_long_name(self): - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) name = "%slocal." % ("part." * 4000) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) r.DNSIncoming(generated.packet()) def test_exceedingly_long_name_part(self): name = "%s.local." % ("a" * 1000) - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) self.assertRaises(r.NamePartTooLongException, generated.packet) def test_same_name(self): name = "paired.local." - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) - question = r.DNSQuestion(name, r._TYPE_SRV, r._CLASS_IN) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) generated.add_question(question) r.DNSIncoming(generated.packet()) @@ -99,7 +94,7 @@ def test_lots_of_names(self): longest_packet_len = 0 longest_packet = None # type: Optional[r.DNSOutgoing] - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" for packet in out.packets(): nonlocal longest_packet_len, longest_packet @@ -123,7 +118,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # we will never get to this large of a packet given the application-layer # splitting of packets, but we still want to track the longest_packet_len # for the debug message below - while sleep_count < 100 and longest_packet_len < r._MAX_MSG_ABSOLUTE - 100: + while sleep_count < 100 and longest_packet_len < const._MAX_MSG_ABSOLUTE - 100: sleep_count += 1 time.sleep(0.1) @@ -135,8 +130,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) # now the browser has sent at least one request, verify the size - assert longest_packet_len <= r._MAX_MSG_TYPICAL - assert longest_packet_len >= r._MAX_MSG_TYPICAL - 100 + assert longest_packet_len <= const._MAX_MSG_TYPICAL + assert longest_packet_len >= const._MAX_MSG_TYPICAL - 100 # mock zeroconf's logger warning() and debug() from unittest.mock import patch @@ -167,8 +162,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # mock the zeroconf logger and check for the correct logging backoff call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count # force receive on oversized packet - s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) - s.sendto(packet, 0, (r._MDNS_ADDR, r._MDNS_PORT)) + s.sendto(packet, 0, (const._MDNS_ADDR, const._MDNS_PORT)) + s.sendto(packet, 0, (const._MDNS_ADDR, const._MDNS_PORT)) time.sleep(2.0) zeroconf.log.debug( 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts @@ -238,10 +233,21 @@ def generate_many_hosts(self, zc, type_, name, number_hosts): @staticmethod def generate_host(zc, host_name, type_): name = '.'.join((host_name, type_)) - out = r.DNSOutgoing(r._FLAGS_QR_RESPONSE | r._FLAGS_AA) - out.add_answer_at_time(r.DNSPointer(type_, r._TYPE_PTR, r._CLASS_IN, r._DNS_OTHER_TTL, name), 0) + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + out.add_answer_at_time( + r.DNSPointer(type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name), 0 + ) out.add_answer_at_time( - r.DNSService(type_, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, r._DNS_HOST_TTL, 0, 0, 80, name), + r.DNSService( + type_, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + name, + ), 0, ) zc.send(out) @@ -275,10 +281,10 @@ def test_ttl(self): def get_ttl(record_type): if expected_ttl is not None: return expected_ttl - elif record_type in [r._TYPE_A, r._TYPE_SRV]: - return r._DNS_HOST_TTL + elif record_type in [const._TYPE_A, const._TYPE_SRV]: + return const._DNS_HOST_TTL else: - return r._DNS_OTHER_TTL + return const._DNS_OTHER_TTL def _process_outgoing_packet(out): """Sends an outgoing packet.""" @@ -305,12 +311,12 @@ def _process_outgoing_packet(out): nbr_answers = nbr_additionals = nbr_authorities = 0 # query - query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) assert query.is_query() is True - query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, r._TYPE_SRV, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -328,8 +334,8 @@ def _process_outgoing_packet(out): _process_outgoing_packet(zc.generate_service_query(info)) zc.registry.add(info) # register service with custom TTL - expected_ttl = r._DNS_HOST_TTL * 2 - assert expected_ttl != r._DNS_HOST_TTL + expected_ttl = const._DNS_HOST_TTL * 2 + assert expected_ttl != const._DNS_HOST_TTL for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, expected_ttl)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 @@ -337,11 +343,11 @@ def _process_outgoing_packet(out): # query expected_ttl = None - query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) - query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, r._TYPE_SRV, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, r._TYPE_TXT, r._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, r._TYPE_A, r._CLASS_IN)) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -405,8 +411,8 @@ def test_register_and_lookup_type_by_uppercase_name(self): info.load_from_cache(zc) assert info.addresses == [] - out = r.DNSOutgoing(r._FLAGS_QR_QUERY) - out.add_question(r.DNSQuestion(type_.upper(), r._TYPE_PTR, r._CLASS_IN)) + out = r.DNSOutgoing(const._FLAGS_QR_QUERY) + out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN)) zc.send(out) time.sleep(0.5) info = ServiceInfo(type_, registration_name) @@ -786,7 +792,7 @@ def update_service(self, zc, type_, name) -> None: def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) assert generated.is_response() is True if service_state_change == r.ServiceStateChange.Removed: @@ -795,12 +801,22 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ttl = 120 generated.add_answer_at_time( - r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text), 0 + r.DNSText( + service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + ), + 0, ) generated.add_answer_at_time( r.DNSService( - service_name, r._TYPE_SRV, r._CLASS_IN | r._CLASS_UNIQUE, ttl, 0, 0, 80, service_server + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, ), 0, ) @@ -812,8 +828,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi generated.add_answer_at_time( r.DNSAddress( service_server, - r._TYPE_AAAA, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_pton(socket.AF_INET6, service_v6_address), ), @@ -822,8 +838,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi generated.add_answer_at_time( r.DNSAddress( service_server, - r._TYPE_AAAA, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_pton(socket.AF_INET6, service_v6_second_address), ), @@ -832,8 +848,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi generated.add_answer_at_time( r.DNSAddress( service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_aton(service_address), ), @@ -841,7 +857,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ) generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 ) return r.DNSIncoming(generated.packet()) @@ -1003,19 +1019,19 @@ def test_ptr_optimization(): nbr_answers = nbr_additionals = nbr_authorities = 0 # query - query = r.DNSOutgoing(r._FLAGS_QR_QUERY | r._FLAGS_AA) - query.add_question(r.DNSQuestion(info.type, r._TYPE_PTR, r._CLASS_IN)) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) out = zc.query_handler.response(r.DNSIncoming(query.packet()), False) assert out is not None nbr_answers += len(out.answers) nbr_authorities += len(out.authorities) for answer in out.additionals: nbr_additionals += 1 - if answer.type == r._TYPE_SRV: + if answer.type == const._TYPE_SRV: has_srv = True - elif answer.type == r._TYPE_TXT: + elif answer.type == const._TYPE_TXT: has_txt = True - elif answer.type == r._TYPE_A: + elif answer.type == const._TYPE_A: has_a = True assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 assert has_srv and has_txt and has_a diff --git a/tests/test_services.py b/tests/test_services.py index 7cf476b42..07554eb67 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -14,6 +14,7 @@ import pytest import zeroconf as r +from zeroconf import const import zeroconf.services as s from zeroconf.core import Zeroconf from zeroconf.services import ( @@ -75,8 +76,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSText( service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', ), @@ -87,8 +88,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSService( service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, 0, 0, @@ -104,8 +105,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSAddress( 'ASH-2.local.', - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, new_address, ), @@ -117,8 +118,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSText( "incorrect.name.", - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', ), @@ -129,8 +130,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSService( "incorrect.name.", - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, 0, 0, @@ -146,8 +147,8 @@ def test_service_info_rejects_non_matching_updates(self): now, r.DNSAddress( "incorrect.name.", - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, new_address, ), @@ -174,8 +175,8 @@ def test_service_info_rejects_expired_records(self): now, r.DNSText( service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', ), @@ -184,8 +185,8 @@ def test_service_info_rejects_expired_records(self): # Expired record expired_record = r.DNSText( service_name, - r._TYPE_TXT, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', ) @@ -211,7 +212,7 @@ def test_get_info_partial(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" nonlocal last_sent @@ -223,7 +224,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -247,10 +248,10 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expext query for SRV, A, AAAA @@ -259,15 +260,23 @@ def get_service_info_helper(zc, type, name): _inject_response( zc, mock_incoming_msg( - [r.DNSText(service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text)] + [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ) + ] ), ) send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 3 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expext query for A, AAAA @@ -279,8 +288,8 @@ def get_service_info_helper(zc, type, name): [ r.DNSService( service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, 0, 0, @@ -293,8 +302,8 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 2 - assert r.DNSQuestion(service_server, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_server, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions last_sent = None assert service_info is None @@ -307,8 +316,8 @@ def get_service_info_helper(zc, type, name): [ r.DNSAddress( service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_pton(socket.AF_INET, service_address), ) @@ -340,7 +349,7 @@ def test_get_info_single(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" nonlocal last_sent @@ -352,7 +361,7 @@ def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -376,10 +385,10 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, r._TYPE_SRV, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_TXT, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_A, r._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, r._TYPE_AAAA, r._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expext no further queries @@ -390,12 +399,16 @@ def get_service_info_helper(zc, type, name): mock_incoming_msg( [ r.DNSText( - service_name, r._TYPE_TXT, r._CLASS_IN | r._CLASS_UNIQUE, ttl, service_text + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, ), r.DNSService( service_name, - r._TYPE_SRV, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, 0, 0, @@ -404,8 +417,8 @@ def get_service_info_helper(zc, type, name): ), r.DNSAddress( service_server, - r._TYPE_A, - r._CLASS_IN | r._CLASS_UNIQUE, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_pton(socket.AF_INET, service_address), ), @@ -449,9 +462,9 @@ def remove_service(self, zc, type_, name) -> None: def mock_incoming_msg( service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int ) -> r.DNSIncoming: - generated = r.DNSOutgoing(r._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( - r.DNSPointer(service_type, r._TYPE_PTR, r._CLASS_IN, ttl, service_name), 0 + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 ) return r.DNSIncoming(generated.packet()) @@ -476,7 +489,7 @@ def mock_incoming_msg( def _mock_get_expiration_time(self, percent): nonlocal called_with_refresh_time_check - if percent == r._EXPIRE_REFRESH_TIME_PERCENT: + if percent == const._EXPIRE_REFRESH_TIME_PERCENT: called_with_refresh_time_check = True return 0 return self.created + (percent * self.ttl * 10) @@ -541,7 +554,7 @@ def current_time_millis(): """Current system time in milliseconds""" return start_time + time_offset * 1000 - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" got_query.set() old_send(out, addr=addr, port=port) @@ -619,11 +632,11 @@ def current_time_millis(): """Current system time in milliseconds""" return time.time() * 1000 + time_offset * 1000 - expected_ttl = r._DNS_HOST_TTL + expected_ttl = const._DNS_HOST_TTL nbr_answers = 0 - def send(out, addr=r._MDNS_ADDR, port=r._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" pout = r.DNSIncoming(out.packet()) nonlocal nbr_answers @@ -693,7 +706,7 @@ def test_legacy_record_update_listener(): with pytest.raises(RuntimeError): r.RecordUpdateListener().update_record( - zc, 0, r.DNSRecord('irrelevant', r._TYPE_SRV, r._CLASS_IN, r._DNS_HOST_TTL) + zc, 0, r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) ) updates = [] diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8242c4e1e..043de013a 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -22,57 +22,6 @@ import sys -from .const import ( # noqa # import needed for backwards compat - _BROWSER_BACKOFF_LIMIT, - _BROWSER_TIME, - _CACHE_CLEANUP_INTERVAL, - _CHECK_TIME, - _CLASSES, - _CLASS_IN, - _CLASS_NONE, - _CLASS_MASK, - _CLASS_UNIQUE, - _DNS_HOST_TTL, - _DNS_OTHER_TTL, - _DNS_PORT, - _EXPIRE_FULL_TIME_PERCENT, - _EXPIRE_REFRESH_TIME_PERCENT, - _EXPIRE_STALE_TIME_PERCENT, - _FLAGS_AA, - _FLAGS_QR_MASK, - _FLAGS_QR_QUERY, - _FLAGS_QR_RESPONSE, - _FLAGS_TC, - _HAS_ASCII_CONTROL_CHARS, - _HAS_A_TO_Z, - _HAS_ONLY_A_TO_Z_NUM_HYPHEN, - _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE, - _IPPROTO_IPV6, - _LISTENER_TIME, - _LOCAL_TRAILER, - _MAX_MSG_ABSOLUTE, - _MAX_MSG_TYPICAL, - _MDNS_ADDR, - _MDNS_ADDR6, - _MDNS_ADDR6_BYTES, - _MDNS_ADDR_BYTES, - _MDNS_PORT, - _NONTCP_PROTOCOL_LOCAL_TRAILER, - _REGISTER_TIME, - _SERVICE_TYPE_ENUMERATION_NAME, - _TCP_PROTOCOL_LOCAL_TRAILER, - _TYPES, - _TYPE_A, - _TYPE_AAAA, - _TYPE_ANY, - _TYPE_CNAME, - _TYPE_HINFO, - _TYPE_PTR, - _TYPE_SOA, - _TYPE_SRV, - _TYPE_TXT, - _UNREGISTER_TIME, -) from .core import NotifyListener, Zeroconf # noqa # import needed for backwards compat from .dns import ( # noqa # import needed for backwards compat DNSAddress, @@ -102,7 +51,6 @@ Signal, SignalRegistrationInterface, RecordUpdateListener, - _ServiceBrowserBase, ServiceBrowser, ServiceInfo, ServiceListener, @@ -120,8 +68,6 @@ InterfaceChoice, InterfacesType, IPVersion, - _is_v6_address, - _encode_address, get_all_addresses, ) from .utils.struct import int2byte # noqa # import needed for backwards compat From 0e0bc2a901ed1d64e357c63e9fb8655f3a6e9298 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jun 2021 23:49:35 -1000 Subject: [PATCH 0305/1433] Breakout DNSCache into zeroconf.cache (#568) --- zeroconf/__init__.py | 2 +- zeroconf/cache.py | 117 +++++++++++++++++++++++++++++++++++++++++++ zeroconf/core.py | 3 +- zeroconf/dns.py | 98 +++--------------------------------- 4 files changed, 126 insertions(+), 94 deletions(-) create mode 100644 zeroconf/cache.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 043de013a..aae68e4ee 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -22,10 +22,10 @@ import sys +from .cache import DNSCache # noqa # import needed for backwards compat from .core import NotifyListener, Zeroconf # noqa # import needed for backwards compat from .dns import ( # noqa # import needed for backwards compat DNSAddress, - DNSCache, DNSEntry, DNSHinfo, DNSIncoming, diff --git a/zeroconf/cache.py b/zeroconf/cache.py new file mode 100644 index 000000000..48750f5af --- /dev/null +++ b/zeroconf/cache.py @@ -0,0 +1,117 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from typing import Dict, Iterable, List, Optional, cast + +from .const import _TYPE_PTR +from .dns import DNSEntry, DNSPointer, DNSRecord, DNSService +from .utils.time import current_time_millis + + +class DNSCache: + """A cache of DNS entries.""" + + def __init__(self) -> None: + self.cache: Dict[str, List[DNSRecord]] = {} + self.service_cache: Dict[str, List[DNSRecord]] = {} + + def add(self, entry: DNSRecord) -> None: + """Adds an entry""" + # Insert last in list, get will return newest entry + # iteration will result in last update winning + self.cache.setdefault(entry.key, []).append(entry) + if isinstance(entry, DNSService): + self.service_cache.setdefault(entry.server, []).append(entry) + + def add_records(self, entries: Iterable[DNSRecord]) -> None: + """Add multiple records.""" + for entry in entries: + self.add(entry) + + def remove(self, entry: DNSRecord) -> None: + """Removes an entry.""" + if isinstance(entry, DNSService): + DNSCache.remove_key(self.service_cache, entry.server, entry) + DNSCache.remove_key(self.cache, entry.key, entry) + + def remove_records(self, entries: Iterable[DNSRecord]) -> None: + """Remove multiple records.""" + for entry in entries: + self.remove(entry) + + @staticmethod + def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: + """Forgiving remove of a cache key.""" + try: + cache[key].remove(entry) + if not cache[key]: + del cache[key] + except (KeyError, ValueError): + pass + + def get(self, entry: DNSEntry) -> Optional[DNSRecord]: + """Gets an entry by key. Will return None if there is no + matching entry.""" + for cached_entry in reversed(self.entries_with_name(entry.key)): + if entry.__eq__(cached_entry): + return cached_entry + return None + + def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: + """Gets the first matching entry by details. Returns None if no entries match.""" + return self.get(DNSEntry(name, type_, class_)) + + def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: + """Gets all matching entries by details.""" + match_entry = DNSEntry(name, type_, class_) + return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] + + def entries_with_server(self, server: str) -> List[DNSRecord]: + """Returns a list of entries whose server matches the name.""" + return self.service_cache.get(server, [])[:] + + def entries_with_name(self, name: str) -> List[DNSRecord]: + """Returns a list of entries whose key matches the name.""" + return self.cache.get(name.lower(), [])[:] + + def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: + now = current_time_millis() + for record in reversed(self.entries_with_name(name)): + if ( + record.type == _TYPE_PTR + and not record.is_expired(now) + and cast(DNSPointer, record).alias == alias + ): + return record + return None + + def names(self) -> List[str]: + """Return a copy of the list of current cache names.""" + return list(self.cache) + + def expire(self, now: float) -> Iterable[DNSRecord]: + """Purge expired entries from the cache.""" + for name in self.names(): + for record in self.entries_with_name(name): + if record.is_expired(now): + self.remove(record) + yield record diff --git a/zeroconf/core.py b/zeroconf/core.py index f432c4112..23c3583ec 100644 --- a/zeroconf/core.py +++ b/zeroconf/core.py @@ -28,6 +28,7 @@ from types import TracebackType # noqa # used in type hints from typing import Dict, List, Optional, Type, Union, cast +from .cache import DNSCache from .const import ( _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, @@ -44,7 +45,7 @@ _TYPE_PTR, _UNREGISTER_TIME, ) -from .dns import DNSCache, DNSIncoming, DNSOutgoing, DNSQuestion +from .dns import DNSIncoming, DNSOutgoing, DNSQuestion from .exceptions import NonUniqueNameException from .handlers import QueryHandler, RecordManager from .logger import QuietLogger, log diff --git a/zeroconf/dns.py b/zeroconf/dns.py index 60d3c9192..c5139dac6 100644 --- a/zeroconf/dns.py +++ b/zeroconf/dns.py @@ -23,7 +23,7 @@ import enum import socket import struct -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast from .const import ( _CLASSES, @@ -54,6 +54,11 @@ from .utils.time import current_time_millis, millis_to_seconds +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from .cache import DNSCache # pylint: disable=cyclic-import + + class DNSEntry: """A DNS entry""" @@ -937,94 +942,3 @@ def packets(self) -> List[bytes]: break self.state = self.State.finished return self.packets_data - - -class DNSCache: - - """A cache of DNS entries""" - - def __init__(self) -> None: - self.cache = {} # type: Dict[str, List[DNSRecord]] - self.service_cache = {} # type: Dict[str, List[DNSRecord]] - - def add(self, entry: DNSRecord) -> None: - """Adds an entry""" - # Insert last in list, get will return newest entry - # iteration will result in last update winning - self.cache.setdefault(entry.key, []).append(entry) - if isinstance(entry, DNSService): - self.service_cache.setdefault(entry.server, []).append(entry) - - def add_records(self, entries: Iterable[DNSRecord]) -> None: - """Add multiple records.""" - for entry in entries: - self.add(entry) - - def remove(self, entry: DNSRecord) -> None: - """Removes an entry.""" - if isinstance(entry, DNSService): - DNSCache.remove_key(self.service_cache, entry.server, entry) - DNSCache.remove_key(self.cache, entry.key, entry) - - def remove_records(self, entries: Iterable[DNSRecord]) -> None: - """Remove multiple records.""" - for entry in entries: - self.remove(entry) - - @staticmethod - def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: - """Forgiving remove of a cache key.""" - try: - cache[key].remove(entry) - if not cache[key]: - del cache[key] - except (KeyError, ValueError): - pass - - def get(self, entry: DNSEntry) -> Optional[DNSRecord]: - """Gets an entry by key. Will return None if there is no - matching entry.""" - for cached_entry in reversed(self.entries_with_name(entry.key)): - if entry.__eq__(cached_entry): - return cached_entry - return None - - def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: - """Gets the first matching entry by details. Returns None if no entries match.""" - return self.get(DNSEntry(name, type_, class_)) - - def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: - """Gets all matching entries by details.""" - match_entry = DNSEntry(name, type_, class_) - return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] - - def entries_with_server(self, server: str) -> List[DNSRecord]: - """Returns a list of entries whose server matches the name.""" - return self.service_cache.get(server, [])[:] - - def entries_with_name(self, name: str) -> List[DNSRecord]: - """Returns a list of entries whose key matches the name.""" - return self.cache.get(name.lower(), [])[:] - - def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: - now = current_time_millis() - for record in reversed(self.entries_with_name(name)): - if ( - record.type == _TYPE_PTR - and not record.is_expired(now) - and cast(DNSPointer, record).alias == alias - ): - return record - return None - - def names(self) -> List[str]: - """Return a copy of the list of current cache names.""" - return list(self.cache) - - def expire(self, now: float) -> Iterable[DNSRecord]: - """Purge expired entries from the cache.""" - for name in self.names(): - for record in self.entries_with_name(name): - if record.is_expired(now): - self.remove(record) - yield record From 1e7c07481bb0cd08fe492dab02be888c6a1dadf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 00:07:35 -1000 Subject: [PATCH 0306/1433] Remove DNSOutgoing.packet backwards compatibility (#569) - DNSOutgoing.packet only returned a partial message when the DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL This was a legacy function that was replaced with .packets() which always returns a complete payload in #248 As packet() should not be used since it will end up missing data, it has been removed --- tests/test_core.py | 6 +++--- tests/test_dns.py | 36 ++++++++++++++++++------------------ tests/test_init.py | 20 ++++++++++---------- tests/test_services.py | 8 ++++---- zeroconf/dns.py | 15 --------------- 5 files changed, 35 insertions(+), 50 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 0d2a2a064..abdac3b9b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -116,7 +116,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ), 0, ) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) if service_state_change == r.ServiceStateChange.Removed: ttl = 0 @@ -154,7 +154,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi 0, ) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: """Mock an incoming message for the case where the packet is split.""" @@ -183,7 +183,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS ), 0, ) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) service_name = 'name._type._tcp.local.' service_type = '_type._tcp.local.' diff --git a/tests/test_dns.py b/tests/test_dns.py index db41693d6..a3c50ee21 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -141,20 +141,20 @@ def test_dns_outgoing_repr(self): class PacketGeneration(unittest.TestCase): def test_parse_own_packet_simple(self): generated = r.DNSOutgoing(0) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_simple_unicast(self): generated = r.DNSOutgoing(0, False) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_flags(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_response(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -171,7 +171,7 @@ def test_parse_own_packet_response(self): ), 0, ) - parsed = r.DNSIncoming(generated.packet()) + parsed = r.DNSIncoming(generated.packets()[0]) assert len(generated.answers) == 1 assert len(generated.answers) == len(parsed.answers) @@ -179,7 +179,7 @@ def test_match_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) - parsed = r.DNSIncoming(generated.packet()) + parsed = r.DNSIncoming(generated.packets()[0]) assert len(generated.questions) == 1 assert len(generated.questions) == len(parsed.questions) assert question == parsed.questions[0] @@ -220,7 +220,7 @@ def test_suppress_answer(self): ) query_generated.add_answer_at_time(answer1, 0) query_generated.add_answer_at_time(staleanswer2, 0) - query = r.DNSIncoming(query_generated.packet()) + query = r.DNSIncoming(query_generated.packets()[0]) # Should be suppressed response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -255,14 +255,14 @@ def test_suppress_answer(self): def test_dns_hinfo(self): generated = r.DNSOutgoing(0) generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) - parsed = r.DNSIncoming(generated.packet()) + parsed = r.DNSIncoming(generated.packets()[0]) answer = cast(r.DNSHinfo, parsed.answers[0]) assert answer.cpu == u'cpu' assert answer.os == u'os' generated = r.DNSOutgoing(0) generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) - self.assertRaises(r.NamePartTooLongException, generated.packet) + self.assertRaises(r.NamePartTooLongException, generated.packets) def test_many_questions(self): """Test many questions get seperated into multiple packets.""" @@ -290,7 +290,7 @@ def test_only_one_answer_can_by_large(self): https://datatracker.ietf.org/doc/html/rfc6762#section-17 """ generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packet()) + query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packets()[0]) for i in range(3): generated.add_answer( query, @@ -381,25 +381,25 @@ class PacketForm(unittest.TestCase): def test_transaction_id(self): """ID must be zero in a DNS-SD packet""" generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - bytes = generated.packet() + bytes = generated.packets()[0] id = bytes[0] << 8 | bytes[1] assert id == 0 def test_query_header_bits(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - bytes = generated.packet() + bytes = generated.packets()[0] flags = bytes[2] << 8 | bytes[3] assert flags == 0x0 def test_response_header_bits(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - bytes = generated.packet() + bytes = generated.packets()[0] flags = bytes[2] << 8 | bytes[3] assert flags == 0x8000 def test_numbers(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - bytes = generated.packet() + bytes = generated.packets()[0] (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) assert num_questions == 0 assert num_answers == 0 @@ -411,7 +411,7 @@ def test_numbers_questions(self): question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) for i in range(10): generated.add_question(question) - bytes = generated.packet() + bytes = generated.packets()[0] (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) assert num_questions == 10 assert num_answers == 0 @@ -422,7 +422,7 @@ def test_numbers_questions(self): class TestDnsIncoming(unittest.TestCase): def test_incoming_exception_handling(self): generated = r.DNSOutgoing(0) - packet = generated.packet() + packet = generated.packets()[0] packet = packet[:8] + b'deadbeef' + packet[8:] parsed = r.DNSIncoming(packet) parsed = r.DNSIncoming(packet) @@ -432,7 +432,7 @@ def test_incoming_unknown_type(self): generated = r.DNSOutgoing(0) answer = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') generated.add_additional_answer(answer) - packet = generated.packet() + packet = generated.packets()[0] parsed = r.DNSIncoming(packet) assert len(parsed.answers) == 0 assert parsed.is_query() != parsed.is_response() @@ -443,7 +443,7 @@ def test_incoming_ipv6(self): generated = r.DNSOutgoing(0) answer = r.DNSAddress('domain', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) generated.add_additional_answer(answer) - packet = generated.packet() + packet = generated.packets()[0] parsed = r.DNSIncoming(packet) record = parsed.answers[0] assert isinstance(record, r.DNSAddress) diff --git a/tests/test_init.py b/tests/test_init.py index f89f786ea..871055980 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -43,28 +43,28 @@ def test_long_name(self): "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", const._TYPE_SRV, const._CLASS_IN ) generated.add_question(question) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_exceedingly_long_name(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) name = "%slocal." % ("part." * 1000) question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_extra_exceedingly_long_name(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) name = "%slocal." % ("part." * 4000) question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_exceedingly_long_name_part(self): name = "%s.local." % ("a" * 1000) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) - self.assertRaises(r.NamePartTooLongException, generated.packet) + self.assertRaises(r.NamePartTooLongException, generated.packets) def test_same_name(self): name = "paired.local." @@ -72,7 +72,7 @@ def test_same_name(self): question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) generated.add_question(question) - r.DNSIncoming(generated.packet()) + r.DNSIncoming(generated.packets()[0]) def test_lots_of_names(self): @@ -156,7 +156,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): assert mocked_log_warn.call_count == call_counts[0] # force a receive of a packet - packet = out.packet() + packet = out.packets()[0] s = zc._respond_sockets[0] # mock the zeroconf logger and check for the correct logging backoff @@ -317,7 +317,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -348,7 +348,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packet()), False)) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -860,7 +860,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 ) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) @@ -1021,7 +1021,7 @@ def test_ptr_optimization(): # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - out = zc.query_handler.response(r.DNSIncoming(query.packet()), False) + out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False) assert out is not None nbr_answers += len(out.answers) nbr_authorities += len(out.authorities) diff --git a/tests/test_services.py b/tests/test_services.py index 07554eb67..d598d166d 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -229,7 +229,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: for record in records: generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) def get_service_info_helper(zc, type, name): nonlocal service_info @@ -366,7 +366,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: for record in records: generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) def get_service_info_helper(zc, type, name): nonlocal service_info @@ -466,7 +466,7 @@ def mock_incoming_msg( generated.add_answer_at_time( r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 ) - return r.DNSIncoming(generated.packet()) + return r.DNSIncoming(generated.packets()[0]) zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) @@ -638,7 +638,7 @@ def current_time_millis(): def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" - pout = r.DNSIncoming(out.packet()) + pout = r.DNSIncoming(out.packets()[0]) nonlocal nbr_answers for answer in pout.answers: nbr_answers += 1 diff --git a/zeroconf/dns.py b/zeroconf/dns.py index c5139dac6..430369f08 100644 --- a/zeroconf/dns.py +++ b/zeroconf/dns.py @@ -799,21 +799,6 @@ def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) del self.names[name] return False - def packet(self) -> bytes: - """Returns a bytestring containing the first packet's bytes. - - Generally, you want to use packets() in case the response - does not fit in a single packet, but this exists for - backward compatibility.""" - packets = self.packets() - if len(packets) == 0: - return b'' - if len(packets[0]) > _MAX_MSG_ABSOLUTE: - QuietLogger.log_warning_once( - "Created over-sized packet (%d bytes) %r", len(packets[0]), packets[0] - ) - return packets[0] - def _write_questions_from_offset(self, questions_offset: int) -> int: questions_written = 0 for question in self.questions[questions_offset:]: From ae552e94732568fd798e1f2d0e811849edff7790 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 00:19:21 -1000 Subject: [PATCH 0307/1433] Relocate services tests to test_services (#570) --- tests/test_init.py | 408 ---------------------------------------- tests/test_services.py | 411 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 410 insertions(+), 409 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index 871055980..eedb7fbb2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -588,414 +588,6 @@ def test_integration_with_subtype_and_listener(self): zeroconf_registrar.close() -class ListenerTest(unittest.TestCase): - def test_integration_with_listener_class(self): - - service_added = Event() - service_removed = Event() - service_updated = Event() - service_updated2 = Event() - - subtype_name = "My special Subtype" - type_ = "_http._tcp.local." - subtype = subtype_name + "._sub." + type_ - name = "UPPERxxxyyyæøå" - registration_name = "%s.%s" % (name, subtype) - - class MyListener(r.ServiceListener): - def add_service(self, zeroconf, type, name): - zeroconf.get_service_info(type, name) - service_added.set() - - def remove_service(self, zeroconf, type, name): - service_removed.set() - - def update_service(self, zeroconf, type, name): - service_updated2.set() - - class MySubListener(r.ServiceListener): - def add_service(self, zeroconf, type, name): - pass - - def remove_service(self, zeroconf, type, name): - pass - - def update_service(self, zeroconf, type, name): - service_updated.set() - - listener = MyListener() - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - zeroconf_browser.add_service_listener(subtype, listener) - - properties = dict( - prop_none=None, - prop_string=b'a_prop', - prop_float=1.0, - prop_blank=b'a blanked string', - prop_true=1, - prop_false=0, - ) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} # type: Dict - desc.update(properties) - addresses = [socket.inet_aton("10.0.1.2")] - if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): - addresses.append(socket.inet_pton(socket.AF_INET6, "6001:db8::1")) - addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) - info_service = ServiceInfo( - subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses - ) - zeroconf_registrar.register_service(info_service) - - try: - service_added.wait(1) - assert service_added.is_set() - - # short pause to allow multicast timers to expire - time.sleep(3) - - # clear the answer cache to force query - _clear_cache(zeroconf_browser) - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties == {} - - # get service info without answer cache - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_none'] is None - assert info.properties[b'prop_string'] == properties['prop_string'] - assert info.properties[b'prop_float'] == b'1.0' - assert info.properties[b'prop_blank'] == properties['prop_blank'] - assert info.properties[b'prop_true'] == b'1' - assert info.properties[b'prop_false'] == b'0' - assert info.addresses == addresses[:1] # no V6 by default - assert info.addresses_by_version(r.IPVersion.All) == addresses - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - - # Populate the cache - zeroconf_browser.get_service_info(subtype, registration_name) - - # get service info with only the cache - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - # get service info with only the cache with the lowercase name - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - # Ensure uppercase output is preserved - assert cached_info.name == registration_name - assert cached_info.key == registration_name.lower() - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - info = zeroconf_browser.get_service_info(subtype, registration_name) - assert info is not None - assert info.properties is not None - assert info.properties[b'prop_none'] is None - - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_none'] is None - - # test TXT record update - sublistener = MySubListener() - zeroconf_browser.add_service_listener(registration_name, sublistener) - properties['prop_blank'] = b'an updated string' - desc.update(properties) - info_service = ServiceInfo( - subtype, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.update_service(info_service) - service_updated.wait(1) - assert service_updated.is_set() - - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_blank'] == properties['prop_blank'] - - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] - - zeroconf_registrar.unregister_service(info_service) - service_removed.wait(1) - assert service_removed.is_set() - - finally: - zeroconf_registrar.close() - zeroconf_browser.remove_service_listener(listener) - zeroconf_browser.close() - - -class TestServiceBrowser(unittest.TestCase): - def test_update_record(self): - enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - service_v6_address = "2001:db8::1" - service_v6_second_address = "6001:db8::1" - - service_added_count = 0 - service_removed_count = 0 - service_updated_count = 0 - service_add_event = Event() - service_removed_event = Event() - service_updated_event = Event() - - class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: - nonlocal service_added_count - service_added_count += 1 - service_add_event.set() - - def remove_service(self, zc, type_, name) -> None: - nonlocal service_removed_count - service_removed_count += 1 - service_removed_event.set() - - def update_service(self, zc, type_, name) -> None: - nonlocal service_updated_count - service_updated_count += 1 - service_info = zc.get_service_info(type_, name) - assert socket.inet_aton(service_address) in service_info.addresses - if enable_ipv6: - assert socket.inet_pton( - socket.AF_INET6, service_v6_address - ) in service_info.addresses_by_version(r.IPVersion.V6Only) - assert socket.inet_pton( - socket.AF_INET6, service_v6_second_address - ) in service_info.addresses_by_version(r.IPVersion.V6Only) - assert service_info.text == service_text - assert service_info.server == service_server - service_updated_event.set() - - def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - assert generated.is_response() is True - - if service_state_change == r.ServiceStateChange.Removed: - ttl = 0 - else: - ttl = 120 - - generated.add_answer_at_time( - r.DNSText( - service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text - ), - 0, - ) - - generated.add_answer_at_time( - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - 0, - ) - - # Send the IPv6 address first since we previously - # had a bug where the IPv4 would be missing if the - # IPv6 was seen first - if enable_ipv6: - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_AAAA, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET6, service_v6_address), - ), - 0, - ) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_AAAA, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET6, service_v6_second_address), - ), - 0, - ) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_aton(service_address), - ), - 0, - ) - - generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 - ) - - return r.DNSIncoming(generated.packets()[0]) - - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) - - try: - wait_time = 3 - - # service added - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - service_add_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 0 - assert service_removed_count == 0 - - # service SRV updated - service_updated_event.clear() - service_server = 'ash-2.local.' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 1 - assert service_removed_count == 0 - - # service TXT updated - service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 2 - assert service_removed_count == 0 - - # service TXT updated - duplicate update should not trigger another service_updated - service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 2 - assert service_removed_count == 0 - - # service A updated - service_updated_event.clear() - service_address = '10.0.1.3' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 3 - assert service_removed_count == 0 - - # service all updated - service_updated_event.clear() - service_server = 'ash-3.local.' - service_text = b'path=/~matt3/' - service_address = '10.0.1.3' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 4 - assert service_removed_count == 0 - - # service removed - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - service_removed_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 4 - assert service_removed_count == 1 - - finally: - assert len(zeroconf.listeners) == 1 - service_browser.cancel() - assert len(zeroconf.listeners) == 0 - zeroconf.remove_all_service_listeners() - zeroconf.close() - - -def test_multiple_addresses(): - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" - address = socket.inet_aton(address_parsed) - - # New kwarg way - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) - - assert info.addresses == [address, address] - - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - parsed_addresses=[address_parsed, address_parsed], - ) - assert info.addresses == [address, address] - - if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): - address_v6_parsed = "2001:db8::1" - address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) - infos = [ - ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[address, address_v6], - ), - ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - parsed_addresses=[address_parsed, address_v6_parsed], - ), - ] - for info in infos: - assert info.addresses == [address] - assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] - assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] - assert info.parsed_addresses() == [address_parsed, address_v6_parsed] - assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] - - def test_ptr_optimization(): # instantiate a zeroconf instance diff --git a/tests/test_services.py b/tests/test_services.py index d598d166d..243662fe9 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -8,6 +8,7 @@ import socket import threading import time +import os import unittest from threading import Event @@ -23,7 +24,7 @@ ServiceStateChange, ) -from . import _inject_response +from . import has_working_ipv6, _clear_cache, _inject_response log = logging.getLogger('zeroconf') @@ -537,6 +538,414 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() +class ListenerTest(unittest.TestCase): + def test_integration_with_listener_class(self): + + service_added = Event() + service_removed = Event() + service_updated = Event() + service_updated2 = Event() + + subtype_name = "My special Subtype" + type_ = "_http._tcp.local." + subtype = subtype_name + "._sub." + type_ + name = "UPPERxxxyyyæøå" + registration_name = "%s.%s" % (name, subtype) + + class MyListener(r.ServiceListener): + def add_service(self, zeroconf, type, name): + zeroconf.get_service_info(type, name) + service_added.set() + + def remove_service(self, zeroconf, type, name): + service_removed.set() + + def update_service(self, zeroconf, type, name): + service_updated2.set() + + class MySubListener(r.ServiceListener): + def add_service(self, zeroconf, type, name): + pass + + def remove_service(self, zeroconf, type, name): + pass + + def update_service(self, zeroconf, type, name): + service_updated.set() + + listener = MyListener() + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + zeroconf_browser.add_service_listener(subtype, listener) + + properties = dict( + prop_none=None, + prop_string=b'a_prop', + prop_float=1.0, + prop_blank=b'a blanked string', + prop_true=1, + prop_false=0, + ) + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} # type: Dict + desc.update(properties) + addresses = [socket.inet_aton("10.0.1.2")] + if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): + addresses.append(socket.inet_pton(socket.AF_INET6, "6001:db8::1")) + addresses.append(socket.inet_pton(socket.AF_INET6, "2001:db8::1")) + info_service = ServiceInfo( + subtype, registration_name, port=80, properties=desc, server="ash-2.local.", addresses=addresses + ) + zeroconf_registrar.register_service(info_service) + + try: + service_added.wait(1) + assert service_added.is_set() + + # short pause to allow multicast timers to expire + time.sleep(3) + + # clear the answer cache to force query + _clear_cache(zeroconf_browser) + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties == {} + + # get service info without answer cache + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_none'] is None + assert info.properties[b'prop_string'] == properties['prop_string'] + assert info.properties[b'prop_float'] == b'1.0' + assert info.properties[b'prop_blank'] == properties['prop_blank'] + assert info.properties[b'prop_true'] == b'1' + assert info.properties[b'prop_false'] == b'0' + assert info.addresses == addresses[:1] # no V6 by default + assert info.addresses_by_version(r.IPVersion.All) == addresses + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + + # Populate the cache + zeroconf_browser.get_service_info(subtype, registration_name) + + # get service info with only the cache + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + # get service info with only the cache with the lowercase name + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + # Ensure uppercase output is preserved + assert cached_info.name == registration_name + assert cached_info.key == registration_name.lower() + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + info = zeroconf_browser.get_service_info(subtype, registration_name) + assert info is not None + assert info.properties is not None + assert info.properties[b'prop_none'] is None + + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_none'] is None + + # test TXT record update + sublistener = MySubListener() + zeroconf_browser.add_service_listener(registration_name, sublistener) + properties['prop_blank'] = b'an updated string' + desc.update(properties) + info_service = ServiceInfo( + subtype, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.update_service(info_service) + service_updated.wait(1) + assert service_updated.is_set() + + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_blank'] == properties['prop_blank'] + + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] + + zeroconf_registrar.unregister_service(info_service) + service_removed.wait(1) + assert service_removed.is_set() + + finally: + zeroconf_registrar.close() + zeroconf_browser.remove_service_listener(listener) + zeroconf_browser.close() + + +class TestServiceBrowser(unittest.TestCase): + def test_update_record(self): + enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + service_v6_address = "2001:db8::1" + service_v6_second_address = "6001:db8::1" + + service_added_count = 0 + service_removed_count = 0 + service_updated_count = 0 + service_add_event = Event() + service_removed_event = Event() + service_updated_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added_count + service_added_count += 1 + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_removed_count + service_removed_count += 1 + service_removed_event.set() + + def update_service(self, zc, type_, name) -> None: + nonlocal service_updated_count + service_updated_count += 1 + service_info = zc.get_service_info(type_, name) + assert socket.inet_aton(service_address) in service_info.addresses + if enable_ipv6: + assert socket.inet_pton( + socket.AF_INET6, service_v6_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) + assert socket.inet_pton( + socket.AF_INET6, service_v6_second_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) + assert service_info.text == service_text + assert service_info.server == service_server + service_updated_event.set() + + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + assert generated.is_response() is True + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + else: + ttl = 120 + + generated.add_answer_at_time( + r.DNSText( + service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + ), + 0, + ) + + generated.add_answer_at_time( + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + 0, + ) + + # Send the IPv6 address first since we previously + # had a bug where the IPv4 would be missing if the + # IPv6 was seen first + if enable_ipv6: + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_second_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + + generated.add_answer_at_time( + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + ) + + return r.DNSIncoming(generated.packets()[0]) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) + + try: + wait_time = 3 + + # service added + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) + service_add_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 0 + assert service_removed_count == 0 + + # service SRV updated + service_updated_event.clear() + service_server = 'ash-2.local.' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 1 + assert service_removed_count == 0 + + # service TXT updated + service_updated_event.clear() + service_text = b'path=/~matt2/' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 2 + assert service_removed_count == 0 + + # service TXT updated - duplicate update should not trigger another service_updated + service_updated_event.clear() + service_text = b'path=/~matt2/' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 2 + assert service_removed_count == 0 + + # service A updated + service_updated_event.clear() + service_address = '10.0.1.3' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 3 + assert service_removed_count == 0 + + # service all updated + service_updated_event.clear() + service_server = 'ash-3.local.' + service_text = b'path=/~matt3/' + service_address = '10.0.1.3' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 0 + + # service removed + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) + service_removed_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 1 + + finally: + assert len(zeroconf.listeners) == 1 + service_browser.cancel() + assert len(zeroconf.listeners) == 0 + zeroconf.remove_all_service_listeners() + zeroconf.close() + + +def test_multiple_addresses(): + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) + + assert info.addresses == [address, address] + + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_parsed], + ) + assert info.addresses == [address, address] + + if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): + address_v6_parsed = "2001:db8::1" + address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) + infos = [ + ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[address, address_v6], + ), + ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_v6_parsed], + ), + ] + for info in infos: + assert info.addresses == [address] + assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.V4Only) == [address] + assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] + assert info.parsed_addresses() == [address_parsed, address_v6_parsed] + assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] + + def test_backoff(): got_query = Event() From f10a562471ad89527e6eef6ba935a27177bb1417 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 00:46:35 -1000 Subject: [PATCH 0308/1433] Update changelog (#573) --- README.rst | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.rst b/README.rst index 228f0facf..dc9444253 100644 --- a/README.rst +++ b/README.rst @@ -189,6 +189,65 @@ Changelog The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. +* Breaking change: Remove DNSOutgoing.packet backwards compatibility (#569) @bdraco + + DNSOutgoing.packet only returned a partial message when the + DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL + This was a legacy function that was replaced with .packets() + which always returns a complete payload in #248 As packet() + should not be used since it will end up missing data, it has + been removed + +* Breakout DNSCache into zeroconf.cache (#568) @bdraco + +* Removed protected imports from zeroconf namespace (#567) @bdraco + +* Fix invalid typing in ServiceInfo._set_text (#554) @bdraco + +* Move QueryHandler and RecordManager handlers into zeroconf.handlers (#551) @bdraco + +* Move ServiceListener to zeroconf.services (#550) @bdraco + +* Move the ServiceRegistry into its own module (#549) @bdraco + +* Move ServiceStateChange to zeroconf.services (#548) @bdraco + +* Relocate core functions into zeroconf.core (#547) @bdraco + +* Breakout service classes into zeroconf.services (#544) @bdraco + +* Move service_type_name to zeroconf.utils.name (#543) @bdraco + +* Relocate DNS classes to zeroconf.dns (#541) @bdraco + +* Update zeroconf.aio import locations (#539) @bdraco + +* Move int2byte to zeroconf.utils.struct (#540) @bdraco + +* Breakout network utils into zeroconf.utils.net (#537) @bdraco + +* Move time utility functions into zeroconf.utils.time (#536) @bdraco + +* Avoid making DNSOutgoing aware of the Zeroconf object (#535) @bdraco + +* Move logger into zeroconf.logger (#533) @bdraco + +* Move exceptions into zeroconf.exceptions (#532) @bdraco + +* Move constants into const.py (#531) @bdraco + +* Move asyncio utils into zeroconf.utils.aio (#530) @bdraco + +* Move ipversion auto detection code into its own function (#524) @bdraco + +* Breaking change: Update python compatibility as PyPy3 7.2 is required (#523) @bdraco + +* Remove broad exception catch from RecordManager.remove_listener (#517) @bdraco + +* Small cleanups to RecordManager.add_listener (#516) @bdraco + +* Move RecordUpdateListener management into RecordManager (#514) @bdraco + * Break out record updating into RecordManager (#512) @bdraco * Remove uneeded wait in the Engine thread (#511) @bdraco From 0e61b1502c7fd3412f979bc4d651ee016e712de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 01:30:23 -1000 Subject: [PATCH 0309/1433] Mark zeroconf.dns as protected by renaming to zeroconf._dns (#574) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- tests/__init__.py | 2 +- zeroconf/__init__.py | 2 +- zeroconf/{dns.py => _dns.py} | 0 zeroconf/aio.py | 2 +- zeroconf/cache.py | 2 +- zeroconf/core.py | 2 +- zeroconf/handlers.py | 2 +- zeroconf/services/__init__.py | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename zeroconf/{dns.py => _dns.py} (100%) diff --git a/tests/__init__.py b/tests/__init__.py index 6399dbefe..237bea3ab 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -28,7 +28,7 @@ from zeroconf.core import Zeroconf -from zeroconf.dns import DNSIncoming +from zeroconf._dns import DNSIncoming def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index aae68e4ee..cee8d28a6 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -24,7 +24,7 @@ from .cache import DNSCache # noqa # import needed for backwards compat from .core import NotifyListener, Zeroconf # noqa # import needed for backwards compat -from .dns import ( # noqa # import needed for backwards compat +from ._dns import ( # noqa # import needed for backwards compat DNSAddress, DNSEntry, DNSHinfo, diff --git a/zeroconf/dns.py b/zeroconf/_dns.py similarity index 100% rename from zeroconf/dns.py rename to zeroconf/_dns.py diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 82e861998..3bfdce17f 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,9 +26,9 @@ from types import TracebackType # noqa # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Type, Union +from ._dns import DNSOutgoing from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME from .core import NotifyListener, Zeroconf -from .dns import DNSOutgoing from .exceptions import NonUniqueNameException from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from .utils.aio import wait_condition_or_timeout diff --git a/zeroconf/cache.py b/zeroconf/cache.py index 48750f5af..cb54341ed 100644 --- a/zeroconf/cache.py +++ b/zeroconf/cache.py @@ -22,8 +22,8 @@ from typing import Dict, Iterable, List, Optional, cast +from ._dns import DNSEntry, DNSPointer, DNSRecord, DNSService from .const import _TYPE_PTR -from .dns import DNSEntry, DNSPointer, DNSRecord, DNSService from .utils.time import current_time_millis diff --git a/zeroconf/core.py b/zeroconf/core.py index 23c3583ec..4ab97178a 100644 --- a/zeroconf/core.py +++ b/zeroconf/core.py @@ -28,6 +28,7 @@ from types import TracebackType # noqa # used in type hints from typing import Dict, List, Optional, Type, Union, cast +from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion from .cache import DNSCache from .const import ( _CACHE_CLEANUP_INTERVAL, @@ -45,7 +46,6 @@ _TYPE_PTR, _UNREGISTER_TIME, ) -from .dns import DNSIncoming, DNSOutgoing, DNSQuestion from .exceptions import NonUniqueNameException from .handlers import QueryHandler, RecordManager from .logger import QuietLogger, log diff --git a/zeroconf/handlers.py b/zeroconf/handlers.py index 000bc9083..2e2e37367 100644 --- a/zeroconf/handlers.py +++ b/zeroconf/handlers.py @@ -23,6 +23,7 @@ import itertools from typing import List, Optional, TYPE_CHECKING, Union +from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from .const import ( _CLASS_IN, _DNS_OTHER_TTL, @@ -35,7 +36,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from .dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from .logger import log from .services import ( RecordUpdateListener, diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index cd84971d0..f9ad4d25a 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -27,6 +27,7 @@ from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from .._dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -46,7 +47,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from ..dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from ..exceptions import BadTypeInNameException from ..utils.name import service_type_name from ..utils.net import ( From 601e8f70499638a6f24291bc0a28054fd78243c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 01:40:06 -1000 Subject: [PATCH 0310/1433] Mark zeroconf.core as protected by renaming to zeroconf._core (#575) --- tests/__init__.py | 3 +-- tests/test_aio.py | 2 +- tests/test_core.py | 6 +++--- tests/test_services.py | 2 +- zeroconf/__init__.py | 2 +- zeroconf/{core.py => _core.py} | 0 zeroconf/aio.py | 2 +- zeroconf/handlers.py | 2 +- zeroconf/services/__init__.py | 2 +- zeroconf/services/types.py | 2 +- 10 files changed, 11 insertions(+), 12 deletions(-) rename zeroconf/{core.py => _core.py} (100%) diff --git a/tests/__init__.py b/tests/__init__.py index 237bea3ab..420541d78 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -27,8 +27,7 @@ import ifaddr -from zeroconf.core import Zeroconf -from zeroconf._dns import DNSIncoming +from zeroconf import DNSIncoming, Zeroconf def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: diff --git a/tests/test_aio.py b/tests/test_aio.py index b1be151d7..d197570c3 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -12,7 +12,7 @@ import pytest from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf -from zeroconf.core import Zeroconf +from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf.exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf.services import ServiceInfo, ServiceListener diff --git a/tests/test_core.py b/tests/test_core.py index abdac3b9b..a99519cb9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,7 +14,7 @@ from typing import cast import zeroconf as r -from zeroconf import core +from zeroconf import _core from zeroconf import const from . import has_working_ipv6, _inject_response @@ -35,9 +35,9 @@ def teardown_module(): class TestReaper(unittest.TestCase): - @unittest.mock.patch.object(core, "_CACHE_CLEANUP_INTERVAL", 10) + @unittest.mock.patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10) def test_reaper(self): - zeroconf = core.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = _core.Zeroconf(interfaces=['127.0.0.1']) cache = zeroconf.cache original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') diff --git a/tests/test_services.py b/tests/test_services.py index 243662fe9..86f15ae78 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -17,7 +17,7 @@ import zeroconf as r from zeroconf import const import zeroconf.services as s -from zeroconf.core import Zeroconf +from zeroconf import Zeroconf from zeroconf.services import ( ServiceBrowser, ServiceInfo, diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cee8d28a6..cdac526b9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,7 +23,7 @@ import sys from .cache import DNSCache # noqa # import needed for backwards compat -from .core import NotifyListener, Zeroconf # noqa # import needed for backwards compat +from ._core import NotifyListener, Zeroconf # noqa # import needed for backwards compat from ._dns import ( # noqa # import needed for backwards compat DNSAddress, DNSEntry, diff --git a/zeroconf/core.py b/zeroconf/_core.py similarity index 100% rename from zeroconf/core.py rename to zeroconf/_core.py diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 3bfdce17f..3c503a46d 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,9 +26,9 @@ from types import TracebackType # noqa # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Type, Union +from ._core import NotifyListener, Zeroconf from ._dns import DNSOutgoing from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME -from .core import NotifyListener, Zeroconf from .exceptions import NonUniqueNameException from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from .utils.aio import wait_condition_or_timeout diff --git a/zeroconf/handlers.py b/zeroconf/handlers.py index 2e2e37367..a1de5eb06 100644 --- a/zeroconf/handlers.py +++ b/zeroconf/handlers.py @@ -46,7 +46,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .core import Zeroconf # pylint: disable=cyclic-import + from ._core import Zeroconf # pylint: disable=cyclic-import class QueryHandler: diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index f9ad4d25a..cf8b06861 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -59,7 +59,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from ..core import Zeroconf # pylint: disable=cyclic-import + from .._core import Zeroconf # pylint: disable=cyclic-import @enum.unique diff --git a/zeroconf/services/types.py b/zeroconf/services/types.py index e27defff8..d6cc1e976 100644 --- a/zeroconf/services/types.py +++ b/zeroconf/services/types.py @@ -23,8 +23,8 @@ import time from typing import Optional, Set, Tuple, Union +from .._core import Zeroconf from ..const import _SERVICE_TYPE_ENUMERATION_NAME -from ..core import Zeroconf from ..services import ServiceBrowser, ServiceListener from ..utils.net import IPVersion, InterfaceChoice, InterfacesType From c29a235eb59ed3b4883305cf11f8bf9fa06284d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 02:01:46 -1000 Subject: [PATCH 0311/1433] Log zeroconf.asyncio deprecation warning with the logger module (#576) --- zeroconf/asyncio.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index bdca1c0d3..0a0457e51 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -20,11 +20,8 @@ USA """ -import logging - from .aio import AsyncZeroconf # pylint: disable=unused-import # noqa - -log = logging.getLogger(__name__) +from .logger import log # The asyncio module would shadow system asyncio in some import cases # to resolve this, the module has been renamed zeroconf.aio From 1a2ee6892e996c1e84ba97082e5cda609d1d55d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 08:55:31 -1000 Subject: [PATCH 0312/1433] Mark zeroconf.handlers as protected by renaming to zeroconf._handlers (#577) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- zeroconf/_core.py | 2 +- zeroconf/{handlers.py => _handlers.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename zeroconf/{handlers.py => _handlers.py} (100%) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 4ab97178a..146793491 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -29,6 +29,7 @@ from typing import Dict, List, Optional, Type, Union, cast from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion +from ._handlers import QueryHandler, RecordManager from .cache import DNSCache from .const import ( _CACHE_CLEANUP_INTERVAL, @@ -47,7 +48,6 @@ _UNREGISTER_TIME, ) from .exceptions import NonUniqueNameException -from .handlers import QueryHandler, RecordManager from .logger import QuietLogger, log from .services import ( RecordUpdateListener, diff --git a/zeroconf/handlers.py b/zeroconf/_handlers.py similarity index 100% rename from zeroconf/handlers.py rename to zeroconf/_handlers.py From 500066f940aa89737f343976ee0387eae97eac37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:15:02 -1000 Subject: [PATCH 0313/1433] Mark zeroconf.logger as protected by renaming to zeroconf._logger (#578) --- tests/test_init.py | 4 ++-- tests/test_logger.py | 18 +++++++++--------- zeroconf/__init__.py | 2 +- zeroconf/_core.py | 2 +- zeroconf/_dns.py | 2 +- zeroconf/_handlers.py | 2 +- zeroconf/{logger.py => _logger.py} | 0 zeroconf/asyncio.py | 2 +- zeroconf/utils/net.py | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) rename zeroconf/{logger.py => _logger.py} (100%) diff --git a/tests/test_init.py b/tests/test_init.py index eedb7fbb2..ce4248575 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -136,8 +136,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # mock zeroconf's logger warning() and debug() from unittest.mock import patch - patch_warn = patch('zeroconf.log.warning') - patch_debug = patch('zeroconf.log.debug') + patch_warn = patch('zeroconf._logger.log.warning') + patch_debug = patch('zeroconf._logger.log.debug') mocked_log_warn = patch_warn.start() mocked_log_debug = patch_debug.start() diff --git a/tests/test_logger.py b/tests/test_logger.py index 52bf830f9..2c661cf98 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -5,22 +5,22 @@ """Unit tests for logger.py.""" from unittest.mock import patch -from zeroconf.logger import QuietLogger +from zeroconf._logger import QuietLogger def test_log_warning_once(): """Test we only log with warning level once.""" quiet_logger = QuietLogger() - with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( - "zeroconf.logger.log.debug" + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" ) as mock_log_debug: quiet_logger.log_warning_once("the warning") assert mock_log_warning.mock_calls assert not mock_log_debug.mock_calls - with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( - "zeroconf.logger.log.debug" + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" ) as mock_log_debug: quiet_logger.log_warning_once("the warning") @@ -31,16 +31,16 @@ def test_log_warning_once(): def test_log_exception_warning(): """Test we only log with warning level once.""" quiet_logger = QuietLogger() - with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( - "zeroconf.logger.log.debug" + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" ) as mock_log_debug: quiet_logger.log_exception_warning("the exception warning") assert mock_log_warning.mock_calls assert not mock_log_debug.mock_calls - with patch("zeroconf.logger.log.warning") as mock_log_warning, patch( - "zeroconf.logger.log.debug" + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" ) as mock_log_debug: quiet_logger.log_exception_warning("the exception warning") diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cdac526b9..794f95d25 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -36,6 +36,7 @@ DNSService, DNSText, ) +from ._logger import QuietLogger, log # noqa # import needed for backwards compat from .exceptions import ( # noqa # import needed for backwards compat AbstractMethodException, BadTypeInNameException, @@ -45,7 +46,6 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) -from .logger import QuietLogger, log # noqa # import needed for backwards compat from .services import ( # noqa # import needed for backwards compat instance_name_from_service_info, Signal, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 146793491..64b365eff 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -30,6 +30,7 @@ from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion from ._handlers import QueryHandler, RecordManager +from ._logger import QuietLogger, log from .cache import DNSCache from .const import ( _CACHE_CLEANUP_INTERVAL, @@ -48,7 +49,6 @@ _UNREGISTER_TIME, ) from .exceptions import NonUniqueNameException -from .logger import QuietLogger, log from .services import ( RecordUpdateListener, ServiceBrowser, diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 430369f08..d9f91a339 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -25,6 +25,7 @@ import struct from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast +from ._logger import QuietLogger, log from .const import ( _CLASSES, _CLASS_MASK, @@ -48,7 +49,6 @@ _TYPE_TXT, ) from .exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException -from .logger import QuietLogger, log from .utils.net import _is_v6_address from .utils.struct import int2byte from .utils.time import current_time_millis, millis_to_seconds diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index a1de5eb06..f3397fa90 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -24,6 +24,7 @@ from typing import List, Optional, TYPE_CHECKING, Union from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from ._logger import log from .const import ( _CLASS_IN, _DNS_OTHER_TTL, @@ -36,7 +37,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from .logger import log from .services import ( RecordUpdateListener, ) diff --git a/zeroconf/logger.py b/zeroconf/_logger.py similarity index 100% rename from zeroconf/logger.py rename to zeroconf/_logger.py diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 0a0457e51..3de171f73 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -20,8 +20,8 @@ USA """ +from ._logger import log from .aio import AsyncZeroconf # pylint: disable=unused-import # noqa -from .logger import log # The asyncio module would shadow system asyncio in some import cases # to resolve this, the module has been renamed zeroconf.aio diff --git a/zeroconf/utils/net.py b/zeroconf/utils/net.py index 5ea499241..963faf558 100644 --- a/zeroconf/utils/net.py +++ b/zeroconf/utils/net.py @@ -30,8 +30,8 @@ import ifaddr +from .._logger import log from ..const import _IPPROTO_IPV6, _MDNS_ADDR6_BYTES, _MDNS_ADDR_BYTES, _MDNS_PORT -from ..logger import log @enum.unique From dd9ada781fdb1d5efc7c6ad194426e92550245b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:22:45 -1000 Subject: [PATCH 0314/1433] Fix flakey backoff test race on startup (#579) --- tests/test_services.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 86f15ae78..090fc7367 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -989,9 +989,14 @@ def on_service_state_change(zeroconf, service_type, state_change, name): next_query_interval = 0.0 expected_query_time = 0.0 while True: - zeroconf_browser.notify_all() sleep_count += 1 - got_query.wait(0.1) + for _ in range(2): + # If the browser thread is starting up + # its possible we notify before the initial sleep + # which means the test will fail so we need to d + # this twice to eliminate the race condition + zeroconf_browser.notify_all() + got_query.wait(0.05) if time_offset == expected_query_time: assert got_query.is_set() got_query.clear() From 241700a07a76a8c45afbe1bdd8325cd9f0eb0168 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:25:11 -1000 Subject: [PATCH 0315/1433] Mark zeroconf.exceptions as protected by renaming to zeroconf._exceptions (#580) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- tests/test_aio.py | 2 +- tests/test_exceptions.py | 2 +- zeroconf/__init__.py | 2 +- zeroconf/_core.py | 2 +- zeroconf/_dns.py | 2 +- zeroconf/{exceptions.py => _exceptions.py} | 2 -- zeroconf/aio.py | 2 +- zeroconf/services/__init__.py | 2 +- zeroconf/services/registry.py | 2 +- zeroconf/utils/name.py | 2 +- 10 files changed, 9 insertions(+), 11 deletions(-) rename zeroconf/{exceptions.py => _exceptions.py} (98%) diff --git a/tests/test_aio.py b/tests/test_aio.py index d197570c3..962e0df85 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -14,7 +14,7 @@ from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME -from zeroconf.exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered +from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf.services import ServiceInfo, ServiceListener from zeroconf.utils.time import current_time_millis diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c85da0454..cfc4c19d9 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- -""" Unit tests for zeroconf.exceptions """ +""" Unit tests for zeroconf._exceptions """ import logging import unittest diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 794f95d25..39f0c3d79 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -37,7 +37,7 @@ DNSText, ) from ._logger import QuietLogger, log # noqa # import needed for backwards compat -from .exceptions import ( # noqa # import needed for backwards compat +from ._exceptions import ( # noqa # import needed for backwards compat AbstractMethodException, BadTypeInNameException, Error, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 64b365eff..5df9291f8 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -29,6 +29,7 @@ from typing import Dict, List, Optional, Type, Union, cast from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion +from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log from .cache import DNSCache @@ -48,7 +49,6 @@ _TYPE_PTR, _UNREGISTER_TIME, ) -from .exceptions import NonUniqueNameException from .services import ( RecordUpdateListener, ServiceBrowser, diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index d9f91a339..8deed48ff 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -25,6 +25,7 @@ import struct from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast +from ._exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log from .const import ( _CLASSES, @@ -48,7 +49,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from .exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException from .utils.net import _is_v6_address from .utils.struct import int2byte from .utils.time import current_time_millis, millis_to_seconds diff --git a/zeroconf/exceptions.py b/zeroconf/_exceptions.py similarity index 98% rename from zeroconf/exceptions.py rename to zeroconf/_exceptions.py index ea4686595..02771140f 100644 --- a/zeroconf/exceptions.py +++ b/zeroconf/_exceptions.py @@ -20,8 +20,6 @@ USA """ -# Exceptions - class Error(Exception): pass diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 3c503a46d..d8ed256e5 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -28,8 +28,8 @@ from ._core import NotifyListener, Zeroconf from ._dns import DNSOutgoing +from ._exceptions import NonUniqueNameException from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME -from .exceptions import NonUniqueNameException from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from .utils.aio import wait_condition_or_timeout from .utils.net import IPVersion, InterfaceChoice, InterfacesType diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index cf8b06861..4fc62c877 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -28,6 +28,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from .._dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from .._exceptions import BadTypeInNameException from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -47,7 +48,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from ..exceptions import BadTypeInNameException from ..utils.name import service_type_name from ..utils.net import ( IPVersion, diff --git a/zeroconf/services/registry.py b/zeroconf/services/registry.py index 19d4ba46f..b17c42841 100644 --- a/zeroconf/services/registry.py +++ b/zeroconf/services/registry.py @@ -24,7 +24,7 @@ from typing import Dict, List, Optional -from ..exceptions import ServiceNameAlreadyRegistered +from .._exceptions import ServiceNameAlreadyRegistered from ..services import ServiceInfo diff --git a/zeroconf/utils/name.py b/zeroconf/utils/name.py index 65713eb05..10a0ccf87 100644 --- a/zeroconf/utils/name.py +++ b/zeroconf/utils/name.py @@ -20,6 +20,7 @@ USA """ +from .._exceptions import BadTypeInNameException from ..const import ( _HAS_ASCII_CONTROL_CHARS, _HAS_A_TO_Z, @@ -29,7 +30,6 @@ _NONTCP_PROTOCOL_LOCAL_TRAILER, _TCP_PROTOCOL_LOCAL_TRAILER, ) -from ..exceptions import BadTypeInNameException def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches From a16e85b20c2069aa9cee0510c618cb61d46dc19c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:32:26 -1000 Subject: [PATCH 0316/1433] Mark zeroconf.cache as protected by renaming to zeroconf._cache (#581) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- zeroconf/__init__.py | 2 +- zeroconf/{cache.py => _cache.py} | 0 zeroconf/_core.py | 2 +- zeroconf/_dns.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename zeroconf/{cache.py => _cache.py} (100%) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 39f0c3d79..dec263531 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -22,7 +22,7 @@ import sys -from .cache import DNSCache # noqa # import needed for backwards compat +from ._cache import DNSCache # noqa # import needed for backwards compat from ._core import NotifyListener, Zeroconf # noqa # import needed for backwards compat from ._dns import ( # noqa # import needed for backwards compat DNSAddress, diff --git a/zeroconf/cache.py b/zeroconf/_cache.py similarity index 100% rename from zeroconf/cache.py rename to zeroconf/_cache.py diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 5df9291f8..594754ee9 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -28,11 +28,11 @@ from types import TracebackType # noqa # used in type hints from typing import Dict, List, Optional, Type, Union, cast +from ._cache import DNSCache from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log -from .cache import DNSCache from .const import ( _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 8deed48ff..5e1c60671 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -56,7 +56,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .cache import DNSCache # pylint: disable=cyclic-import + from ._cache import DNSCache # pylint: disable=cyclic-import class DNSEntry: From cc5bc36f6f7597a0adb0d637147c2f93ca243ff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:39:38 -1000 Subject: [PATCH 0317/1433] Mark zeroconf.utils as protected by renaming to zeroconf._utils (#582) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- setup.py | 2 +- tests/test_aio.py | 2 +- tests/utils/test_aio.py | 4 ++-- tests/utils/test_net.py | 8 ++++---- zeroconf/__init__.py | 8 ++++---- zeroconf/_cache.py | 2 +- zeroconf/_core.py | 20 ++++++++++---------- zeroconf/_dns.py | 6 +++--- zeroconf/_handlers.py | 2 +- zeroconf/{utils => _utils}/__init__.py | 0 zeroconf/{utils => _utils}/aio.py | 0 zeroconf/{utils => _utils}/name.py | 0 zeroconf/{utils => _utils}/net.py | 0 zeroconf/{utils => _utils}/struct.py | 0 zeroconf/{utils => _utils}/time.py | 0 zeroconf/aio.py | 6 +++--- zeroconf/services/__init__.py | 17 +++++++++-------- zeroconf/services/types.py | 2 +- 18 files changed, 40 insertions(+), 39 deletions(-) rename zeroconf/{utils => _utils}/__init__.py (100%) rename zeroconf/{utils => _utils}/aio.py (100%) rename zeroconf/{utils => _utils}/name.py (100%) rename zeroconf/{utils => _utils}/net.py (100%) rename zeroconf/{utils => _utils}/struct.py (100%) rename zeroconf/{utils => _utils}/time.py (100%) diff --git a/setup.py b/setup.py index c1c0da340..f6f8582be 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', package_data={"zeroconf": ["py.typed"]}, - packages=["zeroconf", "zeroconf.services", "zeroconf.utils"], + packages=["zeroconf", "zeroconf.services", "zeroconf._utils"], platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, diff --git a/tests/test_aio.py b/tests/test_aio.py index 962e0df85..5d04e0192 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -16,7 +16,7 @@ from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf.services import ServiceInfo, ServiceListener -from zeroconf.utils.time import current_time_millis +from zeroconf._utils.time import current_time_millis @pytest.fixture(autouse=True) diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index e38eb5832..a74d991d8 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -2,13 +2,13 @@ # -*- coding: utf-8 -*- -"""Unit tests for zeroconf.utils.aio.""" +"""Unit tests for zeroconf._utils.aio.""" import asyncio import pytest -from zeroconf.utils import aio as aioutils +from zeroconf._utils import aio as aioutils @pytest.mark.asyncio diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index d4b829c20..1360f9363 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -2,13 +2,13 @@ # -*- coding: utf-8 -*- -"""Unit tests for zeroconf.utils.net.""" +"""Unit tests for zeroconf._utils.net.""" from unittest.mock import Mock, patch import ifaddr import pytest -from zeroconf.utils import net as netutils +from zeroconf._utils import net as netutils def _generate_mock_adapters(): @@ -50,9 +50,9 @@ def test_interface_index_to_ip6_address(): def test_ip6_addresses_to_indexes(): """Test we can extract from mocked adapters.""" interfaces = [1] - with patch("zeroconf.utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): + with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] interfaces = ['2001:db8::'] - with patch("zeroconf.utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): + with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dec263531..e424118d7 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -58,8 +58,8 @@ ) from .services.registry import ServiceRegistry # noqa # import needed for backwards compat from .services.types import ZeroconfServiceTypes # noqa # import needed for backwards compat -from .utils.name import service_type_name # noqa # import needed for backwards compat -from .utils.net import ( # noqa # import needed for backwards compat +from ._utils.name import service_type_name # noqa # import needed for backwards compat +from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, can_send_to, autodetect_ip_version, @@ -70,8 +70,8 @@ IPVersion, get_all_addresses, ) -from .utils.struct import int2byte # noqa # import needed for backwards compat -from .utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat +from ._utils.struct import int2byte # noqa # import needed for backwards compat +from ._utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index cb54341ed..135b1884e 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -23,8 +23,8 @@ from typing import Dict, Iterable, List, Optional, cast from ._dns import DNSEntry, DNSPointer, DNSRecord, DNSService +from ._utils.time import current_time_millis from .const import _TYPE_PTR -from .utils.time import current_time_millis class DNSCache: diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 594754ee9..a2547305a 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -33,6 +33,16 @@ from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log +from ._utils.name import service_type_name +from ._utils.net import ( + IPVersion, + InterfaceChoice, + InterfacesType, + autodetect_ip_version, + can_send_to, + create_sockets, +) +from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, @@ -57,16 +67,6 @@ instance_name_from_service_info, ) from .services.registry import ServiceRegistry -from .utils.name import service_type_name -from .utils.net import ( - IPVersion, - InterfaceChoice, - InterfacesType, - autodetect_ip_version, - can_send_to, - create_sockets, -) -from .utils.time import current_time_millis, millis_to_seconds class NotifyListener: diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 5e1c60671..aa2b4983a 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -27,6 +27,9 @@ from ._exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log +from ._utils.net import _is_v6_address +from ._utils.struct import int2byte +from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _CLASSES, _CLASS_MASK, @@ -49,9 +52,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from .utils.net import _is_v6_address -from .utils.struct import int2byte -from .utils.time import current_time_millis, millis_to_seconds if TYPE_CHECKING: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index f3397fa90..83f2524a6 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -25,6 +25,7 @@ from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from ._logger import log +from ._utils.time import current_time_millis from .const import ( _CLASS_IN, _DNS_OTHER_TTL, @@ -41,7 +42,6 @@ RecordUpdateListener, ) from .services.registry import ServiceRegistry -from .utils.time import current_time_millis if TYPE_CHECKING: diff --git a/zeroconf/utils/__init__.py b/zeroconf/_utils/__init__.py similarity index 100% rename from zeroconf/utils/__init__.py rename to zeroconf/_utils/__init__.py diff --git a/zeroconf/utils/aio.py b/zeroconf/_utils/aio.py similarity index 100% rename from zeroconf/utils/aio.py rename to zeroconf/_utils/aio.py diff --git a/zeroconf/utils/name.py b/zeroconf/_utils/name.py similarity index 100% rename from zeroconf/utils/name.py rename to zeroconf/_utils/name.py diff --git a/zeroconf/utils/net.py b/zeroconf/_utils/net.py similarity index 100% rename from zeroconf/utils/net.py rename to zeroconf/_utils/net.py diff --git a/zeroconf/utils/struct.py b/zeroconf/_utils/struct.py similarity index 100% rename from zeroconf/utils/struct.py rename to zeroconf/_utils/struct.py diff --git a/zeroconf/utils/time.py b/zeroconf/_utils/time.py similarity index 100% rename from zeroconf/utils/time.py rename to zeroconf/_utils/time.py diff --git a/zeroconf/aio.py b/zeroconf/aio.py index d8ed256e5..d745957aa 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -29,11 +29,11 @@ from ._core import NotifyListener, Zeroconf from ._dns import DNSOutgoing from ._exceptions import NonUniqueNameException +from ._utils.aio import wait_condition_or_timeout +from ._utils.net import IPVersion, InterfaceChoice, InterfacesType +from ._utils.time import current_time_millis, millis_to_seconds from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info -from .utils.aio import wait_condition_or_timeout -from .utils.net import IPVersion, InterfaceChoice, InterfacesType -from .utils.time import current_time_millis, millis_to_seconds def _get_best_available_queue() -> queue.Queue: diff --git a/zeroconf/services/__init__.py b/zeroconf/services/__init__.py index 4fc62c877..09aa4c737 100644 --- a/zeroconf/services/__init__.py +++ b/zeroconf/services/__init__.py @@ -29,6 +29,14 @@ from .._dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException +from .._utils.name import service_type_name +from .._utils.net import ( + IPVersion, + _encode_address, + _is_v6_address, +) +from .._utils.struct import int2byte +from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -48,14 +56,7 @@ _TYPE_SRV, _TYPE_TXT, ) -from ..utils.name import service_type_name -from ..utils.net import ( - IPVersion, - _encode_address, - _is_v6_address, -) -from ..utils.struct import int2byte -from ..utils.time import current_time_millis, millis_to_seconds + if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 diff --git a/zeroconf/services/types.py b/zeroconf/services/types.py index d6cc1e976..6b454e65d 100644 --- a/zeroconf/services/types.py +++ b/zeroconf/services/types.py @@ -24,9 +24,9 @@ from typing import Optional, Set, Tuple, Union from .._core import Zeroconf +from .._utils.net import IPVersion, InterfaceChoice, InterfacesType from ..const import _SERVICE_TYPE_ENUMERATION_NAME from ..services import ServiceBrowser, ServiceListener -from ..utils.net import IPVersion, InterfaceChoice, InterfacesType class ZeroconfServiceTypes(ServiceListener): From 4a88066d66b2f2a00ebc388c5cda478c52cb9e6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:49:00 -1000 Subject: [PATCH 0318/1433] Mark zeroconf.services as protected by renaming to zeroconf._services (#583) - The public API should only access zeroconf and zeroconf.aio as internals may be relocated between releases --- setup.py | 2 +- tests/test_aio.py | 2 +- tests/test_services.py | 6 +++--- zeroconf/__init__.py | 6 +++--- zeroconf/_core.py | 16 ++++++++-------- zeroconf/_handlers.py | 6 ++---- zeroconf/{services => _services}/__init__.py | 0 zeroconf/{services => _services}/registry.py | 18 +++++++++--------- zeroconf/{services => _services}/types.py | 2 +- zeroconf/aio.py | 2 +- 10 files changed, 29 insertions(+), 31 deletions(-) rename zeroconf/{services => _services}/__init__.py (100%) rename zeroconf/{services => _services}/registry.py (90%) rename zeroconf/{services => _services}/types.py (98%) diff --git a/setup.py b/setup.py index f6f8582be..0ad299fb9 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', package_data={"zeroconf": ["py.typed"]}, - packages=["zeroconf", "zeroconf.services", "zeroconf._utils"], + packages=["zeroconf", "zeroconf._services", "zeroconf._utils"], platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, diff --git a/tests/test_aio.py b/tests/test_aio.py index 5d04e0192..2b2222422 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -15,7 +15,7 @@ from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered -from zeroconf.services import ServiceInfo, ServiceListener +from zeroconf._services import ServiceInfo, ServiceListener from zeroconf._utils.time import current_time_millis diff --git a/tests/test_services.py b/tests/test_services.py index 090fc7367..c78b9f9c8 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- -""" Unit tests for zeroconf.services. """ +""" Unit tests for zeroconf._services. """ import logging import socket @@ -16,9 +16,9 @@ import zeroconf as r from zeroconf import const -import zeroconf.services as s +import zeroconf._services as s from zeroconf import Zeroconf -from zeroconf.services import ( +from zeroconf._services import ( ServiceBrowser, ServiceInfo, ServiceStateChange, diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e424118d7..8277c32b3 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -46,7 +46,7 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) -from .services import ( # noqa # import needed for backwards compat +from ._services import ( # noqa # import needed for backwards compat instance_name_from_service_info, Signal, SignalRegistrationInterface, @@ -56,8 +56,8 @@ ServiceListener, ServiceStateChange, ) -from .services.registry import ServiceRegistry # noqa # import needed for backwards compat -from .services.types import ZeroconfServiceTypes # noqa # import needed for backwards compat +from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat +from ._services.types import ZeroconfServiceTypes # noqa # import needed for backwards compat from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a2547305a..1b3a4c1f4 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -33,6 +33,14 @@ from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log +from ._services import ( + RecordUpdateListener, + ServiceBrowser, + ServiceInfo, + ServiceListener, + instance_name_from_service_info, +) +from ._services.registry import ServiceRegistry from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -59,14 +67,6 @@ _TYPE_PTR, _UNREGISTER_TIME, ) -from .services import ( - RecordUpdateListener, - ServiceBrowser, - ServiceInfo, - ServiceListener, - instance_name_from_service_info, -) -from .services.registry import ServiceRegistry class NotifyListener: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 83f2524a6..7e8e734b5 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -25,6 +25,8 @@ from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from ._logger import log +from ._services import RecordUpdateListener +from ._services.registry import ServiceRegistry from ._utils.time import current_time_millis from .const import ( _CLASS_IN, @@ -38,10 +40,6 @@ _TYPE_SRV, _TYPE_TXT, ) -from .services import ( - RecordUpdateListener, -) -from .services.registry import ServiceRegistry if TYPE_CHECKING: diff --git a/zeroconf/services/__init__.py b/zeroconf/_services/__init__.py similarity index 100% rename from zeroconf/services/__init__.py rename to zeroconf/_services/__init__.py diff --git a/zeroconf/services/registry.py b/zeroconf/_services/registry.py similarity index 90% rename from zeroconf/services/registry.py rename to zeroconf/_services/registry.py index b17c42841..6d1baa8ea 100644 --- a/zeroconf/services/registry.py +++ b/zeroconf/_services/registry.py @@ -25,7 +25,7 @@ from .._exceptions import ServiceNameAlreadyRegistered -from ..services import ServiceInfo +from .._services import ServiceInfo class ServiceRegistry: @@ -40,7 +40,7 @@ def __init__( self, ) -> None: """Create the ServiceRegistry class.""" - self.services = {} # type: Dict[str, ServiceInfo] + self._services = {} # type: Dict[str, ServiceInfo] self.types = {} # type: Dict[str, List] self.servers = {} # type: Dict[str, List] self._lock = threading.Lock() # add and remove services thread safe @@ -66,11 +66,11 @@ def update(self, info: ServiceInfo) -> None: def get_service_infos(self) -> List[ServiceInfo]: """Return all ServiceInfo.""" - return list(self.services.values()) + return list(self._services.values()) def get_info_name(self, name: str) -> Optional[ServiceInfo]: """Return all ServiceInfo for the name.""" - return self.services.get(name) + return self._services.get(name) def get_types(self) -> List[str]: """Return all types.""" @@ -89,7 +89,7 @@ def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: service_infos = [] for name in getattr(self, attr).get(key, [])[:]: - info = self.services.get(name) + info = self._services.get(name) # Since we do not get under a lock since it would be # a performance issue, its possible # the service can be unregistered during the get @@ -102,17 +102,17 @@ def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" lower_name = info.name.lower() - if lower_name in self.services: + if lower_name in self._services: raise ServiceNameAlreadyRegistered - self.services[lower_name] = info + self._services[lower_name] = info self.types.setdefault(info.type, []).append(lower_name) self.servers.setdefault(info.server, []).append(lower_name) def _remove(self, info: ServiceInfo) -> None: """Remove a service under the lock.""" lower_name = info.name.lower() - old_service_info = self.services[lower_name] + old_service_info = self._services[lower_name] self.types[old_service_info.type].remove(lower_name) self.servers[old_service_info.server].remove(lower_name) - del self.services[lower_name] + del self._services[lower_name] diff --git a/zeroconf/services/types.py b/zeroconf/_services/types.py similarity index 98% rename from zeroconf/services/types.py rename to zeroconf/_services/types.py index 6b454e65d..f611fc4c3 100644 --- a/zeroconf/services/types.py +++ b/zeroconf/_services/types.py @@ -24,9 +24,9 @@ from typing import Optional, Set, Tuple, Union from .._core import Zeroconf +from .._services import ServiceBrowser, ServiceListener from .._utils.net import IPVersion, InterfaceChoice, InterfacesType from ..const import _SERVICE_TYPE_ENUMERATION_NAME -from ..services import ServiceBrowser, ServiceListener class ZeroconfServiceTypes(ServiceListener): diff --git a/zeroconf/aio.py b/zeroconf/aio.py index d745957aa..3df58eae8 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -29,11 +29,11 @@ from ._core import NotifyListener, Zeroconf from ._dns import DNSOutgoing from ._exceptions import NonUniqueNameException +from ._services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from ._utils.aio import wait_condition_or_timeout from ._utils.net import IPVersion, InterfaceChoice, InterfacesType from ._utils.time import current_time_millis, millis_to_seconds from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME -from .services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info def _get_best_available_queue() -> queue.Queue: From 1fe282ba246505d172356cc8672307c7d125820d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 09:58:38 -1000 Subject: [PATCH 0319/1433] Relocate ServiceTypesQuery tests to tests/services/test_types (#584) --- tests/services/__init__.py | 21 +++++ tests/services/test_types.py | 143 +++++++++++++++++++++++++++++++++++ tests/test_init.py | 135 +-------------------------------- 3 files changed, 166 insertions(+), 133 deletions(-) create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_types.py diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 000000000..2ef4b15b1 --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1,21 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" diff --git a/tests/services/test_types.py b/tests/services/test_types.py new file mode 100644 index 000000000..845e20f83 --- /dev/null +++ b/tests/services/test_types.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for zeroconf._services.types.""" + +import os +import unittest +import socket + +import zeroconf as r +from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes + +from .. import _clear_cache, has_working_ipv6 + + +class ServiceTypesQuery(unittest.TestCase): + def test_integration_with_listener(self): + + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + def test_integration_with_listener_v6_records(self): + + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_pton(socket.AF_INET6, addr)], + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + def test_integration_with_listener_ipv6(self): + + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + def test_integration_with_subtype_and_listener(self): + subtype_ = "_subtype._sub" + type_ = "_type._tcp.local." + name = "xxxyyy" + # Note: discovery returns only DNS-SD type not subtype + discovery_type = "%s.%s" % (subtype_, type_) + registration_name = "%s.%s" % (name, type_) + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + discovery_type, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.register_service(info) + + try: + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert discovery_type in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert discovery_type in service_types + + finally: + zeroconf_registrar.close() diff --git a/tests/test_init.py b/tests/test_init.py index ce4248575..ab45b75f0 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -6,20 +6,18 @@ import errno import logging -import os import socket import time import unittest import unittest.mock -from threading import Event from typing import Dict, Optional # noqa # used in type hints import pytest import zeroconf as r -from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, ZeroconfServiceTypes, const +from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, const -from . import has_working_ipv6, _clear_cache, _inject_response +from . import _clear_cache log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -459,135 +457,6 @@ def test_lookups(self): assert registry.get_types() == [type_] -class ServiceTypesQuery(unittest.TestCase): - def test_integration_with_listener(self): - - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.register_service(info) - - try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_integration_with_listener_v6_records(self): - - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_pton(socket.AF_INET6, addr)], - ) - zeroconf_registrar.register_service(info) - - try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_integration_with_listener_ipv6(self): - - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.register_service(info) - - try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - def test_integration_with_subtype_and_listener(self): - subtype_ = "_subtype._sub" - type_ = "_type._tcp.local." - name = "xxxyyy" - # Note: discovery returns only DNS-SD type not subtype - discovery_type = "%s.%s" % (subtype_, type_) - registration_name = "%s.%s" % (name, type_) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - discovery_type, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.register_service(info) - - try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert discovery_type in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert discovery_type in service_types - - finally: - zeroconf_registrar.close() - - def test_ptr_optimization(): # instantiate a zeroconf instance From 12f567695b5364c9c5c5af0a7017d877de84274d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:04:46 -1000 Subject: [PATCH 0320/1433] Relocate network utils tests to tests/utils/test_net (#585) --- tests/test_init.py | 24 ------------------------ tests/utils/test_net.py | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index ab45b75f0..9dffe12f6 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,7 +4,6 @@ """ Unit tests for zeroconf.py """ -import errno import logging import socket import time @@ -502,21 +501,6 @@ def test_ptr_optimization(): zc.close() -@pytest.mark.parametrize( - "errno,expected_result", - [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], -) -def test_add_multicast_member_socket_errors(errno, expected_result): - """Test we handle socket errors when adding multicast members.""" - if errno: - setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, "Error: {}".format(errno))) - else: - setsockopt_mock = unittest.mock.Mock() - fileno_mock = unittest.mock.PropertyMock(return_value=10) - socket_mock = unittest.mock.Mock(setsockopt=setsockopt_mock, fileno=fileno_mock) - assert r.add_multicast_member(socket_mock, "0.0.0.0") == expected_result - - def test_notify_listeners(): """Test adding and removing notify listeners.""" # instantiate a zeroconf instance @@ -553,11 +537,3 @@ def on_service_state_change(zeroconf, service_type, state_change, name): assert not notify_called zc.close() - - -def test_autodetect_ip_version(): - """Tests for auto detecting IPVersion based on interface ips.""" - assert r.autodetect_ip_version(["1.3.4.5"]) is r.IPVersion.V4Only - assert r.autodetect_ip_version([]) is r.IPVersion.V4Only - assert r.autodetect_ip_version(["::1", "1.2.3.4"]) is r.IPVersion.All - assert r.autodetect_ip_version(["::1"]) is r.IPVersion.V6Only diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 1360f9363..1a8beebe4 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -5,10 +5,13 @@ """Unit tests for zeroconf._utils.net.""" from unittest.mock import Mock, patch +import errno import ifaddr import pytest +import unittest from zeroconf._utils import net as netutils +import zeroconf as r def _generate_mock_adapters(): @@ -56,3 +59,26 @@ def test_ip6_addresses_to_indexes(): interfaces = ['2001:db8::'] with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] + + +@pytest.mark.parametrize( + "errno,expected_result", + [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], +) +def test_add_multicast_member_socket_errors(errno, expected_result): + """Test we handle socket errors when adding multicast members.""" + if errno: + setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, "Error: {}".format(errno))) + else: + setsockopt_mock = unittest.mock.Mock() + fileno_mock = unittest.mock.PropertyMock(return_value=10) + socket_mock = unittest.mock.Mock(setsockopt=setsockopt_mock, fileno=fileno_mock) + assert r.add_multicast_member(socket_mock, "0.0.0.0") == expected_result + + +def test_autodetect_ip_version(): + """Tests for auto detecting IPVersion based on interface ips.""" + assert r.autodetect_ip_version(["1.3.4.5"]) is r.IPVersion.V4Only + assert r.autodetect_ip_version([]) is r.IPVersion.V4Only + assert r.autodetect_ip_version(["::1", "1.2.3.4"]) is r.IPVersion.All + assert r.autodetect_ip_version(["::1"]) is r.IPVersion.V6Only From 5cb5702fca2845e99b457e4427428497c3cd9b31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:11:22 -1000 Subject: [PATCH 0321/1433] Disable flakey ServiceTypesQuery ipv6 win32 test (#586) --- tests/services/test_types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 845e20f83..e8e9911fa 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -7,6 +7,7 @@ import os import unittest import socket +import sys import zeroconf as r from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes @@ -78,7 +79,7 @@ def test_integration_with_listener_v6_records(self): finally: zeroconf_registrar.close() - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(not has_working_ipv6() or sys.platform == 'win32', 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_ipv6(self): From ae6530a59e2d8ddb9a7367243c29c5e00665a82f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:15:38 -1000 Subject: [PATCH 0322/1433] Relocate ServiceRegistry tests to tests/services/test_registry (#587) --- tests/services/test_registry.py | 48 +++++++++++++++++++++++++++++++++ tests/test_init.py | 37 ------------------------- 2 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 tests/services/test_registry.py diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py new file mode 100644 index 000000000..74af1e650 --- /dev/null +++ b/tests/services/test_registry.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for zeroconf._services.registry.""" + +import unittest +import socket + +import zeroconf as r +from zeroconf import ServiceInfo + + +class TestServiceRegistry(unittest.TestCase): + def test_only_register_once(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) + registry.remove(info) + registry.add(info) + + def test_lookups(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + + assert registry.get_service_infos() == [info] + assert registry.get_info_name(registration_name) == info + assert registry.get_infos_type(type_) == [info] + assert registry.get_infos_server("ash-2.local.") == [info] + assert registry.get_types() == [type_] diff --git a/tests/test_init.py b/tests/test_init.py index 9dffe12f6..33924ecbc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -419,43 +419,6 @@ def test_register_and_lookup_type_by_uppercase_name(self): zc.close() -class TestServiceRegistry(unittest.TestCase): - def test_only_register_once(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - - registry = r.ServiceRegistry() - registry.add(info) - self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) - registry.remove(info) - registry.add(info) - - def test_lookups(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - - registry = r.ServiceRegistry() - registry.add(info) - - assert registry.get_service_infos() == [info] - assert registry.get_info_name(registration_name) == info - assert registry.get_infos_type(type_) == [info] - assert registry.get_infos_server("ash-2.local.") == [info] - assert registry.get_types() == [type_] - - def test_ptr_optimization(): # instantiate a zeroconf instance From 8aa14d33849c057c91a00e1093606081ade488e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:29:10 -1000 Subject: [PATCH 0323/1433] Relocate handlers tests to tests/test_handlers (#588) --- tests/test_handlers.py | 246 +++++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 218 +----------------------------------- 2 files changed, 247 insertions(+), 217 deletions(-) create mode 100644 tests/test_handlers.py diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 000000000..53d9b9b77 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._handlers """ + +import logging +import pytest +import socket +import time +import unittest +import unittest.mock + +import zeroconf as r +from zeroconf import ServiceInfo, Zeroconf +from zeroconf import const + +from . import _clear_cache + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestRegistrar(unittest.TestCase): + def test_ttl(self): + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + nbr_answers = nbr_additionals = nbr_authorities = 0 + + def get_ttl(record_type): + if expected_ttl is not None: + return expected_ttl + elif record_type in [const._TYPE_A, const._TYPE_SRV]: + return const._DNS_HOST_TTL + else: + return const._DNS_OTHER_TTL + + def _process_outgoing_packet(out): + """Sends an outgoing packet.""" + nonlocal nbr_answers, nbr_additionals, nbr_authorities + + for answer, time_ in out.answers: + nbr_answers += 1 + assert answer.ttl == get_ttl(answer.type) + for answer in out.additionals: + nbr_additionals += 1 + assert answer.ttl == get_ttl(answer.type) + for answer in out.authorities: + nbr_authorities += 1 + assert answer.ttl == get_ttl(answer.type) + + # register service with default TTL + expected_ttl = None + for _ in range(3): + _process_outgoing_packet(zc.generate_service_query(info)) + zc.registry.add(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, None)) + assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # query + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + assert query.is_query() is True + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) + assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # unregister + expected_ttl = 0 + zc.registry.remove(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) + assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 + nbr_answers = nbr_additionals = nbr_authorities = 0 + + expected_ttl = None + for _ in range(3): + _process_outgoing_packet(zc.generate_service_query(info)) + zc.registry.add(info) + # register service with custom TTL + expected_ttl = const._DNS_HOST_TTL * 2 + assert expected_ttl != const._DNS_HOST_TTL + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, expected_ttl)) + assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # query + expected_ttl = None + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) + _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) + assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # unregister + expected_ttl = 0 + zc.registry.remove(info) + for _ in range(3): + _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) + assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 + nbr_answers = nbr_additionals = nbr_authorities = 0 + zc.close() + + def test_name_conflicts(self): + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_homeassistant._tcp.local." + name = "Home" + registration_name = "%s.%s" % (name, type_) + + info = ServiceInfo( + type_, + name=registration_name, + server="random123.local.", + addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], + port=80, + properties={"version": "1.0"}, + ) + zc.register_service(info) + + conflicting_info = ServiceInfo( + type_, + name=registration_name, + server="random456.local.", + addresses=[socket.inet_pton(socket.AF_INET, "4.5.6.7")], + port=80, + properties={"version": "1.0"}, + ) + with pytest.raises(r.NonUniqueNameException): + zc.register_service(conflicting_info) + zc.close() + + def test_register_and_lookup_type_by_uppercase_name(self): + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_mylowertype._tcp.local." + name = "Home" + registration_name = "%s.%s" % (name, type_) + + info = ServiceInfo( + type_, + name=registration_name, + server="random123.local.", + addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], + port=80, + properties={"version": "1.0"}, + ) + zc.register_service(info) + _clear_cache(zc) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zc) + assert info.addresses == [] + + out = r.DNSOutgoing(const._FLAGS_QR_QUERY) + out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN)) + zc.send(out) + time.sleep(0.5) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zc) + assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] + assert info.properties == {b"version": b"1.0"} + zc.close() + + +def test_ptr_optimization(): + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + nbr_answers = nbr_additionals = nbr_authorities = 0 + has_srv = has_txt = has_a = False + + # register + zc.register_service(info) + nbr_answers = nbr_additionals = nbr_authorities = 0 + + # query + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False) + assert out is not None + nbr_answers += len(out.answers) + nbr_authorities += len(out.authorities) + for answer in out.additionals: + nbr_additionals += 1 + if answer.type == const._TYPE_SRV: + has_srv = True + elif answer.type == const._TYPE_TXT: + has_txt = True + elif answer.type == const._TYPE_A: + has_a = True + assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert has_srv and has_txt and has_a + + # unregister + zc.unregister_service(info) + zc.close() diff --git a/tests/test_init.py b/tests/test_init.py index 33924ecbc..48fff674b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,15 +9,13 @@ import time import unittest import unittest.mock -from typing import Dict, Optional # noqa # used in type hints +from typing import Optional # noqa # used in type hints import pytest import zeroconf as r from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, const -from . import _clear_cache - log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -250,220 +248,6 @@ def generate_host(zc, host_name, type_): zc.send(out) -class TestRegistrar(unittest.TestCase): - def test_ttl(self): - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - - # service definition - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - - nbr_answers = nbr_additionals = nbr_authorities = 0 - - def get_ttl(record_type): - if expected_ttl is not None: - return expected_ttl - elif record_type in [const._TYPE_A, const._TYPE_SRV]: - return const._DNS_HOST_TTL - else: - return const._DNS_OTHER_TTL - - def _process_outgoing_packet(out): - """Sends an outgoing packet.""" - nonlocal nbr_answers, nbr_additionals, nbr_authorities - - for answer, time_ in out.answers: - nbr_answers += 1 - assert answer.ttl == get_ttl(answer.type) - for answer in out.additionals: - nbr_additionals += 1 - assert answer.ttl == get_ttl(answer.type) - for answer in out.authorities: - nbr_authorities += 1 - assert answer.ttl == get_ttl(answer.type) - - # register service with default TTL - expected_ttl = None - for _ in range(3): - _process_outgoing_packet(zc.generate_service_query(info)) - zc.registry.add(info) - for _ in range(3): - _process_outgoing_packet(zc.generate_service_broadcast(info, None)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 - nbr_answers = nbr_additionals = nbr_authorities = 0 - - # query - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) - assert query.is_query() is True - query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) - assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 - nbr_answers = nbr_additionals = nbr_authorities = 0 - - # unregister - expected_ttl = 0 - zc.registry.remove(info) - for _ in range(3): - _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 - nbr_answers = nbr_additionals = nbr_authorities = 0 - - expected_ttl = None - for _ in range(3): - _process_outgoing_packet(zc.generate_service_query(info)) - zc.registry.add(info) - # register service with custom TTL - expected_ttl = const._DNS_HOST_TTL * 2 - assert expected_ttl != const._DNS_HOST_TTL - for _ in range(3): - _process_outgoing_packet(zc.generate_service_broadcast(info, expected_ttl)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 - nbr_answers = nbr_additionals = nbr_authorities = 0 - - # query - expected_ttl = None - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) - query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) - assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 - nbr_answers = nbr_additionals = nbr_authorities = 0 - - # unregister - expected_ttl = 0 - zc.registry.remove(info) - for _ in range(3): - _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 - nbr_answers = nbr_additionals = nbr_authorities = 0 - zc.close() - - def test_name_conflicts(self): - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_homeassistant._tcp.local." - name = "Home" - registration_name = "%s.%s" % (name, type_) - - info = ServiceInfo( - type_, - name=registration_name, - server="random123.local.", - addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], - port=80, - properties={"version": "1.0"}, - ) - zc.register_service(info) - - conflicting_info = ServiceInfo( - type_, - name=registration_name, - server="random456.local.", - addresses=[socket.inet_pton(socket.AF_INET, "4.5.6.7")], - port=80, - properties={"version": "1.0"}, - ) - with pytest.raises(r.NonUniqueNameException): - zc.register_service(conflicting_info) - zc.close() - - def test_register_and_lookup_type_by_uppercase_name(self): - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_mylowertype._tcp.local." - name = "Home" - registration_name = "%s.%s" % (name, type_) - - info = ServiceInfo( - type_, - name=registration_name, - server="random123.local.", - addresses=[socket.inet_pton(socket.AF_INET, "1.2.3.4")], - port=80, - properties={"version": "1.0"}, - ) - zc.register_service(info) - _clear_cache(zc) - info = ServiceInfo(type_, registration_name) - info.load_from_cache(zc) - assert info.addresses == [] - - out = r.DNSOutgoing(const._FLAGS_QR_QUERY) - out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN)) - zc.send(out) - time.sleep(0.5) - info = ServiceInfo(type_, registration_name) - info.load_from_cache(zc) - assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] - assert info.properties == {b"version": b"1.0"} - zc.close() - - -def test_ptr_optimization(): - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - - # service definition - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - - nbr_answers = nbr_additionals = nbr_authorities = 0 - has_srv = has_txt = has_a = False - - # register - zc.register_service(info) - nbr_answers = nbr_additionals = nbr_authorities = 0 - - # query - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) - query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False) - assert out is not None - nbr_answers += len(out.answers) - nbr_authorities += len(out.authorities) - for answer in out.additionals: - nbr_additionals += 1 - if answer.type == const._TYPE_SRV: - has_srv = True - elif answer.type == const._TYPE_TXT: - has_txt = True - elif answer.type == const._TYPE_A: - has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 - assert has_srv and has_txt and has_a - - # unregister - zc.unregister_service(info) - zc.close() - - def test_notify_listeners(): """Test adding and removing notify listeners.""" # instantiate a zeroconf instance From fd70ac1b6bdded992f8fbbb723ca92f5395abf23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:32:44 -1000 Subject: [PATCH 0324/1433] Set mypy follow_imports to skip as ignore is not a valid option (#590) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index a9dddb260..e9dc052f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ ignore=E203,W503 [mypy] ignore_missing_imports = true -follow_imports = ignore +follow_imports = skip check_untyped_defs = true no_implicit_optional = true warn_incomplete_stub = true From 72032d6dde2ee7388b8cb4545554519d3ffa8508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 10:58:03 -1000 Subject: [PATCH 0325/1433] Move notify listener tests to test_core (#591) --- tests/test_core.py | 44 +++++++++++++++++++++++++++++++++++++++++--- tests/test_init.py | 40 ---------------------------------------- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index a99519cb9..6d7467abc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,11 +2,12 @@ # -*- coding: utf-8 -*- -""" Unit tests for zeroconf.core """ +""" Unit tests for zeroconf._core """ import itertools import logging import os +import pytest import socket import time import unittest @@ -14,8 +15,7 @@ from typing import cast import zeroconf as r -from zeroconf import _core -from zeroconf import const +from zeroconf import _core, const, ServiceBrowser, Zeroconf from . import has_working_ipv6, _inject_response @@ -234,3 +234,41 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS finally: zeroconf.close() + + +def test_notify_listeners(): + """Test adding and removing notify listeners.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + notify_called = 0 + + class TestNotifyListener(r.NotifyListener): + def notify_all(self): + nonlocal notify_called + notify_called += 1 + + with pytest.raises(NotImplementedError): + r.NotifyListener().notify_all() + + notify_listener = TestNotifyListener() + + zc.add_notify_listener(notify_listener) + + def on_service_state_change(zeroconf, service_type, state_change, name): + """Dummy service callback.""" + + # start a browser + browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) + browser.cancel() + + assert notify_called + zc.remove_notify_listener(notify_listener) + + notify_called = 0 + # start a browser + browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) + browser.cancel() + + assert not notify_called + + zc.close() diff --git a/tests/test_init.py b/tests/test_init.py index 48fff674b..4710a994d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,8 +11,6 @@ import unittest.mock from typing import Optional # noqa # used in type hints -import pytest - import zeroconf as r from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, const @@ -246,41 +244,3 @@ def generate_host(zc, host_name, type_): 0, ) zc.send(out) - - -def test_notify_listeners(): - """Test adding and removing notify listeners.""" - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - notify_called = 0 - - class TestNotifyListener(r.NotifyListener): - def notify_all(self): - nonlocal notify_called - notify_called += 1 - - with pytest.raises(NotImplementedError): - r.NotifyListener().notify_all() - - notify_listener = TestNotifyListener() - - zc.add_notify_listener(notify_listener) - - def on_service_state_change(zeroconf, service_type, state_change, name): - """Dummy service callback.""" - - # start a browser - browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) - browser.cancel() - - assert notify_called - zc.remove_notify_listener(notify_listener) - - notify_called = 0 - # start a browser - browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) - browser.cancel() - - assert not notify_called - - zc.close() From 35e25fd46f8d3689b723dd845eba9862a5dc8a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 11:36:30 -1000 Subject: [PATCH 0326/1433] Reduce branching in DNSOutgoing.add_answer_at_time (#592) --- tests/test_dns.py | 44 +++++++++++++++++++++++++++++++++++++++++++- zeroconf/_dns.py | 5 ++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index a3c50ee21..f24f22125 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -14,7 +14,7 @@ from typing import Dict, cast # noqa # used in type hints import zeroconf as r -from zeroconf import const +from zeroconf import const, current_time_millis from zeroconf import ( DNSHinfo, DNSText, @@ -175,6 +175,48 @@ def test_parse_own_packet_response(self): assert len(generated.answers) == 1 assert len(generated.answers) == len(parsed.answers) + def test_adding_empty_answer(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + None, + 0, + ) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + 0, + ) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.answers) == 1 + assert len(generated.answers) == len(parsed.answers) + + def test_adding_expired_answer(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + current_time_millis() + 1000000, + ) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.answers) == 0 + assert len(generated.answers) == len(parsed.answers) + def test_match_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index aa2b4983a..b8f571ed0 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -579,9 +579,8 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" - if record is not None: - if now == 0 or not record.is_expired(now): - self.answers.append((record, now)) + if record is not None and (now == 0 or not record.is_expired(now)): + self.answers.append((record, now)) def add_authorative_answer(self, record: DNSPointer) -> None: """Adds an authoritative answer""" From d2d826220bd4f287835ebb4304450cc2311d1db6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 12:57:15 -1000 Subject: [PATCH 0327/1433] Add unicast property to DNSQuestion to determine if the QU bit is set (#593) --- tests/test_dns.py | 22 +++++++++++++++++++++- zeroconf/_dns.py | 35 +++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index f24f22125..99a6b172a 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -14,7 +14,7 @@ from typing import Dict, cast # noqa # used in type hints import zeroconf as r -from zeroconf import const, current_time_millis +from zeroconf import DNSIncoming, const, current_time_millis from zeroconf import ( DNSHinfo, DNSText, @@ -747,3 +747,23 @@ def test_tc_bit_not_set_in_answer_packet(): third_packet = r.DNSIncoming(packets[2]) assert third_packet.flags & const._FLAGS_TC == 0 assert third_packet.valid is True + + +# 4003 15.973052 192.168.107.68 224.0.0.251 MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question +def test_qm_packet_parser(): + """Test we can parse a query packet with the QM bit.""" + qm_packet = ( + b'\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01' + ) + parsed = DNSIncoming(qm_packet) + assert parsed.questions[0].unicast is False + assert ",QM," in str(parsed.questions[0]) + + +# 389951 1450.577370 192.168.107.111 224.0.0.251 MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT +def test_qu_packet_parser(): + """Test we can parse a query packet with the QU bit.""" + qu_packet = b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' + parsed = DNSIncoming(qu_packet) + assert parsed.questions[0].unicast is True + assert ",QU," in str(parsed.questions[0]) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index b8f571ed0..542258088 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -91,17 +91,14 @@ def get_type(t: int) -> str: def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: """String representation with additional information""" - result = "%s[%s,%s" % (hdr, self.get_type(self.type), self.get_class_(self.class_)) - if self.unique: - result += "-unique," - else: - result += "," - result += self.name - if other is not None: - result += "]=%s" % cast(Any, other) - else: - result += "]" - return result + return "%s[%s,%s%s,%s]%s" % ( + hdr, + self.get_type(self.type), + self.get_class_(self.class_), + "-unique" if self.unique else "", + self.name, + "=%s" % cast(Any, other) if other is not None else "", + ) class DNSQuestion(DNSEntry): @@ -119,9 +116,23 @@ def answered_by(self, rec: 'DNSRecord') -> bool: and self.name == rec.name ) + @property + def unicast(self) -> bool: + """Returns true if the QU (not QM) is set. + + unique shares the same mask as the one + used for unicast. + """ + return self.unique + def __repr__(self) -> str: """String representation""" - return DNSEntry.entry_to_string(self, "question", None) + return "%s[question,%s,%s,%s]" % ( + self.get_type(self.type), + "QU" if self.unicast else "QM", + self.get_class_(self.class_), + self.name, + ) class DNSRecord(DNSEntry): From fe72524dbaf934ca63ebce053e34f3e838743460 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 14:07:09 -1000 Subject: [PATCH 0328/1433] Fix lookup of uppercase names in registry (#597) - If the ServiceInfo was registered with an uppercase name and the query was for a lowercase name, it would not be found and vice-versa. --- tests/services/test_registry.py | 38 +++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 7 +++--- zeroconf/_services/registry.py | 12 +++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 74af1e650..52726a041 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -46,3 +46,41 @@ def test_lookups(self): assert registry.get_infos_type(type_) == [info] assert registry.get_infos_server("ash-2.local.") == [info] assert registry.get_types() == [type_] + + def test_lookups_upper_case_by_lower_case(self): + type_ = "_test-SRVC-type._tcp.local." + name = "Xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ASH-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + + assert registry.get_service_infos() == [info] + assert registry.get_info_name(registration_name.lower()) == info + assert registry.get_infos_type(type_.lower()) == [info] + assert registry.get_infos_server("ash-2.local.") == [info] + assert registry.get_types() == [type_.lower()] + + def test_lookups_lower_case_by_upper_case(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + + assert registry.get_service_infos() == [info] + assert registry.get_info_name(registration_name.upper()) == info + assert registry.get_infos_type(type_.upper()) == [info] + assert registry.get_infos_server("ASH-2.local.") == [info] + assert registry.get_types() == [type_] diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 7e8e734b5..8d824ec52 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -73,7 +73,7 @@ def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgo def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name.lower()): + for service in self.registry.get_infos_type(question.name): out.add_answer(msg, service.dns_pointer()) # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. @@ -87,14 +87,13 @@ def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DN Add answer(s) for A, AAAA, SRV, or TXT queries. """ - name_to_find = question.name.lower() # Answer A record queries for any service addresses we know if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(name_to_find): + for service in self.registry.get_infos_server(question.name): for dns_address in service.dns_addresses(): out.add_answer(msg, dns_address) - service = self.registry.get_info_name(name_to_find) # type: ignore + service = self.registry.get_info_name(question.name) # type: ignore if service is None: return diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 6d1baa8ea..4c4c17065 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -70,7 +70,7 @@ def get_service_infos(self) -> List[ServiceInfo]: def get_info_name(self, name: str) -> Optional[ServiceInfo]: """Return all ServiceInfo for the name.""" - return self._services.get(name) + return self._services.get(name.lower()) def get_types(self) -> List[str]: """Return all types.""" @@ -88,7 +88,7 @@ def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" service_infos = [] - for name in getattr(self, attr).get(key, [])[:]: + for name in getattr(self, attr).get(key.lower(), [])[:]: info = self._services.get(name) # Since we do not get under a lock since it would be # a performance issue, its possible @@ -106,13 +106,13 @@ def _add(self, info: ServiceInfo) -> None: raise ServiceNameAlreadyRegistered self._services[lower_name] = info - self.types.setdefault(info.type, []).append(lower_name) - self.servers.setdefault(info.server, []).append(lower_name) + self.types.setdefault(info.type.lower(), []).append(lower_name) + self.servers.setdefault(info.server.lower(), []).append(lower_name) def _remove(self, info: ServiceInfo) -> None: """Remove a service under the lock.""" lower_name = info.name.lower() old_service_info = self._services[lower_name] - self.types[old_service_info.type].remove(lower_name) - self.servers[old_service_info.server].remove(lower_name) + self.types[old_service_info.type.lower()].remove(lower_name) + self.servers[old_service_info.server.lower()].remove(lower_name) del self._services[lower_name] From cb64e0dd5d1c621f61d0d0f92ea282d287a9c242 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 15:06:28 -1000 Subject: [PATCH 0329/1433] Add id_ param to allow setting the id in the DNSOutgoing constructor (#599) --- tests/test_dns.py | 5 +++++ zeroconf/_dns.py | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 99a6b172a..0e51aa4c4 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -427,6 +427,11 @@ def test_transaction_id(self): id = bytes[0] << 8 | bytes[1] assert id == 0 + def test_setting_id(self): + """Test setting id in the constructor""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY, id_=4444) + assert generated.id == 4444 + def test_query_header_bits(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) bytes = generated.packets()[0] diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 542258088..91bb14dce 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -537,25 +537,25 @@ class DNSOutgoing(DNSMessage): """Object representation of an outgoing packet""" - def __init__(self, flags: int, multicast: bool = True) -> None: + def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: super().__init__(flags) self.finished = False - self.id = 0 + self.id = id_ self.multicast = multicast - self.packets_data = [] # type: List[bytes] + self.packets_data: List[bytes] = [] # these 3 are per-packet -- see also reset_for_next_packet() - self.names = {} # type: Dict[str, int] - self.data = [] # type: List[bytes] - self.size = 12 - self.allow_long = True + self.names: Dict[str, int] = {} + self.data: List[bytes] = [] + self.size: int = 12 + self.allow_long: bool = True self.state = self.State.init - self.questions = [] # type: List[DNSQuestion] - self.answers = [] # type: List[Tuple[DNSRecord, float]] - self.authorities = [] # type: List[DNSPointer] - self.additionals = [] # type: List[DNSRecord] + self.questions: List[DNSQuestion] = [] + self.answers: List[Tuple[DNSRecord, float]] = [] + self.authorities: List[DNSPointer] = [] + self.additionals: List[DNSRecord] = [] def reset_for_next_packet(self) -> None: self.names = {} From 3556c22aacc72e62c318955c084533b70311bcc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 15:22:51 -1000 Subject: [PATCH 0330/1433] Ensure unicast responses can be sent to any source port (#598) - Unicast responses were only being sent if the source port was 53, this prevented responses when testing with dig: dig -p 5353 @224.0.0.251 media-12.local The above query will now see a response --- tests/test_handlers.py | 70 +++++++++++++++++++++++++++++++++++------- zeroconf/_core.py | 25 ++++++--------- zeroconf/_handlers.py | 43 ++++++++++++++------------ 3 files changed, 91 insertions(+), 47 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 53d9b9b77..ea7ab589a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -96,7 +96,9 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) + _process_outgoing_packet( + zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] + ) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -127,7 +129,9 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet(zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False)) + _process_outgoing_packet( + zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] + ) assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -216,21 +220,23 @@ def test_ptr_optimization(): type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) - nbr_answers = nbr_additionals = nbr_authorities = 0 - has_srv = has_txt = has_a = False - # register zc.register_service(info) - nbr_answers = nbr_additionals = nbr_authorities = 0 # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), False) - assert out is not None - nbr_answers += len(out.answers) - nbr_authorities += len(out.authorities) - for answer in out.additionals: + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT + ) + assert multicast_out.id == query.id + assert unicast_out is None + assert multicast_out is not None + has_srv = has_txt = has_a = False + nbr_additionals = 0 + nbr_answers = len(multicast_out.answers) + nbr_authorities = len(multicast_out.authorities) + for answer in multicast_out.additionals: nbr_additionals += 1 if answer.type == const._TYPE_SRV: has_srv = True @@ -244,3 +250,45 @@ def test_ptr_optimization(): # unregister zc.unregister_service(info) zc.close() + + +def test_unicast_response(): + """Ensure we send a unicast response when the source port is not the MDNS port.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + # register + zc.register_service(info) + + # query + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + unicast_out, multicast_out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), "1.2.3.4", 1234) + for out in (unicast_out, multicast_out): + assert out.id == query.id + has_srv = has_txt = has_a = False + nbr_additionals = 0 + nbr_answers = len(multicast_out.answers) + nbr_authorities = len(multicast_out.authorities) + for answer in out.additionals: + nbr_additionals += 1 + if answer.type == const._TYPE_SRV: + has_srv = True + elif answer.type == const._TYPE_TXT: + has_txt = True + elif answer.type == const._TYPE_A: + has_a = True + assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert has_srv and has_txt and has_a + + # unregister + zc.unregister_service(info) + zc.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1b3a4c1f4..783f81fae 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -55,7 +55,6 @@ _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, _CLASS_IN, - _DNS_PORT, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -209,19 +208,11 @@ def handle_read(self, socket_: socket.socket) -> None: if not msg.valid: pass - elif msg.is_query(): - # Always multicast responses - if port == _MDNS_PORT: - self.zc.handle_query(msg, None, _MDNS_PORT) - - # If it's not a multicast query, reply via unicast - # and multicast - elif port == _DNS_PORT: - self.zc.handle_query(msg, addr, port) - self.zc.handle_query(msg, None, _MDNS_PORT) - - else: + elif not msg.is_query(): self.zc.handle_response(msg) + return + + self.zc.handle_query(msg, addr, port) class Zeroconf(QuietLogger): @@ -502,9 +493,11 @@ def handle_response(self, msg: DNSIncoming) -> None: def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: """Deal with incoming query packets. Provides a response if possible.""" - out = self.query_handler.response(msg, port != _MDNS_PORT) - if out: - self.send(out, addr, port) + unicast_out, multicast_out = self.query_handler.response(msg, addr, port) + if unicast_out and unicast_out.answers: + self.send(unicast_out, addr, port) + if multicast_out and multicast_out.answers: + self.send(multicast_out, None, _MDNS_PORT) def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Sends an outgoing packet.""" diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 8d824ec52..65eb472b5 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -21,7 +21,7 @@ """ import itertools -from typing import List, Optional, TYPE_CHECKING, Union +from typing import List, Optional, TYPE_CHECKING, Tuple, Union from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from ._logger import log @@ -33,6 +33,7 @@ _DNS_OTHER_TTL, _FLAGS_AA, _FLAGS_QR_RESPONSE, + _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_A, _TYPE_ANY, @@ -105,30 +106,32 @@ def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DN for dns_address in service.dns_addresses(): out.add_additional_answer(dns_address) - def response(self, msg: DNSIncoming, unicast: bool) -> Optional[DNSOutgoing]: + def response( # pylint: disable=unused-argument + self, msg: DNSIncoming, addr: Optional[str], port: int + ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: """Deal with incoming query packets. Provides a response if possible.""" - if unicast: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False) + unicast_out = None + multicast_out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, id_=msg.id) + outputs = [multicast_out] + + if port != _MDNS_PORT: + unicast_out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=msg.id) + outputs.append(unicast_out) for question in msg.questions: - out.add_question(question) - else: - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - - for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._answer_service_type_enumeration_query(msg, out) - else: - self._answer_ptr_query(msg, out, question) - continue + unicast_out.add_question(question) - self._answer_non_ptr_query(msg, out, question) + for out in outputs: + for question in msg.questions: + if question.type == _TYPE_PTR: + if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + self._answer_service_type_enumeration_query(msg, out) + else: + self._answer_ptr_query(msg, out, question) + continue - if out is not None and out.answers: - out.id = msg.id - return out + self._answer_non_ptr_query(msg, out, question) - return None + return unicast_out, multicast_out class RecordManager: From f6cd8f6d23459f9ed48ad06ff6702e606d620eaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 15:40:23 -1000 Subject: [PATCH 0331/1433] Add ZeroconfServiceTypes to zeroconf.__all__ (#601) - This class is in the readme, but is not exported by default --- zeroconf/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8277c32b3..02d2afa16 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -57,7 +57,7 @@ ServiceStateChange, ) from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat -from ._services.types import ZeroconfServiceTypes # noqa # import needed for backwards compat +from ._services.types import ZeroconfServiceTypes from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, @@ -89,6 +89,7 @@ "InterfaceChoice", "ServiceStateChange", "IPVersion", + "ZeroconfServiceTypes", ] if sys.version_info <= (3, 6): From 809b6df376205e6ab5ce8fb5fe3a92e77662fe2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 15:40:41 -1000 Subject: [PATCH 0332/1433] Fix docs version to match readme (cpython 3.6+) (#602) --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index de5ba41af..8929f417b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ PyPI (installable, stable distributions): https://pypi.org/project/zeroconf. You pip install zeroconf -python-zeroconf works with CPython 3.5+ and PyPy 3 implementing Python 3.5+. +python-zeroconf works with CPython 3.6+ and PyPy 3 implementing Python 3.6+. Contents -------- From 850e2115aa79c10765dfc45a290a68193397de6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Jun 2021 23:58:34 -1000 Subject: [PATCH 0333/1433] Log destination when sending packets (#606) --- zeroconf/_core.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 783f81fae..3532318bb 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -501,14 +501,19 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Sends an outgoing packet.""" - packets = out.packets() - packet_num = 0 - for packet in packets: - packet_num += 1 + for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return - log.debug('Sending (%d bytes #%d) %r as %r...', len(packet), packet_num, out, packet) + log.debug( + 'Sending to (%s, %d) (%d bytes #%d) %r as %r...', + addr, + port, + len(packet), + packet_num + 1, + out, + packet, + ) for s in self._respond_sockets: if self._GLOBAL_DONE: return From 22bd1475fb58c7c421c0009cd0c5c791cedb225d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 00:43:07 -1000 Subject: [PATCH 0334/1433] Ensure the QU bit is set for probe queries (#609) - The bit should be set per https://datatracker.ietf.org/doc/html/rfc6762#section-8.1 --- tests/test_core.py | 15 +++++++++ tests/test_dns.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ zeroconf/_core.py | 11 ++++++- zeroconf/_dns.py | 14 +++++--- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 6d7467abc..b8a5499be 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -272,3 +272,18 @@ def on_service_state_change(zeroconf, service_type, state_change, name): assert not notify_called zc.close() + + +def test_generate_service_query_set_qu_bit(): + """Test generate_service_query sets the QU bit.""" + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + type_ = "._hap._tcp.local." + registration_name = "this-host-is-not-used._hap._tcp.local." + info = r.ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + out = zeroconf_registrar.generate_service_query(info) + assert out.questions[0].unicast is True + zeroconf_registrar.close() diff --git a/tests/test_dns.py b/tests/test_dns.py index 0e51aa4c4..4cd720463 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -326,6 +326,85 @@ def test_many_questions(self): parsed2 = r.DNSIncoming(packets[1]) assert len(parsed2.questions) == 15 + def test_many_questions_with_many_known_answers(self): + """Test many questions and known answers get seperated into multiple packets.""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + questions = [] + for _ in range(30): + question = r.DNSQuestion(f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 30 + now = current_time_millis() + for _ in range(200): + known_answer = r.DNSPointer( + "myservice{i}_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + '123.local.', + ) + generated.add_answer_at_time(known_answer, now) + packets = generated.packets() + assert len(packets) == 3 + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL + assert len(packets[2]) <= const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 30 + assert len(parsed1.answers) == 88 + assert parsed1.flags & const._FLAGS_TC == const._FLAGS_TC + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert len(parsed2.answers) == 101 + assert parsed2.flags & const._FLAGS_TC == const._FLAGS_TC + parsed3 = r.DNSIncoming(packets[2]) + assert len(parsed3.questions) == 0 + assert len(parsed3.answers) == 11 + assert parsed3.flags & const._FLAGS_TC == 0 + + def test_massive_probe_packet_split(self): + """Test probe with many authorative answers.""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + questions = [] + for _ in range(30): + question = r.DNSQuestion( + f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE + ) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 30 + now = current_time_millis() + for _ in range(200): + authorative_answer = r.DNSPointer( + "myservice{i}_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + '123.local.', + ) + generated.add_authorative_answer(authorative_answer) + packets = generated.packets() + assert len(packets) == 3 + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL + assert len(packets[2]) <= const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert parsed1.questions[0].unicast is True + assert len(parsed1.questions) == 30 + assert parsed1.num_authorities == 88 + assert parsed1.flags & const._FLAGS_TC == const._FLAGS_TC + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert parsed2.num_authorities == 101 + assert parsed2.flags & const._FLAGS_TC == const._FLAGS_TC + parsed3 = r.DNSIncoming(packets[2]) + assert len(parsed3.questions) == 0 + assert parsed3.num_authorities == 11 + assert parsed3.flags & const._FLAGS_TC == 0 + def test_only_one_answer_can_by_large(self): """Test that only the first answer in each packet can be large. diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 3532318bb..37985cf38 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -55,6 +55,7 @@ _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, _CLASS_IN, + _CLASS_UNIQUE, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -399,7 +400,15 @@ def send_service_query(self, info: ServiceInfo) -> None: def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use """Generate a query to lookup a service.""" out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) - out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN)) + # https://datatracker.ietf.org/doc/html/rfc6762#section-8.1 + # Because of the mDNS multicast rate-limiting + # rules, the probes SHOULD be sent as "QU" questions with the unicast- + # response bit set, to allow a defending host to respond immediately + # via unicast, instead of potentially having to wait before replying + # via multicast. + # + # _CLASS_UNIQUE is the "QU" bit + out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN | _CLASS_UNIQUE)) out.add_authorative_answer(info.dns_pointer()) return out diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 91bb14dce..dcc809a1c 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -757,9 +757,16 @@ def write_question(self, question: DNSQuestion) -> bool: start_data_length, start_size = len(self.data), self.size self.write_name(question.name) self.write_short(question.type) - self.write_short(question.class_) + self.write_record_class(question) return self._check_data_limit_or_rollback(start_data_length, start_size) + def write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: + """Write out the record class including the unique/unicast (QU) bit.""" + if record.unique and self.multicast: + self.write_short(record.class_ | _CLASS_UNIQUE) + else: + self.write_short(record.class_) + def write_record(self, record: DNSRecord, now: float) -> bool: """Writes a record (answer, authoritative answer, additional) to the packet. Returns True on success, or False if we did not (either @@ -771,10 +778,7 @@ def write_record(self, record: DNSRecord, now: float) -> bool: start_data_length, start_size = len(self.data), self.size self.write_name(record.name) self.write_short(record.type) - if record.unique and self.multicast: - self.write_short(record.class_ | _CLASS_UNIQUE) - else: - self.write_short(record.class_) + self.write_record_class(record) if now == 0: self.write_int(record.ttl) else: From b7d867878153fa600053869265260992e5462b2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 09:35:24 -1000 Subject: [PATCH 0335/1433] Make DNSRecords hashable (#611) - Allows storing them in a set for de-duplication - Needed to be able to check for duplicates to solve https://github.com/jstasiak/python-zeroconf/issues/604 --- tests/test_dns.py | 179 ++++++++++++++++++++++++++++++++++++++++++++++ zeroconf/_dns.py | 39 +++++++--- 2 files changed, 209 insertions(+), 9 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 4cd720463..3f46171c8 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -851,3 +851,182 @@ def test_qu_packet_parser(): parsed = DNSIncoming(qu_packet) assert parsed.questions[0].unicast is True assert ",QU," in str(parsed.questions[0]) + + +def test_dns_record_hashablity_does_not_consider_ttl(): + """Test DNSRecord are hashable.""" + + # Verify the TTL is not considered in the hash + record1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b'same') + record2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') + + record_set = set([record1, record2]) + assert len(record_set) == 1 + + record_set.add(record1) + assert len(record_set) == 1 + + record3_dupe = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') + + record_set.add(record3_dupe) + assert len(record_set) == 1 + + +def test_dns_address_record_hashablity(): + """Test DNSAddress are hashable.""" + address1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'a') + address2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'b') + address3 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'c') + address4 = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 1, b'c') + + record_set = set([address1, address2, address3, address4]) + assert len(record_set) == 4 + + record_set.add(address1) + assert len(record_set) == 4 + + address3_dupe = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'c') + + record_set.add(address3_dupe) + assert len(record_set) == 4 + + # Verify we can remove records + additional_set = set([address1, address2]) + record_set -= additional_set + assert record_set == set([address3, address4]) + + +def test_dns_hinfo_record_hashablity(): + """Test DNSHinfo are hashable.""" + hinfo1 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu1', 'os') + hinfo2 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') + + record_set = set([hinfo1, hinfo2]) + assert len(record_set) == 2 + + record_set.add(hinfo1) + assert len(record_set) == 2 + + hinfo2_dupe = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') + + record_set.add(hinfo2_dupe) + assert len(record_set) == 2 + + +def test_dns_pointer_record_hashablity(): + """Test DNSPointer are hashable.""" + ptr1 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') + ptr2 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') + + record_set = set([ptr1, ptr2]) + assert len(record_set) == 2 + + record_set.add(ptr1) + assert len(record_set) == 2 + + ptr2_dupe = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') + + record_set.add(ptr2_dupe) + assert len(record_set) == 2 + + +def test_dns_text_record_hashablity(): + """Test DNSText are hashable.""" + text1 = r.DNSText('irrelevant', 0, 0, 0, b'12345678901') + text2 = r.DNSText('irrelevant', 1, 0, 0, b'12345678901') + text3 = r.DNSText('irrelevant', 0, 1, 0, b'12345678901') + text4 = r.DNSText('irrelevant', 0, 0, 1, b'12345678901') + text5 = r.DNSText('irrelevant', 0, 0, 0, b'ABCDEFGHIJK') + + record_set = set([text1, text2, text3, text4, text5]) + assert len(record_set) == 5 + + record_set.add(text1) + assert len(record_set) == 5 + + text1_dupe = r.DNSText('irrelevant', 0, 0, 0, b'12345678901') + + record_set.add(text1_dupe) + assert len(record_set) == 5 + + +def test_dns_text_record_hashablity(): + """Test DNSText are hashable.""" + text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') + text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') + text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') + + record_set = set([text1, text2, text3, text4]) + + assert len(record_set) == 4 + + record_set.add(text1) + assert len(record_set) == 4 + + text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + + record_set.add(text1_dupe) + assert len(record_set) == 4 + + +def test_dns_text_record_hashablity(): + """Test DNSText are hashable.""" + text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') + text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') + text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') + + record_set = set([text1, text2, text3, text4]) + + assert len(record_set) == 4 + + record_set.add(text1) + assert len(record_set) == 4 + + text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + + record_set.add(text1_dupe) + assert len(record_set) == 4 + + +def test_dns_text_record_hashablity(): + """Test DNSText are hashable.""" + text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') + text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') + text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') + + record_set = set([text1, text2, text3, text4]) + + assert len(record_set) == 4 + + record_set.add(text1) + assert len(record_set) == 4 + + text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + + record_set.add(text1_dupe) + assert len(record_set) == 4 + + +def test_dns_service_record_hashablity(): + """Test DNSService are hashable.""" + srv1 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a') + srv2 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 1, 80, 'a') + srv3 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 81, 'a') + srv4 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab') + + record_set = set([srv1, srv2, srv3, srv4]) + + assert len(record_set) == 4 + + record_set.add(srv1) + assert len(record_set) == 4 + + srv1_dupe = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a' + ) + + record_set.add(srv1_dupe) + assert len(record_set) == 4 diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index dcc809a1c..8eb62593f 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -70,6 +70,10 @@ def __init__(self, name: str, type_: int, class_: int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 + def _entry_tuple(self) -> Tuple[str, int, int]: + """Entry Tuple for DNSEntry.""" + return (self.key, self.type, self.class_) + def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" return ( @@ -105,9 +109,6 @@ class DNSQuestion(DNSEntry): """A DNS question entry""" - def __init__(self, name: str, type_: int, class_: int) -> None: - DNSEntry.__init__(self, name, type_, class_) - def answered_by(self, rec: 'DNSRecord') -> bool: """Returns true if the question is answered by the record""" return ( @@ -141,7 +142,7 @@ class DNSRecord(DNSEntry): # TODO: Switch to just int ttl def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) -> None: - DNSEntry.__init__(self, name, type_, class_) + super().__init__(name, type_, class_) self.ttl = ttl self.created = current_time_millis() self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) @@ -205,7 +206,7 @@ class DNSAddress(DNSRecord): """A DNS address record""" def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl) self.address = address def write(self, out: 'DNSOutgoing') -> None: @@ -218,6 +219,10 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address ) + def __hash__(self) -> int: + """Hash to compare like DNSAddresses.""" + return hash((*self._entry_tuple(), self.address)) + def __repr__(self) -> str: """String representation""" try: @@ -235,7 +240,7 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" def __init__(self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl) self.cpu = cpu self.os = os @@ -253,6 +258,10 @@ def __eq__(self, other: Any) -> bool: and self.os == other.os ) + def __hash__(self) -> int: + """Hash to compare like DNSHinfo.""" + return hash((*self._entry_tuple(), self.cpu, self.os)) + def __repr__(self) -> str: """String representation""" return self.to_string(self.cpu + " " + self.os) @@ -263,7 +272,7 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl) self.alias = alias def write(self, out: 'DNSOutgoing') -> None: @@ -274,6 +283,10 @@ def __eq__(self, other: Any) -> bool: """Tests equality on alias""" return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) + def __hash__(self) -> int: + """Hash to compare like DNSPointer.""" + return hash((*self._entry_tuple(), self.alias)) + def __repr__(self) -> str: """String representation""" return self.to_string(self.alias) @@ -285,13 +298,17 @@ class DNSText(DNSRecord): def __init__(self, name: str, type_: int, class_: int, ttl: int, text: bytes) -> None: assert isinstance(text, (bytes, type(None))) - DNSRecord.__init__(self, name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl) self.text = text def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_string(self.text) + def __hash__(self) -> int: + """Hash to compare like DNSText.""" + return hash((*self._entry_tuple(), self.text)) + def __eq__(self, other: Any) -> bool: """Tests equality on text""" return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) @@ -318,7 +335,7 @@ def __init__( port: int, server: str, ) -> None: - DNSRecord.__init__(self, name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl) self.priority = priority self.weight = weight self.port = port @@ -342,6 +359,10 @@ def __eq__(self, other: Any) -> bool: and DNSEntry.__eq__(self, other) ) + def __hash__(self) -> int: + """Hash to compare like DNSService.""" + return hash((*self._entry_tuple(), self.priority, self.weight, self.port, self.server)) + def __repr__(self) -> str: """String representation""" return self.to_string("%s:%s" % (self.server, self.port)) From aea2c8ab24d4be19b34f407c854241e0d73d0525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 11:30:55 -1000 Subject: [PATCH 0336/1433] Add the ability for ServiceInfo.dns_addresses to filter by address type (#612) --- tests/test_services.py | 22 +++++++++++++++++++++- zeroconf/_services/__init__.py | 6 ++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index c78b9f9c8..e2aa93f0f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -11,11 +11,12 @@ import os import unittest from threading import Event +from typing import List import pytest import zeroconf as r -from zeroconf import const +from zeroconf import DNSAddress, const import zeroconf._services as s from zeroconf import Zeroconf from zeroconf._services import ( @@ -1170,3 +1171,22 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.remove_listener(listener) zc.close() + + +def test_filter_address_by_type_from_service_info(): + """Verify dns_addresses can filter by ipversion.""" + desc = {'path': '/~paulsm/'} + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + registration_name = "%s.%s" % (name, type_) + ipv4 = socket.inet_aton("10.0.1.2") + ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) + + def dns_addresses_to_addresses(dns_address: List[DNSAddress]): + return [address.address for address in dns_address] + + assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ipv4, ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 09aa4c737..f6092aae9 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -655,7 +655,9 @@ def _process_record(self, record: DNSRecord, now: float) -> None: if record.key == self.key: self._set_text(record.text) - def dns_addresses(self, override_ttl: Optional[int] = None) -> List[DNSAddress]: + def dns_addresses( + self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All + ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" return [ DNSAddress( @@ -665,7 +667,7 @@ def dns_addresses(self, override_ttl: Optional[int] = None) -> List[DNSAddress]: override_ttl if override_ttl is not None else self.host_ttl, address, ) - for address in self._addresses + for address in self.addresses_by_version(version) ] def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: From 219aa3e54c944b2935c9a40cc15de19284aded3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 12:07:28 -1000 Subject: [PATCH 0337/1433] Avoid including additionals when the answer is suppressed by known-answer supression (#614) --- tests/test_handlers.py | 112 ++++++++++++++++++++++++++++++++++++++++- zeroconf/_handlers.py | 11 +++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ea7ab589a..379d5e8e2 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -12,7 +12,7 @@ import unittest.mock import zeroconf as r -from zeroconf import ServiceInfo, Zeroconf +from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const from . import _clear_cache @@ -292,3 +292,113 @@ def test_unicast_response(): # unregister zc.unregister_service(info) zc.close() + + +def test_known_answer_supression(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knownservice._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.register_service(info) + + now = current_time_millis() + + # Test PTR supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is not None and multicast_out.answers + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + generated.add_answer_at_time(info.dns_pointer(), now) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + # If the answer is suppressed, the additional should be suppresed as well + assert not multicast_out or not multicast_out.answers + + # Test A supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is not None and multicast_out.answers + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) + generated.add_question(question) + for dns_address in info.dns_addresses(): + generated.add_answer_at_time(dns_address, now) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert not multicast_out or not multicast_out.answers + + # Test SRV supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is not None and multicast_out.answers + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) + generated.add_question(question) + generated.add_answer_at_time(info.dns_service(), now) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + # If the answer is suppressed, the additional should be suppresed as well + assert not multicast_out or not multicast_out.answers + + # Test TXT supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is not None and multicast_out.answers + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) + generated.add_question(question) + generated.add_answer_at_time(info.dns_text(), now) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert not multicast_out or not multicast_out.answers + + # unregister + zc.unregister_service(info) + zc.close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 65eb472b5..dc29bbf63 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -75,6 +75,9 @@ def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgo def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: """Answer a PTR query.""" for service in self.registry.get_infos_type(question.name): + dns_pointer = service.dns_pointer() + if dns_pointer.suppressed_by(msg): + continue out.add_answer(msg, service.dns_pointer()) # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. @@ -103,8 +106,12 @@ def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DN if question.type in (_TYPE_TXT, _TYPE_ANY): out.add_answer(msg, service.dns_text()) if question.type == _TYPE_SRV: - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) + dns_service = service.dns_service() + if not dns_service.suppressed_by(msg): + # Add recommended additional answers according to + # https://datatracker.ietf.org/doc/html/rfc6763#section-12.2 + for dns_address in service.dns_addresses(): + out.add_additional_answer(dns_address) def response( # pylint: disable=unused-argument self, msg: DNSIncoming, addr: Optional[str], port: int From c828c7555ed1fb82ff95ed578262d1553f19d903 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 12:53:46 -1000 Subject: [PATCH 0338/1433] Breakout the query response handler into its own class (#615) --- tests/test_handlers.py | 83 +++++++++++++++- tests/test_services.py | 2 +- zeroconf/_handlers.py | 217 +++++++++++++++++++++++++++-------------- 3 files changed, 222 insertions(+), 80 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 379d5e8e2..e8efefeb2 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -96,10 +96,14 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet( - zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] - ) - assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 + multicast_out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[ + 1 + ] + _process_outgoing_packet(multicast_out) + + # The additonals should all be suppresed since they are all in the answers section + # + assert nbr_answers == 4 and nbr_additionals == 3 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -132,7 +136,7 @@ def _process_outgoing_packet(out): _process_outgoing_packet( zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] ) - assert nbr_answers == 4 and nbr_additionals == 4 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 3 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -402,3 +406,72 @@ def test_known_answer_supression(): # unregister zc.unregister_service(info) zc.close() + + +def test_known_answer_supression_service_type_enumeration_query(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knownservice._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.register_service(info) + + type_2 = "_knownservice2._tcp.local." + name = "knownname" + registration_name2 = "%s.%s" % (name, type_2) + desc = {'path': '/~paulsm/'} + server_name2 = "ash-3.local." + info = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.register_service(info) + now = current_time_millis() + + # Test PTR supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is not None and multicast_out.answers + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + generated.add_answer_at_time( + r.DNSPointer( + const._SERVICE_TYPE_ENUMERATION_NAME, + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + type_, + ), + now, + ) + generated.add_answer_at_time( + r.DNSPointer( + const._SERVICE_TYPE_ENUMERATION_NAME, + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + type_2, + ), + now, + ) + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert not multicast_out or not multicast_out.answers + + # unregister + zc.unregister_service(info) + zc.close() diff --git a/tests/test_services.py b/tests/test_services.py index e2aa93f0f..6677bfcb4 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -623,7 +623,7 @@ def update_service(self, zeroconf, type, name): assert info.properties[b'prop_true'] == b'1' assert info.properties[b'prop_false'] == b'0' assert info.addresses == addresses[:1] # no V6 by default - assert info.addresses_by_version(r.IPVersion.All) == addresses + assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) cached_info = ServiceInfo(type_, registration_name) cached_info.load_from_cache(zeroconf_browser) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index dc29bbf63..4884977ca 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -20,8 +20,9 @@ USA """ +import enum import itertools -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from ._logger import log @@ -48,97 +49,165 @@ from ._core import Zeroconf # pylint: disable=cyclic-import +@enum.unique +class RecordSetKeys(enum.Enum): + Answers = 1 + Additionals = 2 + + +# Switch to a TypedDict once Python 3.8 is the minimum supported version +_RecordSetType = Dict[RecordSetKeys, Set[DNSRecord]] + + +class _QueryResponse: + """A pair for unicast and multicast DNSOutgoing responses.""" + + def __init__(self, msg: DNSIncoming, ucast_source: bool) -> None: + """Build a query response.""" + self._msg = msg + self._ucast_source = ucast_source + self._is_probe = msg.num_authorities > 0 + self._now = current_time_millis() + self._ucast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} + self._mcast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} + + def add_ucast_question_response(self, answers: Set[DNSRecord], additionals: Set[DNSRecord]) -> None: + """Generate a response to a unicast query.""" + self._ucast[RecordSetKeys.Answers].update(answers) + self._ucast[RecordSetKeys.Additionals].update(additionals) + + def add_mcast_question_response(self, answers: Set[DNSRecord], additionals: Set[DNSRecord]) -> None: + """Generate a response to a multicast query.""" + self._mcast[RecordSetKeys.Answers].update(answers) + self._mcast[RecordSetKeys.Additionals].update(additionals) + + def outgoing_unicast(self) -> Optional[DNSOutgoing]: + """Build the outgoing unicast response.""" + ucastout = self._construct_outgoing_from_record_set(self._ucast, False) + # Adding the questions back when the source is + # unicast (not MDNS port) is legacy behavior + # Is this correct? + if ucastout and self._ucast_source: + for question in self._msg.questions: + ucastout.add_question(question) + return ucastout + + def outgoing_multicast(self) -> Optional[DNSOutgoing]: + """Build the outgoing multicast response.""" + return self._construct_outgoing_from_record_set(self._mcast, True) + + def _construct_outgoing_from_record_set( + self, rrset: _RecordSetType, multicast: bool + ) -> Optional[DNSOutgoing]: + """Add answers and additionals to a DNSOutgoing.""" + if not rrset[RecordSetKeys.Answers] and not rrset[RecordSetKeys.Additionals]: + return None + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=multicast, id_=self._msg.id) + for answer in rrset[RecordSetKeys.Answers]: + out.add_answer_at_time(answer, 0) + for additional in rrset[RecordSetKeys.Additionals]: + out.add_additional_answer(additional) + return out + + class QueryHandler: """Query the ServiceRegistry.""" - def __init__(self, registry: ServiceRegistry): + def __init__(self, registry: ServiceRegistry) -> None: """Init the query handler.""" self.registry = registry - def _answer_service_type_enumeration_query(self, msg: DNSIncoming, out: DNSOutgoing) -> None: + def _answer_service_type_enumeration_query( + self, + msg: DNSIncoming, + ) -> Set[DNSRecord]: """Provide an answer to a service type enumeration query. https://datatracker.ietf.org/doc/html/rfc6763#section-9 """ - for stype in self.registry.get_types(): - out.add_answer( - msg, - DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_PTR, - _CLASS_IN, - _DNS_OTHER_TTL, - stype, - ), - ) - - def _answer_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a PTR query.""" - for service in self.registry.get_infos_type(question.name): - dns_pointer = service.dns_pointer() - if dns_pointer.suppressed_by(msg): - continue - out.add_answer(msg, service.dns_pointer()) + records: Set[DNSRecord] = set( + DNSPointer(_SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype) + for stype in self.registry.get_types() + ) + records -= set(dns_pointer for dns_pointer in records if dns_pointer.suppressed_by(msg)) + return records + + def _add_pointer_answers( + self, name: str, msg: DNSIncoming, answers: Set[DNSRecord], additionals: Set[DNSRecord] + ) -> None: + """Answer PTR/ANY question.""" + for service in self.registry.get_infos_type(name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. - out.add_additional_answer(service.dns_service()) - out.add_additional_answer(service.dns_text()) + dns_pointer = service.dns_pointer() + if not dns_pointer.suppressed_by(msg): + answers.add(service.dns_pointer()) + additionals.add(service.dns_service()) + additionals.add(service.dns_text()) + additionals.update(service.dns_addresses()) + + def _add_address_answers(self, name: str, msg: DNSIncoming, answers: Set[DNSRecord]) -> None: + """Answer address question.""" + for service in self.registry.get_infos_server(name): for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) - - def _answer_non_ptr_query(self, msg: DNSIncoming, out: DNSOutgoing, question: DNSQuestion) -> None: - """Answer a query any query other then PTR. - - Add answer(s) for A, AAAA, SRV, or TXT queries. - """ - # Answer A record queries for any service addresses we know - if question.type in (_TYPE_A, _TYPE_ANY): - for service in self.registry.get_infos_server(question.name): - for dns_address in service.dns_addresses(): - out.add_answer(msg, dns_address) - - service = self.registry.get_info_name(question.name) # type: ignore - if service is None: - return - - if question.type in (_TYPE_SRV, _TYPE_ANY): - out.add_answer(msg, service.dns_service()) - if question.type in (_TYPE_TXT, _TYPE_ANY): - out.add_answer(msg, service.dns_text()) - if question.type == _TYPE_SRV: - dns_service = service.dns_service() - if not dns_service.suppressed_by(msg): - # Add recommended additional answers according to - # https://datatracker.ietf.org/doc/html/rfc6763#section-12.2 - for dns_address in service.dns_addresses(): - out.add_additional_answer(dns_address) + if not dns_address.suppressed_by(msg): + answers.add(dns_address) + + def _answer_question( + self, msg: DNSIncoming, question: DNSQuestion + ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: + answers: Set[DNSRecord] = set() + additionals: Set[DNSRecord] = set() + type_ = question.type + + if type_ == _TYPE_PTR: + self._add_pointer_answers(question.name, msg, answers, additionals) + + if type_ in (_TYPE_A, _TYPE_ANY): + self._add_address_answers(question.name, msg, answers) + + if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): + service = self.registry.get_info_name(question.name) # type: ignore + if service is not None: + if type_ in (_TYPE_SRV, _TYPE_ANY): + dns_service = service.dns_service() + if not dns_service.suppressed_by(msg): + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.2. + answers.add(service.dns_service()) + additionals.update(service.dns_addresses()) + if type_ in (_TYPE_TXT, _TYPE_ANY): + dns_text = service.dns_text() + if not dns_text.suppressed_by(msg): + answers.add(service.dns_text()) + + return answers, additionals + + def _answer_any_question( + self, msg: DNSIncoming, question: DNSQuestion + ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: + if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + empty_additionals: Set[DNSRecord] = set() + return self._answer_service_type_enumeration_query(msg), empty_additionals + + return self._answer_question(msg, question) def response( # pylint: disable=unused-argument self, msg: DNSIncoming, addr: Optional[str], port: int ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: """Deal with incoming query packets. Provides a response if possible.""" - unicast_out = None - multicast_out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, id_=msg.id) - outputs = [multicast_out] - - if port != _MDNS_PORT: - unicast_out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=msg.id) - outputs.append(unicast_out) - for question in msg.questions: - unicast_out.add_question(question) - - for out in outputs: - for question in msg.questions: - if question.type == _TYPE_PTR: - if question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._answer_service_type_enumeration_query(msg, out) - else: - self._answer_ptr_query(msg, out, question) - continue - - self._answer_non_ptr_query(msg, out, question) - - return unicast_out, multicast_out + ucast_source = port != _MDNS_PORT + query_res = _QueryResponse(msg, ucast_source) + + for question in msg.questions: + all_answers = self._answer_any_question(msg, question) + if ucast_source: + query_res.add_ucast_question_response(*all_answers) + # We always multicast as well even if its a unicast + # source as long as we haven't done it recently (75% of ttl) + query_res.add_mcast_question_response(*all_answers) + + return query_res.outgoing_unicast(), query_res.outgoing_multicast() class RecordManager: From 0100c08c5a3fb90d0795cf57f0bd3e11c7a94a0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 13:10:20 -1000 Subject: [PATCH 0339/1433] Fix queries for AAAA records (#616) --- tests/test_handlers.py | 24 ++++++++++++++++++++++++ zeroconf/_handlers.py | 13 ++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e8efefeb2..e1eec79b8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -256,6 +256,30 @@ def test_ptr_optimization(): zc.close() +def test_aaaa_query(): + """Test that queries for AAAA records work.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knownservice._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + zc.register_service(info) + + _clear_cache(zc) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + _, multicast_out = zc.query_handler.response(r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT) + assert multicast_out.answers[0][0].address == ipv6_address + # unregister + zc.unregister_service(info) + zc.close() + + def test_unicast_response(): """Ensure we send a unicast response when the source port is not the MDNS port.""" # instantiate a zeroconf instance diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 4884977ca..48d6529ae 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -28,6 +28,7 @@ from ._logger import log from ._services import RecordUpdateListener from ._services.registry import ServiceRegistry +from ._utils.net import IPVersion from ._utils.time import current_time_millis from .const import ( _CLASS_IN, @@ -37,12 +38,14 @@ _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_A, + _TYPE_AAAA, _TYPE_ANY, _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, ) +_TYPE_TO_IP_VERSION = {_TYPE_A: IPVersion.V4Only, _TYPE_AAAA: IPVersion.V6Only, _TYPE_ANY: IPVersion.All} if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 @@ -146,10 +149,10 @@ def _add_pointer_answers( additionals.add(service.dns_text()) additionals.update(service.dns_addresses()) - def _add_address_answers(self, name: str, msg: DNSIncoming, answers: Set[DNSRecord]) -> None: - """Answer address question.""" + def _add_address_answers(self, name: str, msg: DNSIncoming, answers: Set[DNSRecord], type_: int) -> None: + """Answer A/AAAA/ANY question.""" for service in self.registry.get_infos_server(name): - for dns_address in service.dns_addresses(): + for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_]): if not dns_address.suppressed_by(msg): answers.add(dns_address) @@ -163,8 +166,8 @@ def _answer_question( if type_ == _TYPE_PTR: self._add_pointer_answers(question.name, msg, answers, additionals) - if type_ in (_TYPE_A, _TYPE_ANY): - self._add_address_answers(question.name, msg, answers) + if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): + self._add_address_answers(question.name, msg, answers, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): service = self.registry.get_info_name(question.name) # type: ignore From 427b7285269984cbb6f28c87a8bf8f864a5e15d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 13:17:42 -1000 Subject: [PATCH 0340/1433] Suppress additionals when they are already in the answers section (#617) --- tests/test_handlers.py | 4 ++-- zeroconf/_handlers.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e1eec79b8..5eef3a8de 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -103,7 +103,7 @@ def _process_outgoing_packet(out): # The additonals should all be suppresed since they are all in the answers section # - assert nbr_answers == 4 and nbr_additionals == 3 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -136,7 +136,7 @@ def _process_outgoing_packet(out): _process_outgoing_packet( zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] ) - assert nbr_answers == 4 and nbr_additionals == 3 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 48d6529ae..824e9ce5d 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -105,6 +105,10 @@ def _construct_outgoing_from_record_set( """Add answers and additionals to a DNSOutgoing.""" if not rrset[RecordSetKeys.Answers] and not rrset[RecordSetKeys.Additionals]: return None + + # Suppress any additionals that are already in answers + rrset[RecordSetKeys.Additionals] -= rrset[RecordSetKeys.Answers] + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=multicast, id_=self._msg.id) for answer in rrset[RecordSetKeys.Answers]: out.add_answer_at_time(answer, 0) From b6365aa1f889a3045aa185f67354de622bd7ebd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 13:30:44 -1000 Subject: [PATCH 0341/1433] Ensure matching PTR queries are returned with the ANY query (#618) Fixes #464 --- tests/test_handlers.py | 25 +++++++++++++++++++++++++ zeroconf/_handlers.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 5eef3a8de..71a28259c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -256,6 +256,31 @@ def test_ptr_optimization(): zc.close() +def test_any_query_for_ptr(): + """Test that queries for ANY will return PTR records.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knownservice._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + zc.register_service(info) + + _clear_cache(zc) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + _, multicast_out = zc.query_handler.response(r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT) + assert multicast_out.answers[0][0].name == type_ + assert multicast_out.answers[0][0].alias == registration_name + # unregister + zc.unregister_service(info) + zc.close() + + def test_aaaa_query(): """Test that queries for AAAA records work.""" zc = Zeroconf(interfaces=['127.0.0.1']) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 824e9ce5d..590a6c967 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -167,7 +167,7 @@ def _answer_question( additionals: Set[DNSRecord] = set() type_ = question.type - if type_ == _TYPE_PTR: + if type_ in (_TYPE_PTR, _TYPE_ANY): self._add_pointer_answers(question.name, msg, answers, additionals) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): From 0e644ad650627024c7a3f926a86f7d9ecc66e591 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 14:15:24 -1000 Subject: [PATCH 0342/1433] Protect the network against excessive packet flooding (#619) --- tests/test_handlers.py | 23 ++++++++++++++++++----- zeroconf/_core.py | 2 +- zeroconf/_handlers.py | 24 +++++++++++++++++++++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 71a28259c..e0eeb353d 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -15,7 +15,7 @@ from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const -from . import _clear_cache +from . import _clear_cache, _inject_response log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -227,7 +227,19 @@ def test_ptr_optimization(): # register zc.register_service(info) - # query + # Verify we won't respond for 1s with the same multicast + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + + # Clear the cache to allow responding again + _clear_cache(zc) + + # Verify we will now respond query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( @@ -320,6 +332,7 @@ def test_unicast_response(): ) # register zc.register_service(info) + _clear_cache(zc) # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) @@ -329,8 +342,8 @@ def test_unicast_response(): assert out.id == query.id has_srv = has_txt = has_a = False nbr_additionals = 0 - nbr_answers = len(multicast_out.answers) - nbr_authorities = len(multicast_out.authorities) + nbr_answers = len(out.answers) + nbr_authorities = len(out.authorities) for answer in out.additionals: nbr_additionals += 1 if answer.type == const._TYPE_SRV: @@ -360,7 +373,7 @@ def test_known_answer_supression(): zc.register_service(info) now = current_time_millis() - + _clear_cache(zc) # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 37985cf38..1cc6bdce5 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -264,8 +264,8 @@ def __init__( self._notify_listeners: List[NotifyListener] = [] self.browsers: Dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() - self.query_handler = QueryHandler(self.registry) self.cache = DNSCache() + self.query_handler = QueryHandler(self.registry, self.cache) self.record_manager = RecordManager(self) self.condition = threading.Condition() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 590a6c967..25ea8aa56 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -24,6 +24,7 @@ import itertools from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from ._cache import DNSCache from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from ._logger import log from ._services import RecordUpdateListener @@ -65,12 +66,13 @@ class RecordSetKeys(enum.Enum): class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" - def __init__(self, msg: DNSIncoming, ucast_source: bool) -> None: + def __init__(self, cache: DNSCache, msg: DNSIncoming, ucast_source: bool) -> None: """Build a query response.""" self._msg = msg self._ucast_source = ucast_source self._is_probe = msg.num_authorities > 0 self._now = current_time_millis() + self._cache = cache self._ucast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} self._mcast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} @@ -97,6 +99,9 @@ def outgoing_unicast(self) -> Optional[DNSOutgoing]: def outgoing_multicast(self) -> Optional[DNSOutgoing]: """Build the outgoing multicast response.""" + if not self._is_probe: + self._suppress_mcasts_from_last_second(self._mcast[RecordSetKeys.Answers]) + self._suppress_mcasts_from_last_second(self._mcast[RecordSetKeys.Additionals]) return self._construct_outgoing_from_record_set(self._mcast, True) def _construct_outgoing_from_record_set( @@ -116,13 +121,26 @@ def _construct_outgoing_from_record_set( out.add_additional_answer(additional) return out + def _suppress_mcasts_from_last_second(self, records: Set[DNSRecord]) -> None: + """Remove any records that were already sent in the last second.""" + records -= set(record for record in records if self._has_mcast_record_in_last_second(record)) + + def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: + """Remove answers that were just broadcast + Protect the network against excessive packet flooding + https://datatracker.ietf.org/doc/html/rfc6762#section-14 + """ + maybe_entry = self._cache.get(record) + return bool(maybe_entry and self._now - maybe_entry.created < 1000) + class QueryHandler: """Query the ServiceRegistry.""" - def __init__(self, registry: ServiceRegistry) -> None: + def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: """Init the query handler.""" self.registry = registry + self.cache = cache def _answer_service_type_enumeration_query( self, @@ -204,7 +222,7 @@ def response( # pylint: disable=unused-argument ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: """Deal with incoming query packets. Provides a response if possible.""" ucast_source = port != _MDNS_PORT - query_res = _QueryResponse(msg, ucast_source) + query_res = _QueryResponse(self.cache, msg, ucast_source) for question in msg.questions: all_answers = self._answer_any_question(msg, question) From 1f36754f3964738e496a1da9c24380e204aaff01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 15:00:23 -1000 Subject: [PATCH 0343/1433] Add is_recent property to DNSRecord (#620) - RFC 6762 defines recent as not multicast within one quarter of its TTL https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 --- tests/test_dns.py | 23 +++++++++++++++++++++++ zeroconf/_dns.py | 7 +++++++ zeroconf/const.py | 1 + 3 files changed, 31 insertions(+) diff --git a/tests/test_dns.py b/tests/test_dns.py index 3f46171c8..18bffcce2 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -137,6 +137,29 @@ def test_dns_outgoing_repr(self): dns_outgoing = r.DNSOutgoing(const._FLAGS_QR_QUERY) repr(dns_outgoing) + def test_dns_record_is_expired(self): + record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + now = current_time_millis() + assert record.is_expired(now) is False + assert record.is_expired(now + (8 / 2 * 1000)) is False + assert record.is_expired(now + (8 * 1000)) is True + + def test_dns_record_is_stale(self): + record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + now = current_time_millis() + assert record.is_stale(now) is False + assert record.is_stale(now + (8 / 4.1 * 1000)) is False + assert record.is_stale(now + (8 / 2 * 1000)) is True + assert record.is_stale(now + (8 * 1000)) is True + + def test_dns_record_is_recent(self): + now = current_time_millis() + record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + assert record.is_recent(now + (8 / 4.1 * 1000)) is True + assert record.is_recent(now + (8 / 3 * 1000)) is False + assert record.is_recent(now + (8 / 2 * 1000)) is False + assert record.is_recent(now + (8 * 1000)) is False + class PacketGeneration(unittest.TestCase): def test_parse_own_packet_simple(self): diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 8eb62593f..dcb8c9a38 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -42,6 +42,7 @@ _FLAGS_TC, _MAX_MSG_ABSOLUTE, _MAX_MSG_TYPICAL, + _RECENT_TIME_PERCENT, _TYPES, _TYPE_A, _TYPE_AAAA, @@ -147,6 +148,7 @@ def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) - self.created = current_time_millis() self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) + self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" @@ -183,6 +185,10 @@ def is_stale(self, now: float) -> bool: """Returns true if this record is at least half way expired.""" return self._stale_time <= now + def is_recent(self, now: float) -> bool: + """Returns true if the record more than one quarter of its TTL remaining.""" + return self._recent_time > now + def reset_ttl(self, other: 'DNSRecord') -> None: """Sets this record's TTL and created time to that of another record.""" @@ -190,6 +196,7 @@ def reset_ttl(self, other: 'DNSRecord') -> None: self.ttl = other.ttl self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) + self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use """Abstract method""" diff --git a/zeroconf/const.py b/zeroconf/const.py index 365fee098..3ec124274 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -130,6 +130,7 @@ _EXPIRE_FULL_TIME_PERCENT = 100 _EXPIRE_STALE_TIME_PERCENT = 50 _EXPIRE_REFRESH_TIME_PERCENT = 75 +_RECENT_TIME_PERCENT = 25 _LOCAL_TRAILER = '.local.' _TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' From 9a32db8582588e4bf812fd5670a7e61c50631a2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 15:08:48 -1000 Subject: [PATCH 0344/1433] Add support for handling QU questions (#621) - Implements RFC 6762 sec 5.4: Questions Requesting Unicast Responses https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 --- tests/test_handlers.py | 101 +++++++++++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 47 +++++++++++++++++-- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e0eeb353d..71f6aff22 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -360,6 +360,107 @@ def test_unicast_response(): zc.close() +def test_qu_response(): + """Handle multicast incoming with the QU bit set.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + other_type_ = "_notthesame._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + registration_name2 = "%s.%s" % (name, other_type_) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = ServiceInfo( + other_type_, + registration_name2, + 80, + 0, + 0, + desc, + "ash-other.local.", + addresses=[socket.inet_aton("10.0.4.2")], + ) + # register + zc.register_service(info) + + def _validate_complete_response(query, out): + assert out.id == query.id + has_srv = has_txt = has_a = False + nbr_additionals = 0 + nbr_answers = len(out.answers) + nbr_authorities = len(out.authorities) + for answer in out.additionals: + nbr_additionals += 1 + if answer.type == const._TYPE_SRV: + has_srv = True + elif answer.type == const._TYPE_TXT: + has_txt = True + elif answer.type == const._TYPE_A: + has_a = True + assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert has_srv and has_txt and has_a + + # With QU should respond to only unicast when the answer has been recently multicast + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out is None + _validate_complete_response(query, unicast_out) + + _clear_cache(zc) + # With QU should respond to only multicast since the response hasn't been seen since 75% of the ttl + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + _validate_complete_response(query, multicast_out) + + # With QU set and an authorative answer (probe) should respond to both unitcast and multicast since the response hasn't been seen since 75% of the ttl + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + query.add_authorative_answer(info2.dns_pointer()) + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + ) + _validate_complete_response(query, unicast_out) + _validate_complete_response(query, multicast_out) + + _inject_response(zc, r.DNSIncoming(multicast_out.packets()[0])) + # With the cache repopulated; should respond to only unicast when the answer has been recently multicast + query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + unicast_out, multicast_out = zc.query_handler.response( + r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out is None + _validate_complete_response(query, unicast_out) + # unregister + zc.unregister_service(info) + zc.close() + + def test_known_answer_supression(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_knownservice._tcp.local." diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 25ea8aa56..15a853b2a 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -76,6 +76,25 @@ def __init__(self, cache: DNSCache, msg: DNSIncoming, ucast_source: bool) -> Non self._ucast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} self._mcast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} + def add_qu_question_response( + self, + answers: Set[DNSRecord], + additionals: Set[DNSRecord], + ) -> None: + """Generate a response to a multicast QU query.""" + self._add_qu_question_response_to_target(answers, RecordSetKeys.Answers) + self._add_qu_question_response_to_target(additionals, RecordSetKeys.Additionals) + + def _add_qu_question_response_to_target(self, target: Set[DNSRecord], answer_type: RecordSetKeys) -> None: + """Add part of the QU response.""" + for record in target: + if self._is_probe: + self._ucast[answer_type].add(record) + if not self._has_mcast_within_one_quarter_ttl(record): + self._mcast[answer_type].add(record) + elif not self._is_probe: + self._ucast[answer_type].add(record) + def add_ucast_question_response(self, answers: Set[DNSRecord], additionals: Set[DNSRecord]) -> None: """Generate a response to a unicast query.""" self._ucast[RecordSetKeys.Answers].update(answers) @@ -119,8 +138,23 @@ def _construct_outgoing_from_record_set( out.add_answer_at_time(answer, 0) for additional in rrset[RecordSetKeys.Additionals]: out.add_additional_answer(additional) + return out + def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: + """Check to see if a record has been mcasted recently. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 + When receiving a question with the unicast-response bit set, a + responder SHOULD usually respond with a unicast packet directed back + to the querier. However, if the responder has not multicast that + record recently (within one quarter of its TTL), then the responder + SHOULD instead multicast the response so as to keep all the peer + caches up to date + """ + maybe_entry = self._cache.get(record) + return bool(maybe_entry and maybe_entry.is_recent(self._now)) + def _suppress_mcasts_from_last_second(self, records: Set[DNSRecord]) -> None: """Remove any records that were already sent in the last second.""" records -= set(record for record in records if self._has_mcast_record_in_last_second(record)) @@ -226,11 +260,14 @@ def response( # pylint: disable=unused-argument for question in msg.questions: all_answers = self._answer_any_question(msg, question) - if ucast_source: - query_res.add_ucast_question_response(*all_answers) - # We always multicast as well even if its a unicast - # source as long as we haven't done it recently (75% of ttl) - query_res.add_mcast_question_response(*all_answers) + if not ucast_source and question.unicast: + query_res.add_qu_question_response(*all_answers) + else: + if ucast_source: + query_res.add_ucast_question_response(*all_answers) + # We always multicast as well even if its a unicast + # source as long as we haven't done it recently (75% of ttl) + query_res.add_mcast_question_response(*all_answers) return query_res.outgoing_unicast(), query_res.outgoing_multicast() From 8f00cfca0e67dde6afda399da6984ed7d8f929df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 15:47:25 -1000 Subject: [PATCH 0345/1433] Replace select loop with asyncio loop (#504) --- tests/__init__.py | 8 +- tests/test_asyncio.py | 34 ------ tests/test_core.py | 3 +- tests/test_init.py | 30 +---- zeroconf/_core.py | 260 +++++++++++++++++++++++------------------- zeroconf/asyncio.py | 32 ------ 6 files changed, 154 insertions(+), 213 deletions(-) delete mode 100644 tests/test_asyncio.py delete mode 100644 zeroconf/asyncio.py diff --git a/tests/__init__.py b/tests/__init__.py index 420541d78..3439a0446 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,6 +20,7 @@ USA """ +import asyncio import socket from functools import lru_cache @@ -32,7 +33,12 @@ def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: """Inject a DNSIncoming response.""" - zc.handle_response(msg) + assert zc.loop is not None + + async def _wait_for_response(): + zc.handle_response(msg) + + asyncio.run_coroutine_threadsafe(_wait_for_response(), zc.loop).result() @lru_cache(maxsize=None) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py deleted file mode 100644 index bf4d887ea..000000000 --- a/tests/test_asyncio.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - - -"""Unit tests for asyncio.py.""" - -import pytest -import threading - -from zeroconf.asyncio import AsyncZeroconf - - -@pytest.fixture(autouse=True) -def verify_threads_ended(): - """Verify that the threads are not running after the test.""" - threads_before = frozenset(threading.enumerate()) - yield - threads_after = frozenset(threading.enumerate()) - non_executor_threads = frozenset( - [ - thread - for thread in threads_after - if "asyncio" not in thread.name and "ThreadPoolExecutor" not in thread.name - ] - ) - threads = non_executor_threads - threads_before - assert not threads - - -@pytest.mark.asyncio -async def test_async_basic_usage() -> None: - """Test we can create and close the instance.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - await aiozc.async_close() diff --git a/tests/test_core.py b/tests/test_core.py index b8a5499be..906a9508e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -46,8 +46,7 @@ def test_reaper(self): zeroconf.cache.add(record_with_1s_ttl) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) time.sleep(1) - with zeroconf.engine.condition: - zeroconf.engine._notify() + zeroconf.notify_all() time.sleep(0.1) entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) zeroconf.close() diff --git a/tests/test_init.py b/tests/test_init.py index 4710a994d..6ccb9cff2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -148,15 +148,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.send(out) assert mocked_log_warn.call_count == call_counts[0] - # force a receive of a packet - packet = out.packets()[0] - s = zc._respond_sockets[0] - # mock the zeroconf logger and check for the correct logging backoff call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count # force receive on oversized packet - s.sendto(packet, 0, (const._MDNS_ADDR, const._MDNS_PORT)) - s.sendto(packet, 0, (const._MDNS_ADDR, const._MDNS_PORT)) + zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) + zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) time.sleep(2.0) zeroconf.log.debug( 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts @@ -166,28 +162,6 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # close our zeroconf which will close the sockets zc.close() - # pop the big chunk off the end of the data and send on a closed socket - out.data.pop() - zc._GLOBAL_DONE = False - - # mock the zeroconf logger and check for the correct logging backoff - call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count - # send on a closed socket (force a socket error) - zc.send(out) - zeroconf.log.debug( - 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts - ) - assert mocked_log_warn.call_count > call_counts[0] - assert mocked_log_debug.call_count > call_counts[0] - zc.send(out) - zeroconf.log.debug( - 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts - ) - assert mocked_log_debug.call_count > call_counts[0] + 2 - - mocked_log_warn.stop() - mocked_log_debug.stop() - def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} info_service = ServiceInfo( diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1cc6bdce5..4668b51c0 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -20,13 +20,14 @@ USA """ +import asyncio import errno +import itertools import platform -import select import socket import threading from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, Type, Union, cast +from typing import Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion @@ -41,6 +42,7 @@ instance_name_from_service_info, ) from ._services.registry import ServiceRegistry +from ._utils.aio import get_running_loop from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -77,83 +79,84 @@ def notify_all(self) -> None: raise NotImplementedError() -class Engine(threading.Thread): +class AsyncEngine: + """An engine wraps sockets in the event loop.""" - """An engine wraps read access to sockets, allowing objects that - need to receive data from sockets to be called back when the - sockets are ready. - - A reader needs a handle_read() method, which is called when the socket - it is interested in is ready for reading. - - Writers are not implemented here, because we only send short - packets. - """ - - def __init__(self, zc: 'Zeroconf') -> None: - threading.Thread.__init__(self) - self.daemon = True - self.zc = zc - self.readers = {} # type: Dict[socket.socket, Listener] - self.timeout = 5 - self.condition = threading.Condition() - self.socketpair = socket.socketpair() - self._last_cache_cleanup = 0.0 - self.name = "zeroconf-Engine-%s" % (getattr(self, 'native_id', self.ident),) - - def run(self) -> None: + def __init__( + self, + zeroconf: 'Zeroconf', + listen_socket: Optional[socket.socket], + respond_sockets: List[socket.socket], + ) -> None: + self.loop: Optional[asyncio.AbstractEventLoop] = None + self.zc = zeroconf + self.readers: List[asyncio.DatagramTransport] = [] + self.senders: List[asyncio.DatagramTransport] = [] + self._listen_socket = listen_socket + self._respond_sockets = respond_sockets + self._cache_cleanup_task: Optional[asyncio.Task] = None + self._running_event: Optional[asyncio.Event] = None + + def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: + """Set up the instance.""" + self.loop = loop + self._running_event = asyncio.Event() + self.loop.create_task(self._async_setup(loop_thread_ready)) + + async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: + """Set up the instance.""" + assert self.loop is not None + await self._async_create_endpoints() + self._cache_cleanup_task = self.loop.create_task(self._async_cache_cleanup()) + assert self._running_event is not None + self._running_event.set() + if loop_thread_ready: + loop_thread_ready.set() + + async def async_wait_for_start(self) -> None: + """Wait for start up.""" + assert self._running_event is not None + await self._running_event.wait() + + async def _async_create_endpoints(self) -> None: + """Create endpoints to send and receive.""" + assert self.loop is not None + loop = self.loop + reader_sockets = [] + sender_sockets = [] + if self._listen_socket: + reader_sockets.append(self._listen_socket) + for s in self._respond_sockets: + if s not in reader_sockets: + reader_sockets.append(s) + sender_sockets.append(s) + + for s in reader_sockets: + transport, _ = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) + self.readers.append(cast(asyncio.DatagramTransport, transport)) + if s in sender_sockets: + self.senders.append(cast(asyncio.DatagramTransport, transport)) + + async def _async_cache_cleanup(self) -> None: + """Periodic cache cleanup.""" while not self.zc.done: - try: - rr, _wr, _er = select.select([*self.readers.keys(), self.socketpair[0]], [], [], self.timeout) - - if self.zc.done: - return - - for socket_ in rr: - reader = self.readers.get(socket_) - if reader: - reader.handle_read(socket_) - - if self.socketpair[0] in rr: - # Clear the socket's buffer - self.socketpair[0].recv(128) - - except (select.error, socket.error) as e: - # If the socket was closed by another thread, during - # shutdown, ignore it and exit - if e.args[0] not in (errno.EBADF, errno.ENOTCONN) or not self.zc.done: - raise - now = current_time_millis() - if now - self._last_cache_cleanup >= _CACHE_CLEANUP_INTERVAL: - self._last_cache_cleanup = now - self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) - self.zc.record_manager.updates_complete() - - self.socketpair[0].close() - self.socketpair[1].close() - - def _notify(self) -> None: - self.condition.notify() - try: - self.socketpair[1].send(b'x') - except socket.error: - # The socketpair may already be closed during shutdown, ignore it - if not self.zc.done: - raise - - def add_reader(self, reader: 'Listener', socket_: socket.socket) -> None: - with self.condition: - self.readers[socket_] = reader - self._notify() + self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) + self.zc.record_manager.updates_complete() + await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) - def del_reader(self, socket_: socket.socket) -> None: - with self.condition: - del self.readers[socket_] - self._notify() + def close(self) -> None: + """Close the engine.""" + if self._cache_cleanup_task: + self._cache_cleanup_task.cancel() + self._cache_cleanup_task = None + for transport in itertools.chain(self.senders, self.readers): + transport.close() + for s in self._respond_sockets: + s.close() -class Listener(QuietLogger): +class AsyncListener(asyncio.Protocol, QuietLogger): """A Listener is used by this module to listen on the multicast group to which DNS messages are sent, allowing the implementation @@ -165,12 +168,18 @@ class Listener(QuietLogger): def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data = None # type: Optional[bytes] + self.transport: Optional[asyncio.DatagramTransport] = None + super().__init__() - def handle_read(self, socket_: socket.socket) -> None: - try: - data, (addr, port, *_v6) = socket_.recvfrom(_MAX_MSG_ABSOLUTE) - except Exception: # pylint: disable=broad-except - self.log_exception_warning('Error reading from socket %d', socket_.fileno()) + def datagram_received( + self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] + ) -> None: + assert self.transport is not None + if len(addrs) == 2: + addr, port = addrs # type: ignore + elif len(addrs) == 4: + addr, port, _flow, _scope = addrs # type: ignore + else: return if self.data == data: @@ -178,7 +187,7 @@ def handle_read(self, socket_: socket.socket) -> None: 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', addr, port, - socket_.fileno(), + self.transport.get_extra_info('socket').fileno(), len(data), data, ) @@ -191,7 +200,7 @@ def handle_read(self, socket_: socket.socket) -> None: 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', addr, port, - socket_.fileno(), + self.transport.get_extra_info('socket').fileno(), msg, len(data), data, @@ -201,7 +210,7 @@ def handle_read(self, socket_: socket.socket) -> None: 'Received from %r:%r (socket %d): (%d bytes) [%r]', addr, port, - socket_.fileno(), + self.transport.get_extra_info('socket').fileno(), len(data), data, ) @@ -215,6 +224,12 @@ def handle_read(self, socket_: socket.socket) -> None: self.zc.handle_query(msg, addr, port) + def error_received(self, exc: Exception) -> None: + """Likely socket closed or IPv6.""" + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.transport = cast(asyncio.DatagramTransport, transport) + class Zeroconf(QuietLogger): @@ -250,16 +265,14 @@ def __init__( # hook for threads self._GLOBAL_DONE = False - self.unicast = unicast if apple_p2p and not platform.system() == 'Darwin': raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') - self._listen_socket, self._respond_sockets = create_sockets( - interfaces, unicast, ip_version, apple_p2p=apple_p2p - ) - log.debug('Listen socket %s, respond sockets %s', self._listen_socket, self._respond_sockets) - self.multi_socket = unicast or interfaces is not InterfaceChoice.Default + listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) + log.debug('Listen socket %s, respond sockets %s', listen_socket, respond_sockets) + + self.engine = AsyncEngine(self, listen_socket, respond_sockets) self._notify_listeners: List[NotifyListener] = [] self.browsers: Dict[ServiceListener, ServiceBrowser] = {} @@ -269,18 +282,36 @@ def __init__( self.record_manager = RecordManager(self) self.condition = threading.Condition() + self.loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_thread: Optional[threading.Thread] = None - self.engine = Engine(self) - self.listener = Listener(self) - if not unicast: - self.engine.add_reader(self.listener, cast(socket.socket, self._listen_socket)) - if self.multi_socket: - for s in self._respond_sockets: - self.engine.add_reader(self.listener, s) - # Start the engine only after all - # the readers have been added to avoid - # missing any packets that are on the wire - self.engine.start() + self.start() + + def start(self) -> None: + """Start Zeroconf.""" + self.loop = get_running_loop() + if self.loop: + self.engine.setup(self.loop, None) + return + self._start_thread() + + def _start_thread(self) -> None: + """Start a thread with a running event loop.""" + loop_thread_ready = threading.Event() + + def _run_loop() -> None: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.engine.setup(self.loop, loop_thread_ready) + self.loop.run_forever() + + self._loop_thread = threading.Thread(target=_run_loop, daemon=True) + self._loop_thread.start() + loop_thread_ready.wait() + + async def async_wait_for_start(self) -> None: + """Wait for start up.""" + await self.engine.async_wait_for_start() @property def done(self) -> bool: @@ -504,11 +535,16 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None possible.""" unicast_out, multicast_out = self.query_handler.response(msg, addr, port) if unicast_out and unicast_out.answers: - self.send(unicast_out, addr, port) + self.async_send(unicast_out, addr, port) if multicast_out and multicast_out.answers: - self.send(multicast_out, None, _MDNS_PORT) + self.async_send(multicast_out, None, _MDNS_PORT) def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + """Sends an outgoing packet threadsafe.""" + assert self.loop is not None + self.loop.call_soon_threadsafe(self.async_send, out, addr, port) + + def async_send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: """Sends an outgoing packet.""" for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: @@ -523,9 +559,10 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P out, packet, ) - for s in self._respond_sockets: + for transport in self.engine.senders: if self._GLOBAL_DONE: return + s = transport.get_extra_info('socket') try: if addr is None: real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR @@ -533,7 +570,7 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P continue else: real_addr = addr - bytes_sent = s.sendto(packet, 0, (real_addr, port)) + transport.sendto(packet, (real_addr, port or _MDNS_PORT)) except OSError as exc: if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: # with IPv6 we don't have a reliable way to determine if an interface actually has @@ -544,9 +581,6 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions # on send errors, log the exception and keep going self.log_exception_warning('Error sending through socket %d', s.fileno()) - else: - if bytes_sent != len(packet): - self.log_warning_once('!!! sent %d of %d bytes to %r' % (bytes_sent, len(packet), s)) def close(self) -> None: """Ends the background threads, and prevent this instance from @@ -557,19 +591,13 @@ def close(self) -> None: self.remove_all_service_listeners() self.unregister_all_services() self._GLOBAL_DONE = True - - # shutdown recv socket and thread - if not self.unicast: - self.engine.del_reader(cast(socket.socket, self._listen_socket)) - cast(socket.socket, self._listen_socket).close() - if self.multi_socket: - for s in self._respond_sockets: - self.engine.del_reader(s) - self.engine.join() + self.engine.close() # shutdown the rest self.notify_all() - for s in self._respond_sockets: - s.close() + if self._loop_thread: + assert self.loop is not None + self.loop.call_soon_threadsafe(self.loop.stop) + self._loop_thread.join() def __enter__(self) -> 'Zeroconf': return self diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py deleted file mode 100644 index 3de171f73..000000000 --- a/zeroconf/asyncio.py +++ /dev/null @@ -1,32 +0,0 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA -""" - -from ._logger import log -from .aio import AsyncZeroconf # pylint: disable=unused-import # noqa - -# The asyncio module would shadow system asyncio in some import cases -# to resolve this, the module has been renamed zeroconf.aio - -log.warning( - "zeroconf.asyncio namespace has changed to zeroconf.aio; " - "This compatibility module will be removed in the next version" -) From f15e84f3ee7a644792fe98edde84dd216b3497cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 15:58:46 -1000 Subject: [PATCH 0346/1433] Eliminate aio sender thread (#622) --- zeroconf/aio.py | 52 +++++++------------------------------------------ 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 3df58eae8..6f445e726 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -21,13 +21,10 @@ """ import asyncio import contextlib -import queue -import threading from types import TracebackType # noqa # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Type, Union from ._core import NotifyListener, Zeroconf -from ._dns import DNSOutgoing from ._exceptions import NonUniqueNameException from ._services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info from ._utils.aio import wait_condition_or_timeout @@ -36,42 +33,6 @@ from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME -def _get_best_available_queue() -> queue.Queue: - """Create the best available queue type.""" - if hasattr(queue, "SimpleQueue"): - return queue.SimpleQueue() # type: ignore # pylint: disable=all - return queue.Queue() - - -class _AsyncSender(threading.Thread): - """A thread to handle sending DNSOutgoing for asyncio.""" - - def __init__(self, zc: 'Zeroconf'): - """Create the sender thread.""" - super().__init__() - self.zc = zc - self.queue = _get_best_available_queue() - self.start() - self.name = "AsyncZeroconfSender" - - def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: - """Queue a send to be processed by the thread.""" - self.queue.put((out, addr, port)) - - def close(self) -> None: - """Close the instance.""" - self.queue.put(None) - self.join() - - def run(self) -> None: - """Runner that processes sends FIFO.""" - while True: - event = self.queue.get() - if event is None: - return - self.zc.send(*event) - - class AsyncNotifyListener(NotifyListener): """A NotifyListener that async code can use to wait for events.""" @@ -115,6 +76,7 @@ async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: delay = _LISTENER_TIME next_ = now last = now + timeout + await aiozc.zeroconf.async_wait_for_start() try: aiozc.zeroconf.add_listener(self, None) while not self._is_complete: @@ -124,7 +86,7 @@ async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: out = self.generate_request_query(aiozc.zeroconf, now) if not out.questions: return self.load_from_cache(aiozc.zeroconf) - aiozc.sender.send(out) + aiozc.zeroconf.async_send(out) next_ = now + delay delay *= 2 @@ -180,7 +142,7 @@ async def async_run(self) -> None: out = self.generate_ready_queries() if out: - self.aiozc.sender.send(out, addr=self.addr, port=self.port) + self.aiozc.zeroconf.async_send(out, addr=self.addr, port=self.port) if not self._handlers_to_call: continue @@ -236,7 +198,6 @@ def __init__( self.async_notify = AsyncNotifyListener(self) self.zeroconf.add_notify_listener(self.async_notify) self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} - self.sender = _AsyncSender(self.zeroconf) self.condition = asyncio.Condition() async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: @@ -244,7 +205,7 @@ async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: for i in range(3): if i != 0: await asyncio.sleep(millis_to_seconds(interval)) - self.sender.send(self.zeroconf.generate_service_broadcast(info, ttl)) + self.zeroconf.async_send(self.zeroconf.generate_service_broadcast(info, ttl)) async def async_register_service( self, @@ -261,6 +222,7 @@ async def async_register_service( The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ + await self.zeroconf.async_wait_for_start() await self.async_check_service(info, cooperating_responders) self.zeroconf.registry.add(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) @@ -274,7 +236,7 @@ async def async_check_service(self, info: ServiceInfo, cooperating_responders: b for i in range(3): if i != 0: await asyncio.sleep(millis_to_seconds(_CHECK_TIME)) - self.sender.send(self.zeroconf.generate_service_query(info)) + self.zeroconf.async_send(self.zeroconf.generate_service_query(info)) self._raise_on_name_conflict(info) def _raise_on_name_conflict(self, info: ServiceInfo) -> None: @@ -304,13 +266,13 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: def _close(self) -> None: """Shutdown zeroconf and the sender.""" - self.sender.close() self.zeroconf.remove_notify_listener(self.async_notify) self.zeroconf.close() async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" + await self.zeroconf.async_wait_for_start() await self.async_remove_all_service_listeners() await self.loop.run_in_executor(None, self._close) From 4d05961088efa8b503cad5658afade874eaeec76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 16:00:02 -1000 Subject: [PATCH 0347/1433] Update changelog (#623) --- README.rst | 93 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index dc9444253..20d37bfa0 100644 --- a/README.rst +++ b/README.rst @@ -134,35 +134,21 @@ See examples directory for more. Changelog ========= -0.33.0 (Unreleased) -=================== - -* Breaking change: zeroconf.asyncio has been removed in favor of zeroconf.aio - TBD - - The asyncio name could shadow system asyncio in some cases. If - zeroconf is in sys.path, this would result in loading zeroconf.asyncio - when system asyncio was intended. - 0.32.0 (Unreleased) =================== -* Breaking change: zeroconf.asyncio has been renamed zeroconf.aio (#503) @bdraco +* BREAKING CHANGE: zeroconf.asyncio has been renamed zeroconf.aio (#503) @bdraco The asyncio name could shadow system asyncio in some cases. If zeroconf is in sys.path, this would result in loading zeroconf.asyncio when system asyncio was intended. - An `zeroconf.asyncio` shim module has been added that imports `zeroconf.aio` - that was available in 0.31 to provide backwards compatibility in 0.32.0 - This module will be removed in 0.33.0 to fix the underlying problem - detailed in #502 - -* Breaking change: Update internal version check to match docs (3.6+) (#491) @bdraco +* BREAKING CHANGE: Update internal version check to match docs (3.6+) (#491) @bdraco Python version eariler then 3.6 were likely broken with zeroconf already, however the version is now explictly checked. -* Breaking change: RecordUpdateListener now uses update_records instead of update_record (#419) @bdraco +* BREAKING CHANGE: RecordUpdateListener now uses update_records instead of update_record (#419) @bdraco This allows the listener to receive all the records that have been updated in a single transaction such as a packet or @@ -181,7 +167,7 @@ Changelog has been updated as its a common pattern to call for ServiceInfo when a ServiceBrowser handler fires. -* Breaking change: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco +* BREAKING CHANGE: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco When manually creating a zeroconf.Engine object, it is no longer started automatically. It must manually be started by calling .start() on the created object. @@ -189,7 +175,7 @@ Changelog The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. -* Breaking change: Remove DNSOutgoing.packet backwards compatibility (#569) @bdraco +* BREAKING CHANGE: Remove DNSOutgoing.packet backwards compatibility (#569) @bdraco DNSOutgoing.packet only returned a partial message when the DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL @@ -198,6 +184,75 @@ Changelog should not be used since it will end up missing data, it has been removed +* TRAFFIC REDUCTION: Add support for handling QU questions (#621) @bdraco + + Implements RFC 6762 sec 5.4: + Questions Requesting Unicast Responses + datatracker.ietf.org/doc/html/rfc6762#section-5.4 + +* TRAFFIC REDUCTION: Protect the network against excessive packet flooding (#619) @bdraco + +* TRAFFIC REDUCTION: Suppress additionals when they are already in the answers section (#617) @bdraco + +* TRAFFIC REDUCTION: Avoid including additionals when the answer is suppressed by known-answer supression (#614) @bdraco + +* MAJOR BUG: Ensure matching PTR queries are returned with the ANY query (#618) @bdraco + +* MAJOR BUG: Fix lookup of uppercase names in registry (#597) @bdraco + + If the ServiceInfo was registered with an uppercase name and the query was + for a lowercase name, it would not be found and vice-versa. + +* MAJOR BUG: Ensure unicast responses can be sent to any source port (#598) @bdraco + + Unicast responses were only being sent if the source port + was 53, this prevented responses when testing with dig: + + dig -p 5353 @224.0.0.251 media-12.local + + The above query will now see a response + +* MAJOR BUG: Fix queries for AAAA records (#616) @bdraco + +* Eliminate aio sender thread (#622) @bdraco + +* Replace select loop with asyncio loop (#504) @bdraco + +* Add is_recent property to DNSRecord (#620) @bdraco + + RFC 6762 defines recent as not multicast within one quarter of its TTL + datatracker.ietf.org/doc/html/rfc6762#section-5.4 + +* Breakout the query response handler into its own class (#615) @bdraco + +* Add the ability for ServiceInfo.dns_addresses to filter by address type (#612) @bdraco + +* Make DNSRecords hashable (#611) @bdraco + + Allows storing them in a set for de-duplication + + Needed to be able to check for duplicates to solve #604 + +* Ensure the QU bit is set for probe queries (#609) @bdraco + + The bit should be set per + datatracker.ietf.org/doc/html/rfc6762#section-8.1 + +* Log destination when sending packets (#606) @bdraco + +* Fix docs version to match readme (cpython 3.6+) (#602) @bdraco + +* Add ZeroconfServiceTypes to zeroconf.__all__ (#601) @bdraco + + This class is in the readme, but is not exported by + default + +* Add id_ param to allow setting the id in the DNSOutgoing constructor (#599) @bdraco + +* Add unicast property to DNSQuestion to determine if the QU bit is set (#593) @bdraco + +* Reduce branching in DNSOutgoing.add_answer_at_time (#592) @bdraco + * Breakout DNSCache into zeroconf.cache (#568) @bdraco * Removed protected imports from zeroconf namespace (#567) @bdraco From 42d53c7c04a7bbf4e60e691e2e58fe7acfec8ad9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 16:27:30 -1000 Subject: [PATCH 0348/1433] Ensure zeroconf can be loaded when the system disables IPv6 (#624) --- zeroconf/const.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zeroconf/const.py b/zeroconf/const.py index 3ec124274..96f536dfa 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -38,7 +38,10 @@ _MDNS_ADDR = '224.0.0.251' _MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) _MDNS_ADDR6 = 'ff02::fb' -_MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) +try: + _MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) +except OSError: # can't use AF_INET6, IPv6 is disabled + pass _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 From 5750f7ceef0441fe1cedc0d96e7ef5ccc232d875 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 17:52:06 -1000 Subject: [PATCH 0349/1433] Fix random test failures due to monkey patching not being undone between tests (#626) - Switch patching to use unitest.mock.patch to ensure the patch is reverted when the test is completed Fixes #505 --- tests/test_init.py | 135 +++++------ tests/test_services.py | 526 ++++++++++++++++++++--------------------- 2 files changed, 329 insertions(+), 332 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index 6ccb9cff2..6e5457ff1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -81,7 +81,7 @@ def test_lots_of_names(self): # verify that name changing works self.verify_name_change(zc, type_, name, server_count) - # we are going to monkey patch the zeroconf send to check packet sizes + # we are going to patch the zeroconf send to check packet sizes old_send = zc.send longest_packet_len = 0 @@ -96,71 +96,74 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): longest_packet = out old_send(out, addr=addr, port=port) - # monkey patch the zeroconf send - setattr(zc, "send", send) - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - # start a browser - browser = ServiceBrowser(zc, type_, [on_service_state_change]) - - # wait until the browse request packet has maxed out in size - sleep_count = 0 - # we will never get to this large of a packet given the application-layer - # splitting of packets, but we still want to track the longest_packet_len - # for the debug message below - while sleep_count < 100 and longest_packet_len < const._MAX_MSG_ABSOLUTE - 100: - sleep_count += 1 - time.sleep(0.1) - - browser.cancel() - time.sleep(0.5) - - import zeroconf - - zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) - - # now the browser has sent at least one request, verify the size - assert longest_packet_len <= const._MAX_MSG_TYPICAL - assert longest_packet_len >= const._MAX_MSG_TYPICAL - 100 - - # mock zeroconf's logger warning() and debug() - from unittest.mock import patch - - patch_warn = patch('zeroconf._logger.log.warning') - patch_debug = patch('zeroconf._logger.log.debug') - mocked_log_warn = patch_warn.start() - mocked_log_debug = patch_debug.start() - - # now that we have a long packet in our possession, let's verify the - # exception handling. - out = longest_packet - assert out is not None - out.data.append(b'\0' * 1000) - - # mock the zeroconf logger and check for the correct logging backoff - call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count - # try to send an oversized packet - zc.send(out) - assert mocked_log_warn.call_count == call_counts[0] - zc.send(out) - assert mocked_log_warn.call_count == call_counts[0] - - # mock the zeroconf logger and check for the correct logging backoff - call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count - # force receive on oversized packet - zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) - zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) - time.sleep(2.0) - zeroconf.log.debug( - 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts - ) - assert mocked_log_debug.call_count > call_counts[0] - - # close our zeroconf which will close the sockets - zc.close() + # patch the zeroconf send + with unittest.mock.patch.object(zc, "send", send): + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + # wait until the browse request packet has maxed out in size + sleep_count = 0 + # we will never get to this large of a packet given the application-layer + # splitting of packets, but we still want to track the longest_packet_len + # for the debug message below + while sleep_count < 100 and longest_packet_len < const._MAX_MSG_ABSOLUTE - 100: + sleep_count += 1 + time.sleep(0.1) + + browser.cancel() + time.sleep(0.5) + + import zeroconf + + zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) + + # now the browser has sent at least one request, verify the size + assert longest_packet_len <= const._MAX_MSG_TYPICAL + assert longest_packet_len >= const._MAX_MSG_TYPICAL - 100 + + # mock zeroconf's logger warning() and debug() + from unittest.mock import patch + + patch_warn = patch('zeroconf._logger.log.warning') + patch_debug = patch('zeroconf._logger.log.debug') + mocked_log_warn = patch_warn.start() + mocked_log_debug = patch_debug.start() + + # now that we have a long packet in our possession, let's verify the + # exception handling. + out = longest_packet + assert out is not None + out.data.append(b'\0' * 1000) + + # mock the zeroconf logger and check for the correct logging backoff + call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count + # try to send an oversized packet + zc.send(out) + assert mocked_log_warn.call_count == call_counts[0] + zc.send(out) + assert mocked_log_warn.call_count == call_counts[0] + + # mock the zeroconf logger and check for the correct logging backoff + call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count + # force receive on oversized packet + zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) + zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) + time.sleep(2.0) + zeroconf.log.debug( + 'warn %d debug %d was %s', + mocked_log_warn.call_count, + mocked_log_debug.call_count, + call_counts, + ) + assert mocked_log_debug.call_count > call_counts[0] + + # close our zeroconf which will close the sockets + zc.close() def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} diff --git a/tests/test_services.py b/tests/test_services.py index 6677bfcb4..043006028 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -221,119 +221,119 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): last_sent = out send_event.set() - # monkey patch the zeroconf send - setattr(zc, "send", send) + # patch the zeroconf send + with unittest.mock.patch.object(zc, "send", send): - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) + for record in records: + generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) + return r.DNSIncoming(generated.packets()[0]) - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for SRV, A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - service_text, - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 3 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 2 - assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - last_sent = None - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for SRV, A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 3 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 2 + assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + last_sent = None + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() def test_get_info_single(self): @@ -358,83 +358,83 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): last_sent = out send_event.set() - # monkey patch the zeroconf send - setattr(zc, "send", send) + # patch the zeroconf send + with unittest.mock.patch.object(zc, "send", send): - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) + for record in records: + generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) + return r.DNSIncoming(generated.packets()[0]) - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - service_text, - ), - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ), - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ), + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ), + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() class TestServiceBrowserMultipleTypes(unittest.TestCase): @@ -953,7 +953,7 @@ def test_backoff(): type_ = "_http._tcp.local." zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - # we are going to monkey patch the zeroconf send to check query transmission + # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.send time_offset = 0.0 @@ -969,55 +969,52 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): got_query.set() old_send(out, addr=addr, port=port) - # monkey patch the zeroconf send - setattr(zeroconf_browser, "send", send) - - # monkey patch the zeroconf current_time_millis - s.current_time_millis = current_time_millis + # patch the zeroconf send + # patch the zeroconf current_time_millis + # patch the backoff limit to prevent test running forever + with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + s, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(s, "_BROWSER_BACKOFF_LIMIT", 10): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass - # monkey patch the backoff limit to prevent test running forever - s._BROWSER_BACKOFF_LIMIT = 10 # seconds + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - try: - # Test that queries are sent at increasing intervals - sleep_count = 0 - next_query_interval = 0.0 - expected_query_time = 0.0 - while True: - sleep_count += 1 - for _ in range(2): - # If the browser thread is starting up - # its possible we notify before the initial sleep - # which means the test will fail so we need to d - # this twice to eliminate the race condition - zeroconf_browser.notify_all() - got_query.wait(0.05) - if time_offset == expected_query_time: - assert got_query.is_set() - got_query.clear() - if next_query_interval == s._BROWSER_BACKOFF_LIMIT: - # Only need to test up to the point where we've seen a query - # after the backoff limit has been hit - break - elif next_query_interval == 0: - next_query_interval = initial_query_interval - expected_query_time = initial_query_interval + try: + # Test that queries are sent at increasing intervals + sleep_count = 0 + next_query_interval = 0.0 + expected_query_time = 0.0 + while True: + sleep_count += 1 + for _ in range(2): + # If the browser thread is starting up + # its possible we notify before the initial sleep + # which means the test will fail so we need to d + # this twice to eliminate the race condition + zeroconf_browser.notify_all() + got_query.wait(0.05) + if time_offset == expected_query_time: + assert got_query.is_set() + got_query.clear() + if next_query_interval == s._BROWSER_BACKOFF_LIMIT: + # Only need to test up to the point where we've seen a query + # after the backoff limit has been hit + break + elif next_query_interval == 0: + next_query_interval = initial_query_interval + expected_query_time = initial_query_interval + else: + next_query_interval = min(2 * next_query_interval, s._BROWSER_BACKOFF_LIMIT) + expected_query_time += next_query_interval else: - next_query_interval = min(2 * next_query_interval, s._BROWSER_BACKOFF_LIMIT) - expected_query_time += next_query_interval - else: - assert not got_query.is_set() - time_offset += initial_query_interval + assert not got_query.is_set() + time_offset += initial_query_interval - finally: - browser.cancel() - zeroconf_browser.close() + finally: + browser.cancel() + zeroconf_browser.close() def test_integration(): @@ -1038,7 +1035,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - # we are going to monkey patch the zeroconf send to check packet sizes + # we are going to patch the zeroconf send to check packet sizes old_send = zeroconf_browser.send time_offset = 0.0 @@ -1063,54 +1060,51 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): got_query.set() old_send(out, addr=addr, port=port) - # monkey patch the zeroconf send - setattr(zeroconf_browser, "send", send) + # patch the zeroconf send + # patch the zeroconf current_time_millis + # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL + with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + s, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(s, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + service_added = Event() + service_removed = Event() - # monkey patch the zeroconf current_time_millis - s.current_time_millis = current_time_millis + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - # monkey patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - s._BROWSER_BACKOFF_LIMIT = int(expected_ttl / 4) + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zeroconf_registrar.register_service(info) - service_added = Event() - service_removed = Event() + try: + service_added.wait(1) + assert service_added.is_set() - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + # Test that we receive queries containing answers only if the remaining TTL + # is greater than half the original TTL + sleep_count = 0 + test_iterations = 50 + while nbr_answers < test_iterations: + # Increase simulated time shift by 1/4 of the TTL in seconds + time_offset += expected_ttl / 4 + zeroconf_browser.notify_all() + sleep_count += 1 + got_query.wait(0.1) + got_query.clear() + # Prevent the test running indefinitely in an error condition + assert sleep_count < test_iterations * 4 + assert not unexpected_ttl.is_set() - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - zeroconf_registrar.register_service(info) - - try: - service_added.wait(1) - assert service_added.is_set() - - # Test that we receive queries containing answers only if the remaining TTL - # is greater than half the original TTL - sleep_count = 0 - test_iterations = 50 - while nbr_answers < test_iterations: - # Increase simulated time shift by 1/4 of the TTL in seconds - time_offset += expected_ttl / 4 - zeroconf_browser.notify_all() - sleep_count += 1 - got_query.wait(0.1) - got_query.clear() - # Prevent the test running indefinitely in an error condition - assert sleep_count < test_iterations * 4 - assert not unexpected_ttl.is_set() - - # Don't remove service, allow close() to cleanup - - finally: - zeroconf_registrar.close() - service_removed.wait(1) - assert service_removed.is_set() - browser.cancel() - zeroconf_browser.close() + # Don't remove service, allow close() to cleanup + + finally: + zeroconf_registrar.close() + service_removed.wait(1) + assert service_removed.is_set() + browser.cancel() + zeroconf_browser.close() def test_legacy_record_update_listener(): From 113874a7b59ac9cc887b1b626ac1486781c7d56f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 17:58:50 -1000 Subject: [PATCH 0350/1433] Add test to ensure ServiceBrowser sees port change as an update (#625) --- tests/test_services.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index 043006028..db1f7579a 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1184,3 +1184,58 @@ def dns_addresses_to_addresses(dns_address: List[DNSAddress]): assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ipv4, ipv6] assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] + + +def test_service_browser_is_aware_of_port_changes(): + """Test that the ServiceBrowser is aware of port changes.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + callbacks = [] + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + nonlocal callbacks + if name == registration_name: + callbacks.append((service_type, state_change, name)) + + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + zc.wait(100) + + assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] + assert zc.get_service_info(type_, registration_name).port == 80 + + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + zc.wait(100) + + assert callbacks == [ + ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), + ('_hap._tcp.local.', ServiceStateChange.Updated, 'xxxyyy._hap._tcp.local.'), + ] + assert zc.get_service_info(type_, registration_name).port == 400 + browser.cancel() + + zc.close() From 215d6badb3db796b13a000b26953cb57c557e5e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 17:58:59 -1000 Subject: [PATCH 0351/1433] Update changelog (#627) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index 20d37bfa0..e12d11f86 100644 --- a/README.rst +++ b/README.rst @@ -214,6 +214,15 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Add test to ensure ServiceBrowser sees port change as an update (#625) @bdraco + +* Fix random test failures due to monkey patching not being undone between tests (#626) @bdraco + + Switch patching to use unitest.mock.patch to ensure the patch + is reverted when the test is completed + +* Ensure zeroconf can be loaded when the system disables IPv6 (#624) @bdraco + * Eliminate aio sender thread (#622) @bdraco * Replace select loop with asyncio loop (#504) @bdraco From 28a614e0586a0ca1c5c1651b59c9a4d9c1af9a1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 21:13:37 -1000 Subject: [PATCH 0352/1433] Return early on invalid data received (#628) - Improve coverage for handling invalid incoming data --- tests/test_core.py | 31 +++++++++++++++++++++++++++++++ tests/test_dns.py | 10 ++++++++++ zeroconf/_core.py | 6 ++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 906a9508e..974575920 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -286,3 +286,34 @@ def test_generate_service_query_set_qu_bit(): out = zeroconf_registrar.generate_service_query(info) assert out.questions[0].unicast is True zeroconf_registrar.close() + + +def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): + """Ensure an invalid packet cannot cause the loop to collapse.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + generated = r.DNSOutgoing(0) + packet = generated.packets()[0] + packet = packet[:8] + b'deadbeef' + packet[8:] + parsed = r.DNSIncoming(packet) + assert parsed.valid is False + + mock_out = unittest.mock.Mock() + mock_out.packets = lambda: [packet] + zc.send(mock_out) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + entry = r.DNSText( + "didnotcrashincoming._crash._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + assert isinstance(entry, r.DNSText) + assert isinstance(entry, r.DNSRecord) + assert isinstance(entry, r.DNSEntry) + + generated.add_answer_at_time(entry, 0) + zc.send(generated) + time.sleep(0.2) + zc.close() + assert zc.cache.get(entry) is not None diff --git a/tests/test_dns.py b/tests/test_dns.py index 18bffcce2..664fccdec 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -586,6 +586,16 @@ def test_incoming_unknown_type(self): assert len(parsed.answers) == 0 assert parsed.is_query() != parsed.is_response() + def test_incoming_circular_reference(self): + assert not r.DNSIncoming( + bytes.fromhex( + '01005e0000fb542a1bf0577608004500006897934000ff11d81bc0a86a31e00000fb' + '14e914e90054f9b2000084000000000100000000095f7365727669636573075f646e' + '732d7364045f756470056c6f63616c00000c0001000011940018105f73706f746966' + '792d636f6e6e656374045f746370c023' + ) + ).valid + def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 4668b51c0..1fe5907b0 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -214,11 +214,9 @@ def datagram_received( len(data), data, ) + return - if not msg.valid: - pass - - elif not msg.is_query(): + if not msg.is_query(): self.zc.handle_response(msg) return From 2065b1d7ec7cb5d41c34826c2d8887bdd8a018b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 21:32:38 -1000 Subject: [PATCH 0353/1433] Add test for wait_condition_or_timeout_times_out util (#630) --- tests/utils/test_aio.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index a74d991d8..65eaf2556 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -5,6 +5,7 @@ """Unit tests for zeroconf._utils.aio.""" import asyncio +import contextlib import pytest @@ -20,3 +21,25 @@ async def test_get_running_loop_from_async() -> None: def test_get_running_loop_no_loop() -> None: """Test we get None when there is no loop running.""" assert aioutils.get_running_loop() is None + + +@pytest.mark.asyncio +async def test_wait_condition_or_timeout_times_out() -> None: + """Test wait_condition_or_timeout will timeout.""" + test_cond = asyncio.Condition() + async with test_cond: + await aioutils.wait_condition_or_timeout(test_cond, 0.1) + + async def _hold_condition(): + async with test_cond: + await test_cond.wait() + + task = asyncio.ensure_future(_hold_condition()) + await asyncio.sleep(0.1) + + async with test_cond: + await aioutils.wait_condition_or_timeout(test_cond, 0.1) + + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task From 2b31612e3f128b1193da9e0d2640f4e93fab2e3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 21:32:54 -1000 Subject: [PATCH 0354/1433] Remove unreachable cache check for DNSAddresses (#629) - The ServiceBrowser would check to see if a DNSAddress was already in the cache and return early to avoid sending updates when the address already was held in the cache. This check was not needed since there is already a check a few lines before as `self.zc.cache.get(record)` which effectively does the same thing. This lead to the check never being covered in the tests and 2 cache lookups when only one was needed. --- zeroconf/_services/__init__.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index f6092aae9..8a6d7d06e 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -248,12 +248,7 @@ def _enqueue_callback( ): self._pending_handlers[key] = state_change - def _process_record_update( - self, - zc: 'Zeroconf', - now: float, - record: DNSRecord, - ) -> None: + def _process_record_update(self, now: float, record: DNSRecord) -> None: """Process a single record update from a batch of updates.""" expired = record.is_expired(now) @@ -281,14 +276,6 @@ def _process_record_update( return if isinstance(record, DNSAddress): - # Only trigger an updated event if the address is new - if record.address in set( - service.address - for service in zc.cache.entries_with_name(record.name) - if isinstance(service, DNSAddress) - ): - return - # Iterate through the DNSCache and callback any services that use this address for service in self.zc.cache.entries_with_server(record.name): type_ = self._record_matching_type(service) @@ -310,7 +297,7 @@ def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) - Ensures that there is are no unecessary duplicates in the list. """ for record in records: - self._process_record_update(zc, now, record) + self._process_record_update(now, record) def update_records_complete(self) -> None: """Called when a record update has completed for all handlers. From 64f6dd7e244c86d58b962f48a50d07625f2a2a33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 21:34:42 -1000 Subject: [PATCH 0355/1433] Update changelog (#631) --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index e12d11f86..e062483a5 100644 --- a/README.rst +++ b/README.rst @@ -214,6 +214,23 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Remove unreachable cache check for DNSAddresses (#629) @bdraco + + The ServiceBrowser would check to see if a DNSAddress was + already in the cache and return early to avoid sending + updates when the address already was held in the cache. + This check was not needed since there is already a check + a few lines before as `self.zc.cache.get(record)` which + effectively does the same thing. This lead to the check + never being covered in the tests and 2 cache lookups when + only one was needed. + +* Add test for wait_condition_or_timeout_times_out util (#630) @bdraco + +* Return early on invalid data received (#628) @bdraco + + Improve coverage for handling invalid incoming data + * Add test to ensure ServiceBrowser sees port change as an update (#625) @bdraco * Fix random test failures due to monkey patching not being undone between tests (#626) @bdraco From 4ce33e48e2094f17d8358cf221c7e2f9a8cb3568 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:04:53 -1000 Subject: [PATCH 0356/1433] Return early in the shutdown/close process (#632) --- zeroconf/_core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1fe5907b0..577da9b84 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -147,13 +147,14 @@ async def _async_cache_cleanup(self) -> None: def close(self) -> None: """Close the engine.""" - if self._cache_cleanup_task: - self._cache_cleanup_task.cancel() - self._cache_cleanup_task = None for transport in itertools.chain(self.senders, self.readers): transport.close() for s in self._respond_sockets: s.close() + if not self._cache_cleanup_task: + return + self._cache_cleanup_task.cancel() + self._cache_cleanup_task = None class AsyncListener(asyncio.Protocol, QuietLogger): @@ -592,10 +593,11 @@ def close(self) -> None: self.engine.close() # shutdown the rest self.notify_all() - if self._loop_thread: - assert self.loop is not None - self.loop.call_soon_threadsafe(self.loop.stop) - self._loop_thread.join() + if not self._loop_thread: + return + assert self.loop is not None + self.loop.call_soon_threadsafe(self.loop.stop) + self._loop_thread.join() def __enter__(self) -> 'Zeroconf': return self From 5f66caaccf44c1504988cb82c1cba78d28dde7e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:13:13 -1000 Subject: [PATCH 0357/1433] Mark DNSOutgoing write functions as protected (#633) --- zeroconf/_dns.py | 84 +++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index dcb8c9a38..41daee4e4 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -572,7 +572,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.multicast = multicast self.packets_data: List[bytes] = [] - # these 3 are per-packet -- see also reset_for_next_packet() + # these 3 are per-packet -- see also _reset_for_next_packet() self.names: Dict[str, int] = {} self.data: List[bytes] = [] self.size: int = 12 @@ -585,7 +585,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.authorities: List[DNSPointer] = [] self.additionals: List[DNSRecord] = [] - def reset_for_next_packet(self) -> None: + def _reset_for_next_packet(self) -> None: self.names = {} self.data = [] self.size = 12 @@ -686,29 +686,29 @@ def add_question_or_all_cache( for cached_entry in cached_entries: self.add_answer_at_time(cached_entry, now) - def pack(self, format_: Union[bytes, str], value: Any) -> None: + def _pack(self, format_: Union[bytes, str], value: Any) -> None: self.data.append(struct.pack(format_, value)) self.size += struct.calcsize(format_) - def write_byte(self, value: int) -> None: + def _write_byte(self, value: int) -> None: """Writes a single byte to the packet""" - self.pack(b'!c', int2byte(value)) + self._pack(b'!c', int2byte(value)) - def insert_short_at_start(self, value: int) -> None: + def _insert_short_at_start(self, value: int) -> None: """Inserts an unsigned short at the start of the packet""" self.data.insert(0, struct.pack(b'!H', value)) - def replace_short(self, index: int, value: int) -> None: + def _replace_short(self, index: int, value: int) -> None: """Replaces an unsigned short in a certain position in the packet""" self.data[index] = struct.pack(b'!H', value) def write_short(self, value: int) -> None: """Writes an unsigned short to the packet""" - self.pack(b'!H', value) + self._pack(b'!H', value) - def write_int(self, value: Union[float, int]) -> None: + def _write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" - self.pack(b'!I', int(value)) + self._pack(b'!I', int(value)) def write_string(self, value: bytes) -> None: """Writes a string to the packet""" @@ -716,13 +716,13 @@ def write_string(self, value: bytes) -> None: self.data.append(value) self.size += len(value) - def write_utf(self, s: str) -> None: + def _write_utf(self, s: str) -> None: """Writes a UTF-8 string of a given length to the packet""" utfstr = s.encode('utf-8') length = len(utfstr) if length > 64: raise NamePartTooLongException - self.write_byte(length) + self._write_byte(length) self.write_string(utfstr) def write_character_string(self, value: bytes) -> None: @@ -730,7 +730,7 @@ def write_character_string(self, value: bytes) -> None: length = len(value) if length > 256: raise NamePartTooLongException - self.write_byte(length) + self._write_byte(length) self.write_string(value) def write_name(self, name: str) -> None: @@ -768,49 +768,45 @@ def write_name(self, name: str) -> None: # write the new names out. for part in parts[:count]: - self.write_utf(part) + self._write_utf(part) # if we wrote part of the name, create a pointer to the rest if count != len(name_suffices): # Found substring in packet, create pointer index = self.names[name_suffices[count]] - self.write_byte((index >> 8) | 0xC0) - self.write_byte(index & 0xFF) + self._write_byte((index >> 8) | 0xC0) + self._write_byte(index & 0xFF) else: # this is the end of a name - self.write_byte(0) + self._write_byte(0) - def write_question(self, question: DNSQuestion) -> bool: + def _write_question(self, question: DNSQuestion) -> bool: """Writes a question to the packet""" start_data_length, start_size = len(self.data), self.size self.write_name(question.name) self.write_short(question.type) - self.write_record_class(question) + self._write_record_class(question) return self._check_data_limit_or_rollback(start_data_length, start_size) - def write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: + def _write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: """Write out the record class including the unique/unicast (QU) bit.""" if record.unique and self.multicast: self.write_short(record.class_ | _CLASS_UNIQUE) else: self.write_short(record.class_) - def write_record(self, record: DNSRecord, now: float) -> bool: + def _write_record(self, record: DNSRecord, now: float) -> bool: """Writes a record (answer, authoritative answer, additional) to - the packet. Returns True on success, or False if we did not (either - because the packet was already finished or because the record does - not fit.""" - if self.state == self.State.finished: - return False - + the packet. Returns True on success, or False if we did not + because the packet because the record does not fit.""" start_data_length, start_size = len(self.data), self.size self.write_name(record.name) self.write_short(record.type) - self.write_record_class(record) + self._write_record_class(record) if now == 0: - self.write_int(record.ttl) + self._write_int(record.ttl) else: - self.write_int(record.get_remaining_ttl(now)) + self._write_int(record.get_remaining_ttl(now)) index = len(self.data) self.write_short(0) # Will get replaced with the actual size @@ -819,7 +815,7 @@ def write_record(self, record: DNSRecord, now: float) -> bool: length = sum((len(d) for d in self.data[index + 1 :])) # Here we replace the 0 length short we wrote # before with the actual length - self.replace_short(index, length) + self._replace_short(index, length) return self._check_data_limit_or_rollback(start_data_length, start_size) def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: @@ -844,7 +840,7 @@ def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) def _write_questions_from_offset(self, questions_offset: int) -> int: questions_written = 0 for question in self.questions[questions_offset:]: - if not self.write_question(question): + if not self._write_question(question): break questions_written += 1 return questions_written @@ -852,7 +848,7 @@ def _write_questions_from_offset(self, questions_offset: int) -> int: def _write_answers_from_offset(self, answer_offset: int) -> int: answers_written = 0 for answer, time_ in self.answers[answer_offset:]: - if not self.write_record(answer, time_): + if not self._write_record(answer, time_): break answers_written += 1 return answers_written @@ -860,7 +856,7 @@ def _write_answers_from_offset(self, answer_offset: int) -> int: def _write_authorities_from_offset(self, authority_offset: int) -> int: authorities_written = 0 for authority in self.authorities[authority_offset:]: - if not self.write_record(authority, 0): + if not self._write_record(authority, 0): break authorities_written += 1 return authorities_written @@ -868,7 +864,7 @@ def _write_authorities_from_offset(self, authority_offset: int) -> int: def _write_additionals_from_offset(self, additional_offset: int) -> int: additionals_written = 0 for additional in self.additionals[additional_offset:]: - if not self.write_record(additional, 0): + if not self._write_record(additional, 0): break additionals_written += 1 return additionals_written @@ -928,10 +924,10 @@ def packets(self) -> List[bytes]: authorities_written = self._write_authorities_from_offset(authority_offset) additionals_written = self._write_additionals_from_offset(additional_offset) - self.insert_short_at_start(additionals_written) - self.insert_short_at_start(authorities_written) - self.insert_short_at_start(answers_written) - self.insert_short_at_start(questions_written) + self._insert_short_at_start(additionals_written) + self._insert_short_at_start(authorities_written) + self._insert_short_at_start(answers_written) + self._insert_short_at_start(questions_written) questions_offset += questions_written answer_offset += answers_written @@ -950,17 +946,17 @@ def packets(self) -> List[bytes]: ): # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 log.debug("Setting TC flag") - self.insert_short_at_start(self.flags | _FLAGS_TC) + self._insert_short_at_start(self.flags | _FLAGS_TC) else: - self.insert_short_at_start(self.flags) + self._insert_short_at_start(self.flags) if self.multicast: - self.insert_short_at_start(0) + self._insert_short_at_start(0) else: - self.insert_short_at_start(self.id) + self._insert_short_at_start(self.id) self.packets_data.append(b''.join(self.data)) - self.reset_for_next_packet() + self._reset_for_next_packet() if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) From a0977a1ddfd7a7a1abcf74c1d90c18021aebc910 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:21:10 -1000 Subject: [PATCH 0358/1433] Clear cache in ZeroconfServiceTypes tests to ensure responses can be mcast before the timeout (#634) - We prevent the same record from being multicast within 1s because of RFC6762 sec 14. Since these test timeout after 0.5s, the answers they are looking for many be suppressed. Since a legitimate querier will retry again later, we need to clear the cache to simulate that the record has not been multicast recently --- tests/services/test_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index e8e9911fa..9d681667d 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -35,7 +35,7 @@ def test_integration_with_listener(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) - + _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types @@ -68,7 +68,7 @@ def test_integration_with_listener_v6_records(self): addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) zeroconf_registrar.register_service(info) - + _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types @@ -100,7 +100,7 @@ def test_integration_with_listener_ipv6(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) - + _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) assert type_ in service_types @@ -132,7 +132,7 @@ def test_integration_with_subtype_and_listener(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) - + _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert discovery_type in service_types From c854d03efd31e1d002518a43221b347fa6ca5de5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:23:04 -1000 Subject: [PATCH 0359/1433] Update changelog (#635) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index e062483a5..def634def 100644 --- a/README.rst +++ b/README.rst @@ -184,6 +184,11 @@ Changelog should not be used since it will end up missing data, it has been removed +* BREAKING CHANGE: Mark DNSOutgoing write functions as protected (#633) @bdraco + + These functions are not intended to be used by external + callers and the API is not likely to be stable in the future + * TRAFFIC REDUCTION: Add support for handling QU questions (#621) @bdraco Implements RFC 6762 sec 5.4: @@ -214,6 +219,8 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Return early in the shutdown/close process (#632) @bdraco + * Remove unreachable cache check for DNSAddresses (#629) @bdraco The ServiceBrowser would check to see if a DNSAddress was From bbbbddf40d78dbd62a84f2439763d0a59211c5b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:56:59 -1000 Subject: [PATCH 0360/1433] Ensure eventloop shutdown is threadsafe (#636) - Prevent ConnectionResetError from being thrown on Windows with ProactorEventLoop on cpython 3.8+ --- zeroconf/_core.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 577da9b84..eae2f49d5 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -21,6 +21,7 @@ """ import asyncio +import contextlib import errno import itertools import platform @@ -145,16 +146,22 @@ async def _async_cache_cleanup(self) -> None: self.zc.record_manager.updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) + async def _async_stop_cleanup_task(self) -> None: + """Stop the cleanup task.""" + assert self._cache_cleanup_task is not None + self._cache_cleanup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._cache_cleanup_task + self._cache_cleanup_task = None + def close(self) -> None: """Close the engine.""" for transport in itertools.chain(self.senders, self.readers): transport.close() for s in self._respond_sockets: s.close() - if not self._cache_cleanup_task: - return - self._cache_cleanup_task.cancel() - self._cache_cleanup_task = None + assert self.loop is not None + asyncio.run_coroutine_threadsafe(self._async_stop_cleanup_task(), self.loop).result() class AsyncListener(asyncio.Protocol, QuietLogger): From 09c18a4173a013e67da5a1cdc7089452ba6f67ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jun 2021 22:58:33 -1000 Subject: [PATCH 0361/1433] Update changelog (#637) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index def634def..07a9b2d63 100644 --- a/README.rst +++ b/README.rst @@ -219,6 +219,8 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Ensure eventloop shutdown is threadsafe (#636) @bdraco + * Return early in the shutdown/close process (#632) @bdraco * Remove unreachable cache check for DNSAddresses (#629) @bdraco From ce6912a75392cde41d8950b224ba3d14460993ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 00:42:18 -1000 Subject: [PATCH 0362/1433] Ensure AsyncZeroconf.async_close can be called multiple times like Zeroconf.close (#638) --- tests/test_aio.py | 18 ++++++++++++++++++ zeroconf/_core.py | 22 ++++++++++++++++------ zeroconf/aio.py | 11 ++++------- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 2b2222422..f4fdde4c6 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -43,6 +43,14 @@ async def test_async_basic_usage() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_async_close_twice() -> None: + """Test we can close twice.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.async_close() + await aiozc.async_close() + + @pytest.mark.asyncio async def test_async_with_sync_passed_in() -> None: """Test we can create and close the instance when passing in a sync Zeroconf.""" @@ -52,6 +60,16 @@ async def test_async_with_sync_passed_in() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_async_with_sync_passed_in_closed_in_async() -> None: + """Test caller closes the sync version in async.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(zc=zc) + assert aiozc.zeroconf is zc + zc.close() + await aiozc.async_close() + + @pytest.mark.asyncio async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" diff --git a/zeroconf/_core.py b/zeroconf/_core.py index eae2f49d5..89f6dd957 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -146,22 +146,31 @@ async def _async_cache_cleanup(self) -> None: self.zc.record_manager.updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) - async def _async_stop_cleanup_task(self) -> None: - """Stop the cleanup task.""" + async def _async_close(self) -> None: + """Cancel and wait for the cleanup task to finish.""" + self._async_shutdown() assert self._cache_cleanup_task is not None self._cache_cleanup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._cache_cleanup_task self._cache_cleanup_task = None + await asyncio.sleep(0) # flush out any call soons - def close(self) -> None: - """Close the engine.""" + def _async_shutdown(self) -> None: + """Shutdown transports and sockets.""" for transport in itertools.chain(self.senders, self.readers): transport.close() for s in self._respond_sockets: s.close() + + def close(self) -> None: + """Close from sync context.""" assert self.loop is not None - asyncio.run_coroutine_threadsafe(self._async_stop_cleanup_task(), self.loop).result() + # Guard against Zeroconf.close() being called from the eventloop + if get_running_loop() == self.loop: + self._async_shutdown() + return + asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result() class AsyncListener(asyncio.Protocol, QuietLogger): @@ -355,7 +364,8 @@ def add_notify_listener(self, listener: NotifyListener) -> None: def remove_notify_listener(self, listener: NotifyListener) -> None: """Removes a listener from the set that is currently listening.""" - self._notify_listeners.remove(listener) + with contextlib.suppress(ValueError): + self._notify_listeners.remove(listener) def add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 6f445e726..626b75f83 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -264,17 +264,14 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: self.zeroconf.registry.update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) - def _close(self) -> None: - """Shutdown zeroconf and the sender.""" - self.zeroconf.remove_notify_listener(self.async_notify) - self.zeroconf.close() - async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" - await self.zeroconf.async_wait_for_start() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) await self.async_remove_all_service_listeners() - await self.loop.run_in_executor(None, self._close) + self.zeroconf.remove_notify_listener(self.async_notify) + await self.loop.run_in_executor(None, self.zeroconf.close) async def async_get_service_info( self, type_: str, name: str, timeout: int = 3000 From 5ebd95452b16e76c37649486b232856a80390ac3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 00:50:25 -1000 Subject: [PATCH 0363/1433] Ensure cache is cleared before starting known answer enumeration query test (#639) --- tests/test_handlers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 71f6aff22..1e8109eb1 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -593,6 +593,7 @@ def test_known_answer_supression_service_type_enumeration_query(): ) zc.register_service(info) now = current_time_millis() + _clear_cache(zc) # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) From 330e36ceb4202c579fe979958c63c37033ababbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 08:29:54 -1000 Subject: [PATCH 0364/1433] Ensure the ServiceInfo.key gets updated when the name is changed externally (#645) --- tests/test_services.py | 19 +++++++++++++++++++ zeroconf/_services/__init__.py | 13 ++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_services.py b/tests/test_services.py index db1f7579a..867c546a4 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1239,3 +1239,22 @@ def mock_incoming_msg(records) -> r.DNSIncoming: browser.cancel() zc.close() + + +def test_changing_name_updates_serviceinfo_key(): + """Verify a name change will adjust the underlying key value.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + assert info_service.key == "mytesthome._homeassistant._tcp.local." + info_service.name = "YourTestHome._homeassistant._tcp.local." + assert info_service.key == "yourtesthome._homeassistant._tcp.local." diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 8a6d7d06e..857197bd7 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -466,7 +466,7 @@ def __init__( if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException self.type = type_ - self.name = name + self._name = name self.key = name.lower() if addresses is not None: self._addresses = addresses @@ -494,6 +494,17 @@ def __init__( self.host_ttl = host_ttl self.other_ttl = other_ttl + @property + def name(self) -> str: + """The name of the service.""" + return self._name + + @name.setter + def name(self, name: str) -> None: + """Replace the the name and reset the key.""" + self._name = name + self.key = name.lower() + @property def addresses(self) -> List[bytes]: """IPv4 addresses of this service. From 9354ab39f350e4e6451dc4965225591761ada40d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 08:33:29 -1000 Subject: [PATCH 0365/1433] Add missing coverage to ServiceRegistry (#646) --- zeroconf/_services/registry.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 4c4c17065..e9db74f16 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -86,18 +86,13 @@ def get_infos_server(self, server: str) -> List[ServiceInfo]: def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" - service_infos = [] - - for name in getattr(self, attr).get(key.lower(), [])[:]: - info = self._services.get(name) - # Since we do not get under a lock since it would be - # a performance issue, its possible - # the service can be unregistered during the get - # so we must check if info is None - if info is not None: - service_infos.append(info) - - return service_infos + # Since we do not get under a lock since it would be + # a performance issue, its possible + # the service can be unregistered during the get + # so we must check if info is None + return list( + filter(None, [self._services.get(name) for name in getattr(self, attr).get(key.lower(), [])[:]]) + ) def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" From a83d390bef042da51d93014c222c65af81723a20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 08:38:30 -1000 Subject: [PATCH 0366/1433] Use ServiceInfo.key/ServiceInfo.server_key instead of lowering in ServiceRegistry (#647) --- zeroconf/_services/registry.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index e9db74f16..058717ceb 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -96,18 +96,16 @@ def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" - lower_name = info.name.lower() - if lower_name in self._services: + if info.key in self._services: raise ServiceNameAlreadyRegistered - self._services[lower_name] = info - self.types.setdefault(info.type.lower(), []).append(lower_name) - self.servers.setdefault(info.server.lower(), []).append(lower_name) + self._services[info.key] = info + self.types.setdefault(info.type.lower(), []).append(info.key) + self.servers.setdefault(info.server_key, []).append(info.key) def _remove(self, info: ServiceInfo) -> None: """Remove a service under the lock.""" - lower_name = info.name.lower() - old_service_info = self._services[lower_name] - self.types[old_service_info.type.lower()].remove(lower_name) - self.servers[old_service_info.server.lower()].remove(lower_name) - del self._services[lower_name] + old_service_info = self._services[info.key] + self.types[old_service_info.type.lower()].remove(info.key) + self.servers[old_service_info.server_key].remove(info.key) + del self._services[info.key] From cf0b5b9e2cfa4779425401b3d205f5d913621864 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 08:45:30 -1000 Subject: [PATCH 0367/1433] Ensure services are removed from the registry when calling unregister_all_services (#644) - There was a race condition where a query could be answered for a service in the registry while goodbye packets which could result a fresh record being broadcast after the goodbye if a query came in at just the right time. To avoid this, we now remove the services from the registry right after we generate the goodbye packet --- tests/test_core.py | 29 +++++++++++++++++++++++++++++ tests/test_init.py | 17 +++++++++++++++-- zeroconf/_core.py | 21 +++++++++++++++------ zeroconf/_services/registry.py | 23 ++++++++++++----------- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 974575920..6252488b9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -317,3 +317,32 @@ def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): time.sleep(0.2) zc.close() assert zc.cache.get(entry) is not None + + +def test_goodbye_all_services(): + """Verify generating the goodbye query does not change with time.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + out = zc.generate_unregister_all_services() + assert out is None + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + desc = {'path': '/~paulsm/'} + info = r.ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info) + out = zc.generate_unregister_all_services() + assert out is not None + first_packet = out.packets() + zc.registry.add(info) + out2 = zc.generate_unregister_all_services() + assert out2 is not None + second_packet = out.packets() + assert second_packet == first_packet + + # Verify the registery is empty + out3 = zc.generate_unregister_all_services() + assert out3 is None + assert zc.registry.get_service_infos() == [] + + zc.close() diff --git a/tests/test_init.py b/tests/test_init.py index 6e5457ff1..3cc16b220 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -184,8 +184,21 @@ def verify_name_change(self, zc, type_, name, number_hosts): # verify no name conflict https://tools.ietf.org/html/rfc6762#section-6.6 zc.register_service(info_service, cooperating_responders=True) - zc.register_service(info_service, allow_name_change=True) - assert info_service.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) + # Create a new object since allow_name_change will mutate the + # original object and then we will have the wrong service + # in the registry + info_service2 = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zc.register_service(info_service2, allow_name_change=True) + assert info_service2.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) def generate_many_hosts(self, zc, type_, name, number_hosts): records_per_server = 2 diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 89f6dd957..c8e7736e3 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -476,10 +476,22 @@ def unregister_service(self, info: ServiceInfo) -> None: self.registry.remove(info) self._broadcast_service(info, _UNREGISTER_TIME, 0) - def unregister_all_services(self) -> None: - """Unregister all registered services.""" + def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: + """Generate a DNSOutgoing goodbye for all services and remove them from the registry.""" service_infos = self.registry.get_service_infos() if not service_infos: + return None + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) + for info in service_infos: + self._add_broadcast_answer(out, info, 0) + self.registry.remove(service_infos) + return out + + def unregister_all_services(self) -> None: + """Unregister all registered services.""" + # Send Goodbye packets https://datatracker.ietf.org/doc/html/rfc6762#section-10.1 + out = self.generate_unregister_all_services() + if not out: return now = current_time_millis() next_time = now @@ -489,9 +501,6 @@ def unregister_all_services(self) -> None: self.wait(next_time - now) now = current_time_millis() continue - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - for info in service_infos: - self._add_broadcast_answer(out, info, 0) self.send(out) i += 1 next_time += _UNREGISTER_TIME @@ -604,8 +613,8 @@ def close(self) -> None: if self._GLOBAL_DONE: return # remove service listeners - self.remove_all_service_listeners() self.unregister_all_services() + self.remove_all_service_listeners() self._GLOBAL_DONE = True self.engine.close() # shutdown the rest diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 058717ceb..244ff294e 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -21,7 +21,7 @@ """ import threading -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from .._exceptions import ServiceNameAlreadyRegistered @@ -47,21 +47,21 @@ def __init__( def add(self, info: ServiceInfo) -> None: """Add a new service to the registry.""" - with self._lock: self._add(info) - def remove(self, info: ServiceInfo) -> None: + def remove(self, info: Union[List[ServiceInfo], ServiceInfo]) -> None: """Remove a new service from the registry.""" + infos = info if isinstance(info, list) else [info] with self._lock: - self._remove(info) + self._remove(infos) def update(self, info: ServiceInfo) -> None: """Update new service in the registry.""" with self._lock: - self._remove(info) + self._remove([info]) self._add(info) def get_service_infos(self) -> List[ServiceInfo]: @@ -103,9 +103,10 @@ def _add(self, info: ServiceInfo) -> None: self.types.setdefault(info.type.lower(), []).append(info.key) self.servers.setdefault(info.server_key, []).append(info.key) - def _remove(self, info: ServiceInfo) -> None: - """Remove a service under the lock.""" - old_service_info = self._services[info.key] - self.types[old_service_info.type.lower()].remove(info.key) - self.servers[old_service_info.server_key].remove(info.key) - del self._services[info.key] + def _remove(self, infos: List[ServiceInfo]) -> None: + """Remove a services under the lock.""" + for info in infos: + old_service_info = self._services[info.key] + self.types[old_service_info.type.lower()].remove(info.key) + self.servers[old_service_info.server_key].remove(info.key) + del self._services[info.key] From 79e39c0e923a1f6d87353761809f34f0fe1f0800 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 09:02:46 -1000 Subject: [PATCH 0368/1433] Use cache clear helper in aio tests (#648) --- tests/test_aio.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index f4fdde4c6..49aa7ef21 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -19,6 +19,9 @@ from zeroconf._utils.time import current_time_millis +from . import _clear_cache + + @pytest.fixture(autouse=True) def verify_threads_ended(): """Verify that the threads are not running after the test.""" @@ -387,10 +390,7 @@ async def test_service_info_async_request() -> None: assert aiosinfos[1].addresses == [socket.inet_aton("10.0.1.5")] aiosinfo = AsyncServiceInfo(type_, registration_name) - zc_cache = aiozc.zeroconf.cache - for name in zc_cache.names(): - for record in zc_cache.entries_with_name(name): - zc_cache.remove(record) + _clear_cache(aiozc.zeroconf) # Generating the race condition is almost impossible # without patching since its a TOCTOU race with unittest.mock.patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): From 72e709b40caed016ba981be3752c439bbbf40ec7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 09:18:24 -1000 Subject: [PATCH 0369/1433] Add async_unregister_all_services to AsyncZeroconf (#649) --- tests/test_aio.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++ zeroconf/aio.py | 15 ++++++++++++ 2 files changed, 75 insertions(+) diff --git a/tests/test_aio.py b/tests/test_aio.py index 49aa7ef21..388668b20 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -18,6 +18,8 @@ from zeroconf._services import ServiceInfo, ServiceListener from zeroconf._utils.time import current_time_millis +from . import _clear_cache + from . import _clear_cache @@ -498,3 +500,61 @@ async def test_async_context_manager() -> None: await task aiosinfo = await aiozc.async_get_service_info(type_, registration_name) assert aiosinfo is not None + + +@pytest.mark.asyncio +async def test_async_unregister_all_services() -> None: + """Test unregistering all services.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "abc" + registration_name = "%s.%s" % (name, type_) + registration_name2 = "%s.%s" % (name2, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-1.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info2 = ServiceInfo( + type_, + registration_name2, + 80, + 0, + 0, + desc, + "ash-5.local.", + addresses=[socket.inet_aton("10.0.1.5")], + ) + tasks = [] + tasks.append(await aiozc.async_register_service(info)) + tasks.append(await aiozc.async_register_service(info2)) + await asyncio.gather(*tasks) + + tasks = [] + tasks.append(aiozc.async_get_service_info(type_, registration_name)) + tasks.append(aiozc.async_get_service_info(type_, registration_name2)) + results = await asyncio.gather(*tasks) + assert results[0] is not None + assert results[1] is not None + + await aiozc.async_unregister_all_services() + + tasks = [] + tasks.append(aiozc.async_get_service_info(type_, registration_name)) + tasks.append(aiozc.async_get_service_info(type_, registration_name2)) + results = await asyncio.gather(*tasks) + assert results[0] is None + assert results[1] is None + + # Verify we can call again + await aiozc.async_unregister_all_services() + + await aiozc.async_close() diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 626b75f83..446f6cf03 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -227,6 +227,21 @@ async def async_register_service( self.zeroconf.registry.add(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + async def async_unregister_all_services(self) -> None: + """Unregister all registered services. + + Unlike async_register_service and async_unregister_service, this + method does not return a future and is always expected to be + awaited since its only called at shutdown. + """ + out = self.zeroconf.generate_unregister_all_services() + if not out: + return + for i in range(3): + if i != 0: + await asyncio.sleep(millis_to_seconds(_UNREGISTER_TIME)) + self.zeroconf.async_send(out) + async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: """Checks the network for a unique service name.""" instance_name_from_service_info(info) From df9f8d9a0110cc9135b7c2f0b4cd47e985da9a7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 09:23:13 -1000 Subject: [PATCH 0370/1433] Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) --- tests/utils/test_net.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 1a8beebe4..1fd4d1136 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -46,9 +46,15 @@ def test_interface_index_to_ip6_address(): """Test we can extract from mocked adapters.""" adapters = _generate_mock_adapters() assert netutils.interface_index_to_ip6_address(adapters, 1) == ('2001:db8::', 1, 1) + + # call with invalid adapter with pytest.raises(RuntimeError): assert netutils.interface_index_to_ip6_address(adapters, 6) + # call with adapter that has ipv4 address only + with pytest.raises(RuntimeError): + assert netutils.interface_index_to_ip6_address(adapters, 2) + def test_ip6_addresses_to_indexes(): """Test we can extract from mocked adapters.""" From b940f878fe1f8e6b8dfe2554b781cd6034dee722 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 09:49:22 -1000 Subject: [PATCH 0371/1433] Set __all__ in zeroconf.aio to ensure private functions do now show in the docs (#652) --- zeroconf/aio.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 446f6cf03..974a2cb9f 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -33,6 +33,14 @@ from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME +__all__ = [ + "AsyncZeroconf", + "AsyncServiceInfo", + "AsyncServiceBrowser", + "AsyncServiceListener", +] + + class AsyncNotifyListener(NotifyListener): """A NotifyListener that async code can use to wait for events.""" From 7d8994bc3cb4d5978bb1ff189bb5a4b7c81b5c4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 09:56:51 -1000 Subject: [PATCH 0372/1433] Remove all calls to the executor in AsyncZeroconf (#653) --- zeroconf/_core.py | 50 +++++++++++++++++++++++++++++++++++------------ zeroconf/aio.py | 3 ++- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index c8e7736e3..e859c7b1c 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -149,11 +149,11 @@ async def _async_cache_cleanup(self) -> None: async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" self._async_shutdown() - assert self._cache_cleanup_task is not None - self._cache_cleanup_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._cache_cleanup_task - self._cache_cleanup_task = None + if self._cache_cleanup_task: + self._cache_cleanup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._cache_cleanup_task + self._cache_cleanup_task = None await asyncio.sleep(0) # flush out any call soons def _async_shutdown(self) -> None: @@ -170,6 +170,8 @@ def close(self) -> None: if get_running_loop() == self.loop: self._async_shutdown() return + if not self.loop.is_running(): + return asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result() @@ -607,17 +609,15 @@ def async_send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _ # on send errors, log the exception and keep going self.log_exception_warning('Error sending through socket %d', s.fileno()) - def close(self) -> None: - """Ends the background threads, and prevent this instance from - servicing further queries.""" + def _close(self) -> None: + """Set global done and remove all service listeners.""" if self._GLOBAL_DONE: return - # remove service listeners - self.unregister_all_services() self.remove_all_service_listeners() self._GLOBAL_DONE = True - self.engine.close() - # shutdown the rest + + def _shutdown_threads(self) -> None: + """Shutdown any threads.""" self.notify_all() if not self._loop_thread: return @@ -625,6 +625,32 @@ def close(self) -> None: self.loop.call_soon_threadsafe(self.loop.stop) self._loop_thread.join() + def close(self) -> None: + """Ends the background threads, and prevent this instance from + servicing further queries. + + This method is idempotent and irreversible. + """ + self.unregister_all_services() + self._close() + self.engine.close() + self._shutdown_threads() + + async def _async_close(self) -> None: + """Ends the background threads, and prevent this instance from + servicing further queries. + + This method is idempotent and irreversible. + + This call only intended to be used by AsyncZeroconf + + Callers are responsible for unregistering all services + before calling this function + """ + self._close() + await self.engine._async_close() # pylint: disable=protected-access + self._shutdown_threads() + def __enter__(self) -> 'Zeroconf': return self diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 974a2cb9f..8fff385f9 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -294,7 +294,8 @@ async def async_close(self) -> None: await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) await self.async_remove_all_service_listeners() self.zeroconf.remove_notify_listener(self.async_notify) - await self.loop.run_in_executor(None, self.zeroconf.close) + await self.async_unregister_all_services() + await self.zeroconf._async_close() # pylint: disable=protected-access async def async_get_service_info( self, type_: str, name: str, timeout: int = 3000 From 3c61d03f5954c3e45229d6c1399a63c0f7331d55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 10:02:43 -1000 Subject: [PATCH 0373/1433] Add test coverage for normalize_interface_choice exception paths (#654) --- tests/utils/test_net.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 1fd4d1136..7890f381d 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -67,6 +67,17 @@ def test_ip6_addresses_to_indexes(): assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] +def test_normalize_interface_choice_errors(): + """Test we generate exception on invalid input.""" + with patch("zeroconf._utils.net.get_all_addresses", return_value=[]), patch( + "zeroconf._utils.net.get_all_addresses_v6", return_value=[] + ), pytest.raises(RuntimeError): + netutils.normalize_interface_choice(r.InterfaceChoice.All) + + with pytest.raises(TypeError): + netutils.normalize_interface_choice("1.2.3.4") + + @pytest.mark.parametrize( "errno,expected_result", [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], From efd6bfbe81f448da2ee68b91d49cbe1982271da3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 10:05:17 -1000 Subject: [PATCH 0374/1433] Improve aio utils tests to validate high lock contention (#655) --- tests/utils/test_aio.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index 65eaf2556..1f0a1d7ef 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -37,8 +37,12 @@ async def _hold_condition(): task = asyncio.ensure_future(_hold_condition()) await asyncio.sleep(0.1) - async with test_cond: - await aioutils.wait_condition_or_timeout(test_cond, 0.1) + async def _async_wait_or_timeout(): + async with test_cond: + await aioutils.wait_condition_or_timeout(test_cond, 0.1) + + # Test high lock contention + await asyncio.gather(*[_async_wait_or_timeout() for _ in range(100)]) task.cancel() with contextlib.suppress(asyncio.CancelledError): From 87fe529a33b920532b2af688bb66182ae832a3ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 10:15:08 -1000 Subject: [PATCH 0375/1433] Add coverage for registering a service with a custom ttl (#656) --- tests/test_core.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 6252488b9..9c04d03dd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -346,3 +346,28 @@ def test_goodbye_all_services(): assert zc.registry.get_service_infos() == [] zc.close() + + +def test_register_service_with_custom_ttl(): + """Test a registering a service with a custom ttl.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + info_service = r.ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-90.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + zc.register_service(info_service, ttl=30) + assert zc.cache.get(info_service.dns_pointer()).ttl == 30 + zc.close() From 5752ace7727bffa34cdac0455125a941014ab123 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 10:23:33 -1000 Subject: [PATCH 0376/1433] Add test for Zeroconf.get_service_info failure case (#657) --- tests/test_core.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 9c04d03dd..97799d955 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -371,3 +371,10 @@ def test_register_service_with_custom_ttl(): zc.register_service(info_service, ttl=30) assert zc.cache.get(info_service.dns_pointer()).ttl == 30 zc.close() + + +def test_get_service_info_failure_path(): + """Verify get_service_info return None when the underlying call returns False.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None + zc.close() From 0e52be059065e23ebe9e11c465adc20655b6080e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 11:14:38 -1000 Subject: [PATCH 0377/1433] Add test for launching with apple_p2p=True (#660) - Switch to using `sys.platform` to detect Mac instead of `platform.system()` since `platform.system()` is not intended to be machine parsable and is only for humans. Closes #650 --- tests/test_core.py | 11 +++++++++++ zeroconf/_core.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 97799d955..19ab81d10 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,7 @@ import os import pytest import socket +import sys import time import unittest import unittest.mock @@ -99,6 +100,16 @@ def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) rv.close() + @unittest.skipIf(sys.platform != 'darwin', reason="apple_p2p only available on mac") + def test_launch_and_close_apple_p2p(self): + rv = r.Zeroconf(apple_p2p=True) + rv.close() + + @unittest.skipIf(sys.platform == 'darwin', reason="apple_p2p available on mac") + def test_launch_and_close_apple_p2p(self): + with pytest.raises(RuntimeError): + r.Zeroconf(apple_p2p=True) + def test_handle_response(self): def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: ttl = 120 diff --git a/zeroconf/_core.py b/zeroconf/_core.py index e859c7b1c..21bc9a6ab 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -24,8 +24,8 @@ import contextlib import errno import itertools -import platform import socket +import sys import threading from types import TracebackType # noqa # used in type hints from typing import Dict, List, Optional, Tuple, Type, Union, cast @@ -283,7 +283,7 @@ def __init__( # hook for threads self._GLOBAL_DONE = False - if apple_p2p and not platform.system() == 'Darwin': + if apple_p2p and sys.platform != 'darwin': raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) From 72db0c10246e948c15d9a53f60a54b835ccc67bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 13:29:39 -1000 Subject: [PATCH 0378/1433] Fix flakey ZeroconfServiceTypes types test (#662) --- tests/services/test_types.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 9d681667d..8cff44315 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -8,6 +8,7 @@ import unittest import socket import sys +import time import zeroconf as r from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes @@ -35,6 +36,8 @@ def test_integration_with_listener(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) + # Ensure we do not clear the cache until after the last broadcast is processed + time.sleep(0.2) _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) @@ -68,6 +71,8 @@ def test_integration_with_listener_v6_records(self): addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) zeroconf_registrar.register_service(info) + # Ensure we do not clear the cache until after the last broadcast is processed + time.sleep(0.2) _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) @@ -100,6 +105,8 @@ def test_integration_with_listener_ipv6(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) + # Ensure we do not clear the cache until after the last broadcast is processed + time.sleep(0.2) _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) @@ -132,6 +139,8 @@ def test_integration_with_subtype_and_listener(self): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.register_service(info) + # Ensure we do not clear the cache until after the last broadcast is processed + time.sleep(0.2) _clear_cache(zeroconf_registrar) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) From aaf8a368063f080be4a9c01fe671243e63bdf576 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:04:06 -1000 Subject: [PATCH 0379/1433] Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.aio (#658) --- tests/test_aio.py | 50 +++++++++++++++++++++++++++++++++++++++++-- zeroconf/aio.py | 54 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 388668b20..47c1e2d9d 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -5,13 +5,14 @@ """Unit tests for aio.py.""" import asyncio +import logging import socket import threading import unittest.mock import pytest -from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf +from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf, AsyncZeroconfServiceTypes from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered @@ -20,8 +21,19 @@ from . import _clear_cache +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) -from . import _clear_cache + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) @pytest.fixture(autouse=True) @@ -558,3 +570,37 @@ async def test_async_unregister_all_services() -> None: await aiozc.async_unregister_all_services() await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_zeroconf_service_types(): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await zeroconf_registrar.async_register_service(info) + await task + # Ensure we do not clear the cache until after the last broadcast is processed + await asyncio.sleep(0.2) + _clear_cache(zeroconf_registrar.zeroconf) + try: + service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar.zeroconf) + service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types + + finally: + await zeroconf_registrar.async_close() diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 8fff385f9..01211bb4f 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -22,15 +22,24 @@ import asyncio import contextlib from types import TracebackType # noqa # used in type hints -from typing import Awaitable, Callable, Dict, List, Optional, Type, Union +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union from ._core import NotifyListener, Zeroconf from ._exceptions import NonUniqueNameException from ._services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info +from ._services.types import ZeroconfServiceTypes from ._utils.aio import wait_condition_or_timeout from ._utils.net import IPVersion, InterfaceChoice, InterfacesType from ._utils.time import current_time_millis, millis_to_seconds -from .const import _BROWSER_TIME, _CHECK_TIME, _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _UNREGISTER_TIME +from .const import ( + _BROWSER_TIME, + _CHECK_TIME, + _LISTENER_TIME, + _MDNS_PORT, + _REGISTER_TIME, + _SERVICE_TYPE_ENUMERATION_NAME, + _UNREGISTER_TIME, +) __all__ = [ @@ -38,6 +47,7 @@ "AsyncServiceInfo", "AsyncServiceBrowser", "AsyncServiceListener", + "AsyncZeroconfServiceTypes", ] @@ -137,6 +147,7 @@ async def async_cancel(self) -> None: async def async_run(self) -> None: """Run the browser task.""" self.run() + await self.aiozc.zeroconf.async_wait_for_start() while True: timeout = self._seconds_to_wait() if timeout: @@ -164,6 +175,45 @@ async def async_run(self) -> None: ) +class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): + """An async version of ZeroconfServiceTypes.""" + + @classmethod + async def async_find( + cls, + aiozc: Optional['AsyncZeroconf'] = None, + timeout: Union[int, float] = 5, + interfaces: InterfacesType = InterfaceChoice.All, + ip_version: Optional[IPVersion] = None, + ) -> Tuple[str, ...]: + """ + Return all of the advertised services on any local networks. + + :param aiozc: AsyncZeroconf() instance. Pass in if already have an + instance running or if non-default interfaces are needed + :param timeout: seconds to wait for any responses + :param interfaces: interfaces to listen on. + :param ip_version: IP protocol version to use. + :return: tuple of service type strings + """ + local_zc = aiozc or AsyncZeroconf(interfaces=interfaces, ip_version=ip_version) + listener = cls() + async_browser = AsyncServiceBrowser( + local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener # type: ignore + ) + + # wait for responses + await asyncio.sleep(timeout) + + await async_browser.async_cancel() + + # close down anything we opened + if aiozc is None: + await local_zc.async_close() + + return tuple(sorted(listener.found_services)) + + class AsyncZeroconf: """Implementation of Zeroconf Multicast DNS Service Discovery From e76c7a5b76485efce0929ee8417aa2e0f262c04c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:08:01 -1000 Subject: [PATCH 0380/1433] Permit the ServiceBrowser to browse overlong types (#666) - At least one type "tivo-videostream" exists in the wild so we are permissive about what we will look for, and strict about what we will announce. Fixes #661 --- tests/test_exceptions.py | 1 - tests/test_services.py | 17 +++++++++++++++++ tests/utils/test_name.py | 26 ++++++++++++++++++++++++++ zeroconf/_utils/name.py | 6 +++++- 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/utils/test_name.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index cfc4c19d9..aa2f74f60 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -60,7 +60,6 @@ def test_bad_service_names(self): '_x-._tcp.local.', '_22._udp.local.', '_2-2._tcp.local.', - '_1234567890-abcde._udp.local.', '\x00._x._udp.local.', ) for name in bad_names_to_try: diff --git a/tests/test_services.py b/tests/test_services.py index 867c546a4..b8edc4acb 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1258,3 +1258,20 @@ def test_changing_name_updates_serviceinfo_key(): assert info_service.key == "mytesthome._homeassistant._tcp.local." info_service.name = "YourTestHome._homeassistant._tcp.local." assert info_service.key == "yourtesthome._homeassistant._tcp.local." + + +def test_servicebrowser_uses_non_strict_names(): + """Verify we can look for technically invalid names as we cannot change what others do.""" + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + browser = ServiceBrowser(zc, ["_tivo-videostream._tcp.local."], [on_service_state_change]) + browser.cancel() + + # Still fail on completely invalid + with pytest.raises(r.BadTypeInNameException): + browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) + zc.close() diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py new file mode 100644 index 000000000..6f8b417d9 --- /dev/null +++ b/tests/utils/test_name.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for zeroconf._utils.name.""" + +import pytest + +from zeroconf._utils import name as nameutils +from zeroconf import BadTypeInNameException + + +def test_service_type_name_overlong_type(): + """Test overlong service_type_name type.""" + with pytest.raises(BadTypeInNameException): + nameutils.service_type_name("Tivo1._tivo-videostream._tcp.local.") + nameutils.service_type_name("Tivo1._tivo-videostream._tcp.local.", strict=False) + + +def test_service_type_name_overlong_full_name(): + """Test overlong service_type_name full name.""" + long_name = "Tivo1Tivo1Tivo1Tivo1Tivo1Tivo1Tivo1Tivo1" * 100 + with pytest.raises(BadTypeInNameException): + nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.") + with pytest.raises(BadTypeInNameException): + nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.", strict=False) diff --git a/zeroconf/_utils/name.py b/zeroconf/_utils/name.py index 10a0ccf87..c59ac33ad 100644 --- a/zeroconf/_utils/name.py +++ b/zeroconf/_utils/name.py @@ -74,6 +74,9 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis :param type_: Type, SubType or service name to validate :return: fully qualified service name (eg: _http._tcp.local.) """ + if len(type_) > 256: + # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 + raise BadTypeInNameException("Full name (%s) must be > 256 bytes" % type_) if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') @@ -104,7 +107,8 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis test_service_name = service_name[1:] - if len(test_service_name) > 15: + if strict and len(test_service_name) > 15: + # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % test_service_name) if '--' in test_service_name: From 481cc42d000f5b0258f1be3b6df7cb7b24428b7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:15:42 -1000 Subject: [PATCH 0381/1433] Update async_browser.py example to use AsyncZeroconfServiceTypes (#665) --- examples/async_browser.py | 46 ++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/examples/async_browser.py b/examples/async_browser.py index 4a3861cb6..cba30223d 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -8,10 +8,10 @@ import argparse import asyncio import logging -from typing import cast +from typing import Any, Optional, cast from zeroconf import IPVersion, ServiceStateChange -from zeroconf.aio import AsyncServiceBrowser, AsyncZeroconf +from zeroconf.aio import AsyncServiceBrowser, AsyncZeroconf, AsyncZeroconfServiceTypes def async_on_service_state_change( @@ -43,11 +43,39 @@ async def async_display_service_info(zeroconf: AsyncZeroconf, service_type: str, print('\n') +class AsyncRunner: + def __init__(self, args: Any) -> None: + self.args = args + self.aiobrowser: Optional[AsyncServiceBrowser] = None + self.aiozc: Optional[AsyncZeroconf] = None + + async def async_run(self) -> None: + self.aiozc = AsyncZeroconf(ip_version=ip_version) + + services = ["_http._tcp.local.", "_hap._tcp.local."] + if self.args.find: + services = list( + await AsyncZeroconfServiceTypes.async_find(aiozc=self.aiozc, ip_version=ip_version) + ) + + print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) + self.aiobrowser = AsyncServiceBrowser(self.aiozc, services, handlers=[async_on_service_state_change]) + while True: + await asyncio.sleep(1) + + async def async_close(self) -> None: + assert self.aiozc is not None + assert self.aiobrowser is not None + await self.aiobrowser.async_cancel() + await self.aiozc.async_close() + + if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() parser.add_argument('--debug', action='store_true') + parser.add_argument('--find', action='store_true', help='Browse all available services') version_group = parser.add_mutually_exclusive_group() version_group.add_argument('--v6', action='store_true') version_group.add_argument('--v6-only', action='store_true') @@ -62,17 +90,9 @@ async def async_display_service_info(zeroconf: AsyncZeroconf, service_type: str, else: ip_version = IPVersion.V4Only - aiozc = AsyncZeroconf(ip_version=ip_version) - - services = ["_http._tcp.local.", "_hap._tcp.local."] - print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) - aiobrowser = AsyncServiceBrowser(aiozc, services, handlers=[async_on_service_state_change]) - loop = asyncio.get_event_loop() + runner = AsyncRunner(args) try: - loop.run_forever() + loop.run_until_complete(runner.async_run()) except KeyboardInterrupt: - pass - finally: - loop.run_until_complete(aiobrowser.async_cancel()) - loop.run_until_complete(aiozc.async_close()) + loop.run_until_complete(runner.async_close()) From 75347b4e30429e130716b666da52953700f0f8e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:25:17 -1000 Subject: [PATCH 0382/1433] Add missing coverage for ServiceListener (#668) --- tests/test_services.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index b8edc4acb..fa343af39 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1275,3 +1275,27 @@ def on_service_state_change(zeroconf, service_type, state_change, name): with pytest.raises(r.BadTypeInNameException): browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) zc.close() + + +def test_servicelisteners_raise_not_implemented(): + """Verify service listeners raise when one of the methods is not implemented.""" + + class MyPartialListener(r.ServiceListener): + """A listener that does not implement anything.""" + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + with pytest.raises(NotImplementedError): + MyPartialListener().add_service( + zc, "_tivo-videostream._tcp.local.", "Tivo1._tivo-videostream._tcp.local." + ) + with pytest.raises(NotImplementedError): + MyPartialListener().remove_service( + zc, "_tivo-videostream._tcp.local.", "Tivo1._tivo-videostream._tcp.local." + ) + with pytest.raises(NotImplementedError): + MyPartialListener().update_service( + zc, "_tivo-videostream._tcp.local.", "Tivo1._tivo-videostream._tcp.local." + ) + + zc.close() From d59fb8be29d8602ad66d89f595b26671a528fd77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:32:12 -1000 Subject: [PATCH 0383/1433] Add missing coverage for ServiceInfo address changes (#669) --- tests/test_services.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index fa343af39..45a5b91ed 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1299,3 +1299,36 @@ class MyPartialListener(r.ServiceListener): ) zc.close() + + +def test_serviceinfo_address_updates(): + """Verify adding/removing/setting addresses on ServiceInfo.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + + # Verify addresses and parsed_addresses are mutually exclusive + with pytest.raises(TypeError): + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + parsed_addresses=["10.0.1.2"], + ) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info_service.addresses = [socket.inet_aton("10.0.1.3")] + assert info_service.addresses == [socket.inet_aton("10.0.1.3")] From d274cd3a3409997b764c49d3eae7e8ee2fba33b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:35:58 -1000 Subject: [PATCH 0384/1433] Add test for sending unicast responses (#670) --- tests/test_core.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 19ab81d10..8cb04369b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -389,3 +389,30 @@ def test_get_service_info_failure_path(): zc = Zeroconf(interfaces=['127.0.0.1']) assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None zc.close() + + +def test_sending_unicast(): + """Test sending unicast response.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + entry = r.DNSText( + "didnotcrashincoming._crash._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + generated.add_answer_at_time(entry, 0) + zc.send(generated, "2001:db8::1", const._MDNS_PORT) # https://www.iana.org/go/rfc3849 + time.sleep(0.2) + assert zc.cache.get(entry) is None + + zc.send(generated, "198.51.100.0", const._MDNS_PORT) # Documentation (TEST-NET-2) + time.sleep(0.2) + assert zc.cache.get(entry) is None + + zc.send(generated) + time.sleep(0.2) + assert zc.cache.get(entry) is not None + + zc.close() From 8535110dd661ce406904930994a9f86faf897597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 14:41:26 -1000 Subject: [PATCH 0385/1433] Add oversized packet to the invalid packet test (#671) --- tests/test_core.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 8cb04369b..1e6d0d92c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -308,9 +308,16 @@ def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): parsed = r.DNSIncoming(packet) assert parsed.valid is False + # Invalid Packet mock_out = unittest.mock.Mock() mock_out.packets = lambda: [packet] zc.send(mock_out) + + # Invalid oversized packet + mock_out = unittest.mock.Mock() + mock_out.packets = lambda: [packet * 1000] + zc.send(mock_out) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) entry = r.DNSText( "didnotcrashincoming._crash._tcp.local.", From ba2a4f960d0f9478198968a1466a8b48c963b772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 16:22:52 -1000 Subject: [PATCH 0386/1433] Make calculation of times in DNSRecord lazy (#676) - Most of the time we only check one of the time attrs or none at all. Wait to calculate them until they are requested. --- zeroconf/_dns.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 41daee4e4..073b95f7a 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -146,9 +146,9 @@ def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) - super().__init__(name, type_, class_) self.ttl = ttl self.created = current_time_millis() - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) + self._expiration_time: Optional[float] = None + self._stale_time: Optional[float] = None + self._recent_time: Optional[float] = None def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" @@ -157,10 +157,7 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" - for record in msg.answers: - if self.suppressed_by_answer(record): - return True - return False + return any(self.suppressed_by_answer(record) for record in msg.answers) def suppressed_by_answer(self, other: 'DNSRecord') -> bool: """Returns true if another record has same name, type and class, @@ -175,18 +172,26 @@ def get_expiration_time(self, percent: int) -> float: # TODO: Switch to just int here def get_remaining_ttl(self, now: float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" + if self._expiration_time is None: + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) return max(0, millis_to_seconds(self._expiration_time - now)) def is_expired(self, now: float) -> bool: """Returns true if this record has expired.""" + if self._expiration_time is None: + self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) return self._expiration_time <= now def is_stale(self, now: float) -> bool: """Returns true if this record is at least half way expired.""" + if self._stale_time is None: + self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) return self._stale_time <= now def is_recent(self, now: float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" + if self._recent_time is None: + self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) return self._recent_time > now def reset_ttl(self, other: 'DNSRecord') -> None: @@ -194,9 +199,9 @@ def reset_ttl(self, other: 'DNSRecord') -> None: another record.""" self.created = other.created self.ttl = other.ttl - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) + self._expiration_time = None + self._stale_time = None + self._recent_time = None def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use """Abstract method""" From 57c94bb25e056e1827f15c234d7e0bcb5702a0e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 16:26:39 -1000 Subject: [PATCH 0387/1433] Remove unreachable BadTypeInNameException check in _ServiceBrowser (#677) --- zeroconf/_services/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 857197bd7..19fdccaf8 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -168,8 +168,8 @@ def __init__( assert handlers or listener, 'You need to specify at least one handler' self.types = set(type_ if isinstance(type_, list) else [type_]) # type: Set[str] for check_type_ in self.types: - if not check_type_.endswith(service_type_name(check_type_, strict=False)): - raise BadTypeInNameException + # Will generate BadTypeInNameException on a bad name + service_type_name(check_type_, strict=False) self.zc = zc self.addr = addr self.port = port From d3d439ad5d475cff094a4ea83f19d17939527021 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 16:35:57 -1000 Subject: [PATCH 0388/1433] Allow unregistering a service multiple times (#679) --- tests/services/test_registry.py | 23 +++++++++++++++++++++++ zeroconf/_services/registry.py | 2 ++ 2 files changed, 25 insertions(+) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 52726a041..496cc629b 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -28,6 +28,29 @@ def test_only_register_once(self): registry.remove(info) registry.add(info) + def test_unregister_multiple_times(self): + """Verify we can unregister a service multiple times. + + In production unregister_service and unregister_all_services + may happen at the same time during shutdown. We want to treat + this as non-fatal since its expected to happen and it is unlikely + that the callers know about each other. + """ + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + registry = r.ServiceRegistry() + registry.add(info) + self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) + registry.remove(info) + registry.remove(info) + def test_lookups(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 244ff294e..8ec34120a 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -106,6 +106,8 @@ def _add(self, info: ServiceInfo) -> None: def _remove(self, infos: List[ServiceInfo]) -> None: """Remove a services under the lock.""" for info in infos: + if info.key not in self._services: + continue old_service_info = self._services[info.key] self.types[old_service_info.type.lower()].remove(info.key) self.servers[old_service_info.server_key].remove(info.key) From 691c29eeb049e17a12d6f0a6e3bce2c3f8c2aa02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 16:36:20 -1000 Subject: [PATCH 0389/1433] Add DNSRRSet class for quick hashtable lookups of records (#678) - This class will be used to do fast checks to see if records should be suppressed by a set of answers. --- tests/test_dns.py | 98 ++++++++++++++++++----------------------------- zeroconf/_dns.py | 20 +++++++++- 2 files changed, 57 insertions(+), 61 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 664fccdec..eab2f2a3e 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -15,6 +15,7 @@ import zeroconf as r from zeroconf import DNSIncoming, const, current_time_millis +from zeroconf._dns import DNSRRSet from zeroconf import ( DNSHinfo, DNSText, @@ -900,6 +901,8 @@ def test_dns_record_hashablity_does_not_consider_ttl(): assert len(record_set) == 1 record3_dupe = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') + assert record2 == record3_dupe + assert record2.__hash__() == record3_dupe.__hash__() record_set.add(record3_dupe) assert len(record_set) == 1 @@ -941,6 +944,8 @@ def test_dns_hinfo_record_hashablity(): assert len(record_set) == 2 hinfo2_dupe = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') + assert hinfo2 == hinfo2_dupe + assert hinfo2.__hash__() == hinfo2_dupe.__hash__() record_set.add(hinfo2_dupe) assert len(record_set) == 2 @@ -958,71 +963,13 @@ def test_dns_pointer_record_hashablity(): assert len(record_set) == 2 ptr2_dupe = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') + assert ptr2 == ptr2 + assert ptr2.__hash__() == ptr2_dupe.__hash__() record_set.add(ptr2_dupe) assert len(record_set) == 2 -def test_dns_text_record_hashablity(): - """Test DNSText are hashable.""" - text1 = r.DNSText('irrelevant', 0, 0, 0, b'12345678901') - text2 = r.DNSText('irrelevant', 1, 0, 0, b'12345678901') - text3 = r.DNSText('irrelevant', 0, 1, 0, b'12345678901') - text4 = r.DNSText('irrelevant', 0, 0, 1, b'12345678901') - text5 = r.DNSText('irrelevant', 0, 0, 0, b'ABCDEFGHIJK') - - record_set = set([text1, text2, text3, text4, text5]) - assert len(record_set) == 5 - - record_set.add(text1) - assert len(record_set) == 5 - - text1_dupe = r.DNSText('irrelevant', 0, 0, 0, b'12345678901') - - record_set.add(text1_dupe) - assert len(record_set) == 5 - - -def test_dns_text_record_hashablity(): - """Test DNSText are hashable.""" - text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') - text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') - text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') - text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') - - record_set = set([text1, text2, text3, text4]) - - assert len(record_set) == 4 - - record_set.add(text1) - assert len(record_set) == 4 - - text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') - - record_set.add(text1_dupe) - assert len(record_set) == 4 - - -def test_dns_text_record_hashablity(): - """Test DNSText are hashable.""" - text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') - text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') - text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') - text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') - - record_set = set([text1, text2, text3, text4]) - - assert len(record_set) == 4 - - record_set.add(text1) - assert len(record_set) == 4 - - text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') - - record_set.add(text1_dupe) - assert len(record_set) == 4 - - def test_dns_text_record_hashablity(): """Test DNSText are hashable.""" text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') @@ -1038,6 +985,8 @@ def test_dns_text_record_hashablity(): assert len(record_set) == 4 text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + assert text1 == text1_dupe + assert text1.__hash__() == text1_dupe.__hash__() record_set.add(text1_dupe) assert len(record_set) == 4 @@ -1060,6 +1009,35 @@ def test_dns_service_record_hashablity(): srv1_dupe = r.DNSService( 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a' ) + assert srv1 == srv1_dupe + assert srv1.__hash__() == srv1_dupe.__hash__() record_set.add(srv1_dupe) assert len(record_set) == 4 + + +def test_rrset_does_not_consider_ttl(): + """Test DNSRRSet does not consider the ttl in the hash.""" + + longarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 100, b'same') + shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') + longaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 100, b'same') + shortaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 10, b'same') + + rrset = DNSRRSet([longarec, shortaaaarec]) + + assert rrset.suppresses(longarec) + assert rrset.suppresses(shortarec) + assert not rrset.suppresses(longaaaarec) + assert rrset.suppresses(shortaaaarec) + + verylongarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1000, b'same') + longarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 100, b'same') + mediumarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 60, b'same') + shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') + + rrset2 = DNSRRSet([mediumarec]) + assert not rrset2.suppresses(verylongarec) + assert rrset2.suppresses(longarec) + assert rrset2.suppresses(mediumarec) + assert rrset2.suppresses(shortarec) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 073b95f7a..ed4390c92 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -23,7 +23,7 @@ import enum import socket import struct -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union, cast from ._exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log @@ -970,3 +970,21 @@ def packets(self) -> List[bytes]: break self.state = self.State.finished return self.packets_data + + +class DNSRRSet: + """A set of dns records independent of the ttl.""" + + def __init__(self, records: Iterable[DNSRecord]) -> None: + """Create an RRset from records.""" + self._records = records + self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None + + def suppresses(self, record: DNSRecord) -> bool: + """Returns true if any answer in the rrset can suffice for the + information held in this record.""" + if self._lookup is None: + # Build the hash table so we can lookup the record independent of the ttl + self._lookup = {record: record for record in self._records} + other = self._lookup.get(record) + return bool(other and other.ttl > (record.ttl / 2)) From e5ea9bb6c0a3bce7d05241f275a205ddd9e6b615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 16:45:33 -1000 Subject: [PATCH 0390/1433] Use DNSRRSet for known answer suppression (#680) - DNSRRSet uses hash table lookups under the hood which is much faster than the linear searches used by DNSRecord.suppressed_by --- zeroconf/_handlers.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 15a853b2a..f8590e865 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -25,7 +25,7 @@ from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union from ._cache import DNSCache -from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord from ._logger import log from ._services import RecordUpdateListener from ._services.registry import ServiceRegistry @@ -178,7 +178,7 @@ def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: def _answer_service_type_enumeration_query( self, - msg: DNSIncoming, + answers_rrset: DNSRRSet, ) -> Set[DNSRecord]: """Provide an answer to a service type enumeration query. @@ -188,68 +188,70 @@ def _answer_service_type_enumeration_query( DNSPointer(_SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype) for stype in self.registry.get_types() ) - records -= set(dns_pointer for dns_pointer in records if dns_pointer.suppressed_by(msg)) + records -= set(dns_pointer for dns_pointer in records if answers_rrset.suppresses(dns_pointer)) return records def _add_pointer_answers( - self, name: str, msg: DNSIncoming, answers: Set[DNSRecord], additionals: Set[DNSRecord] + self, name: str, answers_rrset: DNSRRSet, answers: Set[DNSRecord], additionals: Set[DNSRecord] ) -> None: """Answer PTR/ANY question.""" for service in self.registry.get_infos_type(name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer() - if not dns_pointer.suppressed_by(msg): - answers.add(service.dns_pointer()) + if not answers_rrset.suppresses(dns_pointer): + answers.add(dns_pointer) additionals.add(service.dns_service()) additionals.add(service.dns_text()) additionals.update(service.dns_addresses()) - def _add_address_answers(self, name: str, msg: DNSIncoming, answers: Set[DNSRecord], type_: int) -> None: + def _add_address_answers( + self, name: str, answers_rrset: DNSRRSet, answers: Set[DNSRecord], type_: int + ) -> None: """Answer A/AAAA/ANY question.""" for service in self.registry.get_infos_server(name): for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_]): - if not dns_address.suppressed_by(msg): + if not answers_rrset.suppresses(dns_address): answers.add(dns_address) def _answer_question( - self, msg: DNSIncoming, question: DNSQuestion + self, answers_rrset: DNSRRSet, question: DNSQuestion ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: answers: Set[DNSRecord] = set() additionals: Set[DNSRecord] = set() type_ = question.type if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question.name, msg, answers, additionals) + self._add_pointer_answers(question.name, answers_rrset, answers, additionals) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question.name, msg, answers, type_) + self._add_address_answers(question.name, answers_rrset, answers, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): service = self.registry.get_info_name(question.name) # type: ignore if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): dns_service = service.dns_service() - if not dns_service.suppressed_by(msg): + if not answers_rrset.suppresses(dns_service): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. answers.add(service.dns_service()) additionals.update(service.dns_addresses()) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service.dns_text() - if not dns_text.suppressed_by(msg): + if not answers_rrset.suppresses(dns_text): answers.add(service.dns_text()) return answers, additionals def _answer_any_question( - self, msg: DNSIncoming, question: DNSQuestion + self, answers_rrset: DNSRRSet, question: DNSQuestion ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: empty_additionals: Set[DNSRecord] = set() - return self._answer_service_type_enumeration_query(msg), empty_additionals + return self._answer_service_type_enumeration_query(answers_rrset), empty_additionals - return self._answer_question(msg, question) + return self._answer_question(answers_rrset, question) def response( # pylint: disable=unused-argument self, msg: DNSIncoming, addr: Optional[str], port: int @@ -257,9 +259,10 @@ def response( # pylint: disable=unused-argument """Deal with incoming query packets. Provides a response if possible.""" ucast_source = port != _MDNS_PORT query_res = _QueryResponse(self.cache, msg, ucast_source) + answers_rrset = DNSRRSet(msg.answers) for question in msg.questions: - all_answers = self._answer_any_question(msg, question) + all_answers = self._answer_any_question(answers_rrset, question) if not ucast_source and question.unicast: query_res.add_qu_question_response(*all_answers) else: From d2b5e51d0dcde801e171a4c1e43ef1f86abde825 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 17:12:06 -1000 Subject: [PATCH 0391/1433] Check if SO_REUSEPORT exists instead of using an exception catch (#682) --- zeroconf/_utils/net.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 963faf558..8d1b60bc6 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -196,13 +196,9 @@ def new_socket( # pylint: disable=too-many-branches # versions of Python have SO_REUSEPORT available. # Catch OSError and socket.error for kernel versions <3.9 because lacking # SO_REUSEPORT support. - try: - reuseport = socket.SO_REUSEPORT - except AttributeError: - pass - else: + if hasattr(socket, 'SO_REUSEPORT'): try: - s.setsockopt(socket.SOL_SOCKET, reuseport, 1) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # pylint: disable=no-member except OSError as err: if err.errno != errno.ENOPROTOOPT: raise From 00b972c062fd0ed3f2fcc4ceaec84c43b9a613be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 17:12:26 -1000 Subject: [PATCH 0392/1433] Fix logic reversal in apple_p2p test (#681) --- tests/test_core.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 1e6d0d92c..1a48c5ea3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -100,16 +100,16 @@ def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) rv.close() - @unittest.skipIf(sys.platform != 'darwin', reason="apple_p2p only available on mac") - def test_launch_and_close_apple_p2p(self): - rv = r.Zeroconf(apple_p2p=True) - rv.close() - - @unittest.skipIf(sys.platform == 'darwin', reason="apple_p2p available on mac") - def test_launch_and_close_apple_p2p(self): + @unittest.skipIf(sys.platform == 'darwin', reason="apple_p2p failure path not testable on mac") + def test_launch_and_close_apple_p2p_not_mac(self): with pytest.raises(RuntimeError): r.Zeroconf(apple_p2p=True) + @unittest.skipIf(sys.platform != 'darwin', reason="apple_p2p happy path only testable on mac") + def test_launch_and_close_apple_p2p_on_mac(self): + rv = r.Zeroconf(apple_p2p=True) + rv.close() + def test_handle_response(self): def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: ttl = 120 From 95ddb36de64ddf3be9e93f07a1daa8389410f73d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 17:19:24 -1000 Subject: [PATCH 0393/1433] Add coverage to verify ServiceInfo tolerates bytes or string in the txt record (#683) --- tests/test_services.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index 45a5b91ed..521efd1f0 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1332,3 +1332,48 @@ def test_serviceinfo_address_updates(): ) info_service.addresses = [socket.inet_aton("10.0.1.3")] assert info_service.addresses == [socket.inet_aton("10.0.1.3")] + + +def test_serviceinfo_accepts_bytes_or_string_dict(): + """Verify a bytes or string dict can be passed to ServiceInfo.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + addresses = [socket.inet_aton("10.0.1.2")] + server_name = "ash-2.local." + info_service = ServiceInfo( + type_, '%s.%s' % (name, type_), 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {b'path': '/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': b'/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' From 6fd1bf2364da4fc2949a905d2e4acb7da003e84d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 17:39:48 -1000 Subject: [PATCH 0394/1433] Update changelog (#684) --- README.rst | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.rst b/README.rst index 07a9b2d63..06a6c64e4 100644 --- a/README.rst +++ b/README.rst @@ -219,6 +219,49 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Check if SO_REUSEPORT exists instead of using an exception catch (#682) @bdraco + +* Use DNSRRSet for known answer suppression (#680) @bdraco + + DNSRRSet uses hash table lookups under the hood which + is much faster than the linear searches used by + DNSRecord.suppressed_by + +* Add DNSRRSet class for quick hashtable lookups of records (#678) @bdraco + + This class will be used to do fast checks to see + if records should be suppressed by a set of answers. + +* Allow unregistering a service multiple times (#679) @bdraco + +* Remove unreachable BadTypeInNameException check in _ServiceBrowser (#677) @bdraco + +* Update async_browser.py example to use AsyncZeroconfServiceTypes (#665) @bdraco + +* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.aio (#658) @bdraco + +* Remove all calls to the executor in AsyncZeroconf (#653) @bdraco + +* Set __all__ in zeroconf.aio to ensure private functions do now show in the docs (#652) @bdraco + +* Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) @bdraco + +* Add async_unregister_all_services to AsyncZeroconf (#649) @bdraco + +* Ensure services are removed from the registry when calling unregister_all_services (#644) @bdraco + + There was a race condition where a query could be answered for a service + in the registry while goodbye packets which could result a fresh record + being broadcast after the goodbye if a query came in at just the right + time. To avoid this, we now remove the services from the registry right + after we generate the goodbye packet + +* Use ServiceInfo.key/ServiceInfo.server_key instead of lowering in ServiceRegistry (#647) @bdraco + +* Ensure the ServiceInfo.key gets updated when the name is changed externally (#645) @bdraco + +* Ensure AsyncZeroconf.async_close can be called multiple times like Zeroconf.close (#638) @bdraco + * Ensure eventloop shutdown is threadsafe (#636) @bdraco * Return early in the shutdown/close process (#632) @bdraco From e816053af4d900f57100c07c48f384165ba28b9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 19:17:54 -1000 Subject: [PATCH 0395/1433] Add truncated property to DNSMessage to lookup the TC bit (#686) --- tests/test_dns.py | 24 ++++++++++++------------ zeroconf/_dns.py | 6 ++++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index eab2f2a3e..4096aa94c 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -378,15 +378,15 @@ def test_many_questions_with_many_known_answers(self): parsed1 = r.DNSIncoming(packets[0]) assert len(parsed1.questions) == 30 assert len(parsed1.answers) == 88 - assert parsed1.flags & const._FLAGS_TC == const._FLAGS_TC + assert parsed1.truncated parsed2 = r.DNSIncoming(packets[1]) assert len(parsed2.questions) == 0 assert len(parsed2.answers) == 101 - assert parsed2.flags & const._FLAGS_TC == const._FLAGS_TC + assert parsed2.truncated parsed3 = r.DNSIncoming(packets[2]) assert len(parsed3.questions) == 0 assert len(parsed3.answers) == 11 - assert parsed3.flags & const._FLAGS_TC == 0 + assert not parsed3.truncated def test_massive_probe_packet_split(self): """Test probe with many authorative answers.""" @@ -419,15 +419,15 @@ def test_massive_probe_packet_split(self): assert parsed1.questions[0].unicast is True assert len(parsed1.questions) == 30 assert parsed1.num_authorities == 88 - assert parsed1.flags & const._FLAGS_TC == const._FLAGS_TC + assert parsed1.truncated parsed2 = r.DNSIncoming(packets[1]) assert len(parsed2.questions) == 0 assert parsed2.num_authorities == 101 - assert parsed2.flags & const._FLAGS_TC == const._FLAGS_TC + assert parsed2.truncated parsed3 = r.DNSIncoming(packets[2]) assert len(parsed3.questions) == 0 assert parsed3.num_authorities == 11 - assert parsed3.flags & const._FLAGS_TC == 0 + assert not parsed3.truncated def test_only_one_answer_can_by_large(self): """Test that only the first answer in each packet can be large. @@ -823,15 +823,15 @@ def test_tc_bit_in_query_packet(): assert len(packets) == 3 first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & const._FLAGS_TC == const._FLAGS_TC + assert first_packet.truncated assert first_packet.valid is True second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & const._FLAGS_TC == const._FLAGS_TC + assert second_packet.truncated assert second_packet.valid is True third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & const._FLAGS_TC == 0 + assert not third_packet.truncated assert third_packet.valid is True @@ -855,15 +855,15 @@ def test_tc_bit_not_set_in_answer_packet(): assert len(packets) == 3 first_packet = r.DNSIncoming(packets[0]) - assert first_packet.flags & const._FLAGS_TC == 0 + assert not first_packet.truncated assert first_packet.valid is True second_packet = r.DNSIncoming(packets[1]) - assert second_packet.flags & const._FLAGS_TC == 0 + assert not second_packet.truncated assert second_packet.valid is True third_packet = r.DNSIncoming(packets[2]) - assert third_packet.flags & const._FLAGS_TC == 0 + assert not third_packet.truncated assert third_packet.valid is True diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index ed4390c92..e4b1080f3 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -395,6 +395,11 @@ def is_response(self) -> bool: """Returns true if this is a response.""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + @property + def truncated(self) -> bool: + """Returns true if this is a truncated.""" + return (self.flags & _FLAGS_TC) == _FLAGS_TC + class DNSIncoming(DNSMessage, QuietLogger): @@ -428,6 +433,7 @@ def __repr__(self) -> str: [ 'id=%s' % self.id, 'flags=%s' % self.flags, + 'truncated=%s' % self.truncated, 'n_q=%s' % self.num_questions, 'n_ans=%s' % self.num_answers, 'n_auth=%s' % self.num_authorities, From 4865d2ba782d0313c0f7d878f5887453086febaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jun 2021 23:47:55 -1000 Subject: [PATCH 0396/1433] Remove sleeps from services types test (#688) - Instead of registering the services and doing the broadcast we now put them in the registry directly. --- tests/services/test_types.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 8cff44315..6f6645db4 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -35,10 +35,7 @@ def test_integration_with_listener(self): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - zeroconf_registrar.register_service(info) - # Ensure we do not clear the cache until after the last broadcast is processed - time.sleep(0.2) - _clear_cache(zeroconf_registrar) + zeroconf_registrar.registry.add(info) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types @@ -70,10 +67,7 @@ def test_integration_with_listener_v6_records(self): "ash-2.local.", addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) - zeroconf_registrar.register_service(info) - # Ensure we do not clear the cache until after the last broadcast is processed - time.sleep(0.2) - _clear_cache(zeroconf_registrar) + zeroconf_registrar.registry.add(info) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert type_ in service_types @@ -104,10 +98,7 @@ def test_integration_with_listener_ipv6(self): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - zeroconf_registrar.register_service(info) - # Ensure we do not clear the cache until after the last broadcast is processed - time.sleep(0.2) - _clear_cache(zeroconf_registrar) + zeroconf_registrar.registry.add(info) try: service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) assert type_ in service_types @@ -138,10 +129,7 @@ def test_integration_with_subtype_and_listener(self): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - zeroconf_registrar.register_service(info) - # Ensure we do not clear the cache until after the last broadcast is processed - time.sleep(0.2) - _clear_cache(zeroconf_registrar) + zeroconf_registrar.registry.add(info) try: service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) assert discovery_type in service_types From 8a25a44ec5e4f21c6bdb282fefb8f6c2d296a70b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 00:03:55 -1000 Subject: [PATCH 0397/1433] Implement multi-packet known answer supression (#687) - Implements https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - Fixes https://github.com/jstasiak/python-zeroconf/issues/499 --- tests/test_core.py | 184 ++++++++++++++++++++++++++++++++++++++++- tests/test_handlers.py | 144 +++++++++++++++++++++++--------- zeroconf/_core.py | 38 ++++++++- zeroconf/_handlers.py | 8 +- 4 files changed, 324 insertions(+), 50 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 1a48c5ea3..8f459da15 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,6 +4,7 @@ """ Unit tests for zeroconf._core """ +import asyncio import itertools import logging import os @@ -18,7 +19,7 @@ import zeroconf as r from zeroconf import _core, const, ServiceBrowser, Zeroconf -from . import has_working_ipv6, _inject_response +from . import has_working_ipv6, _clear_cache, _inject_response log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -423,3 +424,184 @@ def test_sending_unicast(): assert zc.cache.get(entry) is not None zc.close() + + +def test_tc_bit_defers(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_tcbitdefer._tcp.local." + name = "knownname" + name2 = "knownname2" + name3 = "knownname3" + + registration_name = "%s.%s" % (name, type_) + registration2_name = "%s.%s" % (name2, type_) + registration3_name = "%s.%s" % (name3, type_) + + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + server_name2 = "ash-3.local." + server_name3 = "ash-4.local." + + info = r.ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = r.ServiceInfo( + type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + info3 = r.ServiceInfo( + type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info) + zc.registry.add(info2) + zc.registry.add(info3) + + def threadsafe_query(*args): + async def make_query(): + zc.handle_query(*args) + + asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() + + now = r.current_time_millis() + _clear_cache(zc) + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + for _ in range(300): + # Add so many answers we end up with another packet + generated.add_answer_at_time(info.dns_pointer(), now) + generated.add_answer_at_time(info2.dns_pointer(), now) + generated.add_answer_at_time(info3.dns_pointer(), now) + packets = generated.packets() + assert len(packets) == 4 + expected_deferred = [] + source_ip = '203.0.113.13' + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert source_ip not in zc._deferred + assert source_ip not in zc._timers + + # unregister + zc.unregister_service(info) + zc.close() + + +def test_tc_bit_defers_last_response_missing(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knowndefer._tcp.local." + name = "knownname" + name2 = "knownname2" + name3 = "knownname3" + + registration_name = "%s.%s" % (name, type_) + registration2_name = "%s.%s" % (name2, type_) + registration3_name = "%s.%s" % (name3, type_) + + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + server_name2 = "ash-3.local." + server_name3 = "ash-4.local." + + info = r.ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = r.ServiceInfo( + type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + info3 = r.ServiceInfo( + type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info) + zc.registry.add(info2) + zc.registry.add(info3) + + def threadsafe_query(*args): + async def make_query(): + zc.handle_query(*args) + + asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() + + now = r.current_time_millis() + _clear_cache(zc) + source_ip = '203.0.113.12' + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + for _ in range(300): + # Add so many answers we end up with another packet + generated.add_answer_at_time(info.dns_pointer(), now) + generated.add_answer_at_time(info2.dns_pointer(), now) + generated.add_answer_at_time(info3.dns_pointer(), now) + packets = generated.packets() + assert len(packets) == 4 + expected_deferred = [] + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + timer1 = zc._timers[source_ip] + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + timer2 = zc._timers[source_ip] + if sys.version_info >= (3, 7): + assert timer1.cancelled() + assert timer2 != timer1 + + # Send the same packet again to similar multi interfaces + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + timer3 = zc._timers[source_ip] + if sys.version_info >= (3, 7): + assert not timer3.cancelled() + assert timer3 == timer2 + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(next_packet, source_ip, const._MDNS_PORT) + assert zc._deferred[source_ip] == expected_deferred + assert source_ip in zc._timers + timer4 = zc._timers[source_ip] + if sys.version_info >= (3, 7): + assert timer3.cancelled() + assert timer4 != timer3 + + for _ in range(7): + time.sleep(0.1) + if source_ip not in zc._timers: + break + + assert source_ip not in zc._deferred + assert source_ip not in zc._timers + + # unregister + zc.registry.remove(info) + zc.close() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 1e8109eb1..fcf094c5a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -96,9 +96,9 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - multicast_out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[ - 1 - ] + multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT + )[1] _process_outgoing_packet(multicast_out) # The additonals should all be suppresed since they are all in the answers section @@ -134,7 +134,9 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) _process_outgoing_packet( - zc.query_handler.response(r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT)[1] + zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT + )[1] ) assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -231,7 +233,7 @@ def test_ptr_optimization(): query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT ) assert unicast_out is None assert multicast_out is None @@ -243,7 +245,7 @@ def test_ptr_optimization(): query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), None, const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT ) assert multicast_out.id == query.id assert unicast_out is None @@ -271,49 +273,52 @@ def test_ptr_optimization(): def test_any_query_for_ptr(): """Test that queries for ANY will return PTR records.""" zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_knownservice._tcp.local." + type_ = "_anyptr._tcp.local." name = "knownname" registration_name = "%s.%s" % (name, type_) desc = {'path': '/~paulsm/'} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) - zc.register_service(info) + zc.registry.add(info) _clear_cache(zc) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.response(r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT) + _, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) assert multicast_out.answers[0][0].name == type_ assert multicast_out.answers[0][0].alias == registration_name # unregister - zc.unregister_service(info) + zc.registry.remove(info) zc.close() def test_aaaa_query(): """Test that queries for AAAA records work.""" zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_knownservice._tcp.local." + type_ = "_knownaaaservice._tcp.local." name = "knownname" registration_name = "%s.%s" % (name, type_) desc = {'path': '/~paulsm/'} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) - zc.register_service(info) + zc.registry.add(info) - _clear_cache(zc) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.response(r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT) + _, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) assert multicast_out.answers[0][0].address == ipv6_address # unregister - zc.unregister_service(info) + zc.registry.remove(info) zc.close() @@ -331,13 +336,15 @@ def test_unicast_response(): type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) # register - zc.register_service(info) + zc.registry.add(info) _clear_cache(zc) # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.response(r.DNSIncoming(query.packets()[0]), "1.2.3.4", 1234) + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", 1234 + ) for out in (unicast_out, multicast_out): assert out.id == query.id has_srv = has_txt = has_a = False @@ -356,7 +363,7 @@ def test_unicast_response(): assert has_srv and has_txt and has_a # unregister - zc.unregister_service(info) + zc.registry.remove(info) zc.close() @@ -413,7 +420,7 @@ def _validate_complete_response(query, out): query.add_question(question) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None _validate_complete_response(query, unicast_out) @@ -426,7 +433,7 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None _validate_complete_response(query, multicast_out) @@ -439,7 +446,7 @@ def _validate_complete_response(query, out): query.add_question(question) query.add_authorative_answer(info2.dns_pointer()) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) _validate_complete_response(query, unicast_out) _validate_complete_response(query, multicast_out) @@ -452,7 +459,7 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(query.packets()[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None _validate_complete_response(query, unicast_out) @@ -463,7 +470,7 @@ def _validate_complete_response(query, out): def test_known_answer_supression(): zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_knownservice._tcp.local." + type_ = "_knownanswersv8._tcp.local." name = "knownname" registration_name = "%s.%s" % (name, type_) desc = {'path': '/~paulsm/'} @@ -471,7 +478,7 @@ def test_known_answer_supression(): info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.register_service(info) + zc.registry.add(info) now = current_time_millis() _clear_cache(zc) @@ -481,7 +488,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert multicast_out is not None and multicast_out.answers @@ -492,7 +499,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None # If the answer is suppressed, the additional should be suppresed as well @@ -504,7 +511,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert multicast_out is not None and multicast_out.answers @@ -516,7 +523,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert not multicast_out or not multicast_out.answers @@ -527,7 +534,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert multicast_out is not None and multicast_out.answers @@ -538,7 +545,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None # If the answer is suppressed, the additional should be suppresed as well @@ -550,7 +557,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert multicast_out is not None and multicast_out.answers @@ -561,19 +568,73 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert not multicast_out or not multicast_out.answers # unregister - zc.unregister_service(info) + zc.registry.remove(info) + zc.close() + + +def test_multi_packet_known_answer_supression(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_handlermultis._tcp.local." + name = "knownname" + name2 = "knownname2" + name3 = "knownname3" + + registration_name = "%s.%s" % (name, type_) + registration2_name = "%s.%s" % (name2, type_) + registration3_name = "%s.%s" % (name3, type_) + + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + server_name2 = "ash-3.local." + server_name3 = "ash-4.local." + + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = ServiceInfo( + type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + info3 = ServiceInfo( + type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info) + zc.registry.add(info2) + zc.registry.add(info3) + + now = current_time_millis() + _clear_cache(zc) + # Test PTR supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + for _ in range(1000): + # Add so many answers we end up with another packet + generated.add_answer_at_time(info.dns_pointer(), now) + generated.add_answer_at_time(info2.dns_pointer(), now) + generated.add_answer_at_time(info3.dns_pointer(), now) + packets = generated.packets() + assert len(packets) > 1 + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + # unregister + zc.registry.remove(info) + zc.registry.remove(info2) + zc.registry.remove(info3) zc.close() def test_known_answer_supression_service_type_enumeration_query(): zc = Zeroconf(interfaces=['127.0.0.1']) - type_ = "_knownservice._tcp.local." + type_ = "_otherknown._tcp.local." name = "knownname" registration_name = "%s.%s" % (name, type_) desc = {'path': '/~paulsm/'} @@ -581,17 +642,17 @@ def test_known_answer_supression_service_type_enumeration_query(): info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.register_service(info) + zc.registry.add(info) - type_2 = "_knownservice2._tcp.local." + type_2 = "_otherknown2._tcp.local." name = "knownname" registration_name2 = "%s.%s" % (name, type_2) desc = {'path': '/~paulsm/'} server_name2 = "ash-3.local." - info = ServiceInfo( + info2 = ServiceInfo( type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.register_service(info) + zc.registry.add(info2) now = current_time_millis() _clear_cache(zc) @@ -601,7 +662,7 @@ def test_known_answer_supression_service_type_enumeration_query(): generated.add_question(question) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert multicast_out is not None and multicast_out.answers @@ -631,11 +692,12 @@ def test_known_answer_supression_service_type_enumeration_query(): ) packets = generated.packets() unicast_out, multicast_out = zc.query_handler.response( - r.DNSIncoming(packets[0]), "1.2.3.4", const._MDNS_PORT + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None assert not multicast_out or not multicast_out.answers # unregister - zc.unregister_service(info) + zc.registry.remove(info) + zc.registry.remove(info2) zc.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 21bc9a6ab..6aef35a32 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -24,6 +24,7 @@ import contextlib import errno import itertools +import random import socket import sys import threading @@ -71,6 +72,8 @@ _UNREGISTER_TIME, ) +_TC_DELAY_RANDOM_INTERVAL = (400, 500) + class NotifyListener: """Receive notifications Zeroconf.notify_all is called.""" @@ -302,6 +305,9 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None + self._deferred: Dict[str, List[DNSIncoming]] = {} + self._timers: Dict[str, asyncio.TimerHandle] = {} + self.start() def start(self) -> None: @@ -557,13 +563,37 @@ def handle_response(self, msg: DNSIncoming) -> None: are held in the cache, and listeners are notified.""" self.record_manager.updates_from_response(msg) - def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None: + def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: """Deal with incoming query packets. Provides a response if possible.""" - unicast_out, multicast_out = self.query_handler.response(msg, addr, port) - if unicast_out and unicast_out.answers: + if not msg.truncated: + self._respond_query(msg, addr, port) + return + + deferred = self._deferred.setdefault(addr, []) + # If we get the same packet on another iterface we ignore it + for incoming in reversed(deferred): + if incoming.data == msg.data: + return + deferred.append(msg) + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) + assert self.loop is not None + if addr in self._timers: + self._timers.pop(addr).cancel() + self._timers[addr] = self.loop.call_later(delay, self._respond_query, None, addr, port) + + def _respond_query(self, msg: Optional[DNSIncoming], addr: str, port: int) -> None: + """Respond to a query and reassemble any truncated deferred packets.""" + if addr in self._timers: + self._timers.pop(addr).cancel() + packets = self._deferred.pop(addr, []) + if msg: + packets.append(msg) + + unicast_out, multicast_out = self.query_handler.response(packets, addr, port) + if unicast_out: self.async_send(unicast_out, addr, port) - if multicast_out and multicast_out.answers: + if multicast_out: self.async_send(multicast_out, None, _MDNS_PORT) def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index f8590e865..a7e18360f 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -254,14 +254,14 @@ def _answer_any_question( return self._answer_question(answers_rrset, question) def response( # pylint: disable=unused-argument - self, msg: DNSIncoming, addr: Optional[str], port: int + self, msgs: List[DNSIncoming], addr: Optional[str], port: int ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: """Deal with incoming query packets. Provides a response if possible.""" ucast_source = port != _MDNS_PORT - query_res = _QueryResponse(self.cache, msg, ucast_source) - answers_rrset = DNSRRSet(msg.answers) + query_res = _QueryResponse(self.cache, msgs[0], ucast_source) + answers_rrset = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) - for question in msg.questions: + for question in itertools.chain(*[msg.questions for msg in msgs]): all_answers = self._answer_any_question(answers_rrset, question) if not ucast_source and question.unicast: query_res.add_qu_question_response(*all_answers) From b60f307d59e342983d1baa6040c3d997f84538ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 11:35:01 -1000 Subject: [PATCH 0398/1433] Remove AA flags from handlers test (#693) - The flag was added by mistake when copying from other tests --- tests/test_handlers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index fcf094c5a..8820b1850 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -230,7 +230,7 @@ def test_ptr_optimization(): zc.register_service(info) # Verify we won't respond for 1s with the same multicast - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT @@ -242,7 +242,7 @@ def test_ptr_optimization(): _clear_cache(zc) # Verify we will now respond - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT @@ -340,7 +340,7 @@ def test_unicast_response(): _clear_cache(zc) # query - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) unicast_out, multicast_out = zc.query_handler.response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", 1234 @@ -413,7 +413,7 @@ def _validate_complete_response(query, out): assert has_srv and has_txt and has_a # With QU should respond to only unicast when the answer has been recently multicast - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unique = True # Set the QU bit assert question.unicast is True @@ -427,7 +427,7 @@ def _validate_complete_response(query, out): _clear_cache(zc) # With QU should respond to only multicast since the response hasn't been seen since 75% of the ttl - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unique = True # Set the QU bit assert question.unicast is True @@ -439,7 +439,7 @@ def _validate_complete_response(query, out): _validate_complete_response(query, multicast_out) # With QU set and an authorative answer (probe) should respond to both unitcast and multicast since the response hasn't been seen since 75% of the ttl - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unique = True # Set the QU bit assert question.unicast is True @@ -453,7 +453,7 @@ def _validate_complete_response(query, out): _inject_response(zc, r.DNSIncoming(multicast_out.packets()[0])) # With the cache repopulated; should respond to only unicast when the answer has been recently multicast - query = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unique = True # Set the QU bit assert question.unicast is True From 993a82e414db8aadaee0e0475e178e75df417a71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 11:35:11 -1000 Subject: [PATCH 0399/1433] Move setting DNS created and ttl into its own function (#692) --- zeroconf/_dns.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index e4b1080f3..b55f77f0f 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -197,8 +197,12 @@ def is_recent(self, now: float) -> bool: def reset_ttl(self, other: 'DNSRecord') -> None: """Sets this record's TTL and created time to that of another record.""" - self.created = other.created - self.ttl = other.ttl + self._set_created_ttl(other.created, other.ttl) + + def _set_created_ttl(self, created: float, ttl: Union[float, int]) -> None: + """Set the created and ttl of a record.""" + self.created = created + self.ttl = ttl self._expiration_time = None self._stale_time = None self._recent_time = None From 0cdba98e65dd3dce2db8aa607e97e3b67b97721a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 12:49:12 -1000 Subject: [PATCH 0400/1433] Suppress additionals when answer is suppressed (#690) --- tests/test_handlers.py | 128 +++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 167 +++++++++++++++++------------------------ 2 files changed, 198 insertions(+), 97 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 8820b1850..11c077b40 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -701,3 +701,131 @@ def test_known_answer_supression_service_type_enumeration_query(): zc.registry.remove(info) zc.registry.remove(info2) zc.close() + + +def test_qu_response_only_sends_additionals_if_sends_answer(): + """Test that a QU response does not send additionals unless it sends the answer as well.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + type_ = "_addtest1._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info) + + type_2 = "_addtest2._tcp.local." + name = "knownname" + registration_name2 = "%s.%s" % (name, type_2) + desc = {'path': '/~paulsm/'} + server_name2 = "ash-3.local." + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.add(info2) + + ptr_record = info.dns_pointer() + + # Add the PTR record to the cache + zc.cache.add(ptr_record) + + # Add the A record to the cache with 50% ttl remaining + a_record = info.dns_addresses()[0] + a_record._set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) + assert not a_record.is_recent(current_time_millis()) + zc.cache.add(a_record) + + # With QU should respond to only unicast when the answer has been recently multicast + # even if the additional has not been recently multicast + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out is None + assert a_record in unicast_out.additionals + assert unicast_out.answers[0][0] == ptr_record + + # Remove the 50% A record and add a 100% A record + zc.cache.remove(a_record) + a_record = info.dns_addresses()[0] + assert a_record.is_recent(current_time_millis()) + zc.cache.add(a_record) + # With QU should respond to only unicast when the answer has been recently multicast + # even if the additional has not been recently multicast + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out is None + assert a_record in unicast_out.additionals + assert unicast_out.answers[0][0] == ptr_record + + # Remove the 100% PTR record and add a 50% PTR record + zc.cache.remove(ptr_record) + ptr_record._set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) + assert not ptr_record.is_recent(current_time_millis()) + zc.cache.add(ptr_record) + # With QU should respond to only multicast since the has less + # than 75% of its ttl remaining + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out.answers[0][0] == ptr_record + assert a_record in multicast_out.additionals + assert info.dns_text() in multicast_out.additionals + assert info.dns_service() in multicast_out.additionals + + assert unicast_out is None + + # Ask 2 QU questions, with info the PTR is at 50%, with info2 the PTR is at 100% + # We should get back a unicast reply for info2, but info should be multicasted since its within 75% of its TTL + # With QU should respond to only multicast since the has less + # than 75% of its ttl remaining + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + + question = r.DNSQuestion(info2.type, const._TYPE_PTR, const._CLASS_IN) + question.unique = True # Set the QU bit + assert question.unicast is True + query.add_question(question) + zc.cache.add(info2.dns_pointer()) # Add 100% TTL for info2 to the cache + + unicast_out, multicast_out = zc.query_handler.response( + [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + ) + assert multicast_out.answers[0][0] == info.dns_pointer() + assert info.dns_addresses()[0] in multicast_out.additionals + assert info.dns_text() in multicast_out.additionals + assert info.dns_service() in multicast_out.additionals + + assert unicast_out.answers[0][0] == info2.dns_pointer() + assert info2.dns_addresses()[0] in unicast_out.additionals + assert info2.dns_text() in unicast_out.additionals + assert info2.dns_service() in unicast_out.additionals + + # unregister + zc.registry.remove(info) + zc.close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index a7e18360f..042ab2a12 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -20,7 +20,6 @@ USA """ -import enum import itertools from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union @@ -53,14 +52,7 @@ from ._core import Zeroconf # pylint: disable=cyclic-import -@enum.unique -class RecordSetKeys(enum.Enum): - Answers = 1 - Additionals = 2 - - -# Switch to a TypedDict once Python 3.8 is the minimum supported version -_RecordSetType = Dict[RecordSetKeys, Set[DNSRecord]] +_AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] class _QueryResponse: @@ -69,48 +61,39 @@ class _QueryResponse: def __init__(self, cache: DNSCache, msg: DNSIncoming, ucast_source: bool) -> None: """Build a query response.""" self._msg = msg - self._ucast_source = ucast_source self._is_probe = msg.num_authorities > 0 + self._ucast_source = ucast_source self._now = current_time_millis() self._cache = cache - self._ucast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} - self._mcast: _RecordSetType = {RecordSetKeys.Answers: set(), RecordSetKeys.Additionals: set()} + self._additionals: _AnswerWithAdditionalsType = {} + self._ucast: Set[DNSRecord] = set() + self._mcast: Set[DNSRecord] = set() - def add_qu_question_response( - self, - answers: Set[DNSRecord], - additionals: Set[DNSRecord], - ) -> None: + def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast QU query.""" - self._add_qu_question_response_to_target(answers, RecordSetKeys.Answers) - self._add_qu_question_response_to_target(additionals, RecordSetKeys.Additionals) - - def _add_qu_question_response_to_target(self, target: Set[DNSRecord], answer_type: RecordSetKeys) -> None: - """Add part of the QU response.""" - for record in target: + for record, additionals in answers.items(): + self._additionals[record] = additionals if self._is_probe: - self._ucast[answer_type].add(record) + self._ucast.add(record) if not self._has_mcast_within_one_quarter_ttl(record): - self._mcast[answer_type].add(record) + self._mcast.add(record) elif not self._is_probe: - self._ucast[answer_type].add(record) + self._ucast.add(record) - def add_ucast_question_response(self, answers: Set[DNSRecord], additionals: Set[DNSRecord]) -> None: + def add_ucast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a unicast query.""" - self._ucast[RecordSetKeys.Answers].update(answers) - self._ucast[RecordSetKeys.Additionals].update(additionals) + self._additionals.update(answers) + self._ucast.update(answers.keys()) - def add_mcast_question_response(self, answers: Set[DNSRecord], additionals: Set[DNSRecord]) -> None: + def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast query.""" - self._mcast[RecordSetKeys.Answers].update(answers) - self._mcast[RecordSetKeys.Additionals].update(additionals) + self._additionals.update(answers) + self._mcast.update(answers.keys()) def outgoing_unicast(self) -> Optional[DNSOutgoing]: """Build the outgoing unicast response.""" ucastout = self._construct_outgoing_from_record_set(self._ucast, False) - # Adding the questions back when the source is - # unicast (not MDNS port) is legacy behavior - # Is this correct? + # Adding the questions back when the source is legacy unicast behavior if ucastout and self._ucast_source: for question in self._msg.questions: ucastout.add_question(question) @@ -119,28 +102,33 @@ def outgoing_unicast(self) -> Optional[DNSOutgoing]: def outgoing_multicast(self) -> Optional[DNSOutgoing]: """Build the outgoing multicast response.""" if not self._is_probe: - self._suppress_mcasts_from_last_second(self._mcast[RecordSetKeys.Answers]) - self._suppress_mcasts_from_last_second(self._mcast[RecordSetKeys.Additionals]) + self._suppress_mcasts_from_last_second(self._mcast) return self._construct_outgoing_from_record_set(self._mcast, True) def _construct_outgoing_from_record_set( - self, rrset: _RecordSetType, multicast: bool + self, answers_rrset: Set[DNSRecord], multicast: bool ) -> Optional[DNSOutgoing]: """Add answers and additionals to a DNSOutgoing.""" - if not rrset[RecordSetKeys.Answers] and not rrset[RecordSetKeys.Additionals]: + # Find additionals and suppress any additionals that are already in answers + additionals_rrset = self._additionals_from_answers_rrset(answers_rrset) - answers_rrset + if not answers_rrset: return None - # Suppress any additionals that are already in answers - rrset[RecordSetKeys.Additionals] -= rrset[RecordSetKeys.Answers] - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=multicast, id_=self._msg.id) - for answer in rrset[RecordSetKeys.Answers]: + for answer in answers_rrset: out.add_answer_at_time(answer, 0) - for additional in rrset[RecordSetKeys.Additionals]: + for additional in additionals_rrset: out.add_additional_answer(additional) - return out + def _additionals_from_answers_rrset(self, rrset: Set[DNSRecord]) -> Set[DNSRecord]: + additionals: Set[DNSRecord] = set() + return additionals.union(*[self._additionals[record] for record in rrset]) + + def _suppress_mcasts_from_last_second(self, rrset: Set[DNSRecord]) -> None: + """Remove any records that were already sent in the last second.""" + rrset -= set(record for record in rrset if self._has_mcast_record_in_last_second(record)) + def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -155,10 +143,6 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: maybe_entry = self._cache.get(record) return bool(maybe_entry and maybe_entry.is_recent(self._now)) - def _suppress_mcasts_from_last_second(self, records: Set[DNSRecord]) -> None: - """Remove any records that were already sent in the last second.""" - records -= set(record for record in records if self._has_mcast_record_in_last_second(record)) - def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: """Remove answers that were just broadcast Protect the network against excessive packet flooding @@ -176,101 +160,90 @@ def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: self.registry = registry self.cache = cache - def _answer_service_type_enumeration_query( - self, - answers_rrset: DNSRRSet, - ) -> Set[DNSRecord]: + def _add_service_type_enumeration_query_answers( + self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + ) -> None: """Provide an answer to a service type enumeration query. https://datatracker.ietf.org/doc/html/rfc6763#section-9 """ - records: Set[DNSRecord] = set( - DNSPointer(_SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype) - for stype in self.registry.get_types() - ) - records -= set(dns_pointer for dns_pointer in records if answers_rrset.suppresses(dns_pointer)) - return records + for stype in self.registry.get_types(): + dns_pointer = DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype + ) + if not known_answers.suppresses(dns_pointer): + answer_set[dns_pointer] = set() def _add_pointer_answers( - self, name: str, answers_rrset: DNSRRSet, answers: Set[DNSRecord], additionals: Set[DNSRecord] + self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet ) -> None: """Answer PTR/ANY question.""" for service in self.registry.get_infos_type(name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer() - if not answers_rrset.suppresses(dns_pointer): - answers.add(dns_pointer) - additionals.add(service.dns_service()) - additionals.add(service.dns_text()) - additionals.update(service.dns_addresses()) + if not known_answers.suppresses(dns_pointer): + answer_set[dns_pointer] = set( + [service.dns_service(), service.dns_text(), *service.dns_addresses()] + ) def _add_address_answers( - self, name: str, answers_rrset: DNSRRSet, answers: Set[DNSRecord], type_: int + self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, type_: int ) -> None: """Answer A/AAAA/ANY question.""" for service in self.registry.get_infos_server(name): for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_]): - if not answers_rrset.suppresses(dns_address): - answers.add(dns_address) + if not known_answers.suppresses(dns_address): + answer_set[dns_address] = set() def _answer_question( - self, answers_rrset: DNSRRSet, question: DNSQuestion - ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: - answers: Set[DNSRecord] = set() - additionals: Set[DNSRecord] = set() + self, question: DNSQuestion, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + ) -> None: + if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + self._add_service_type_enumeration_query_answers(answer_set, known_answers) + return + type_ = question.type if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question.name, answers_rrset, answers, additionals) + self._add_pointer_answers(question.name, answer_set, known_answers) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question.name, answers_rrset, answers, type_) + self._add_address_answers(question.name, answer_set, known_answers, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): service = self.registry.get_info_name(question.name) # type: ignore if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.2. dns_service = service.dns_service() - if not answers_rrset.suppresses(dns_service): - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.2. - answers.add(service.dns_service()) - additionals.update(service.dns_addresses()) + if not known_answers.suppresses(dns_service): + answer_set[dns_service] = set(service.dns_addresses()) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service.dns_text() - if not answers_rrset.suppresses(dns_text): - answers.add(service.dns_text()) - - return answers, additionals - - def _answer_any_question( - self, answers_rrset: DNSRRSet, question: DNSQuestion - ) -> Tuple[Set[DNSRecord], Set[DNSRecord]]: - if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - empty_additionals: Set[DNSRecord] = set() - return self._answer_service_type_enumeration_query(answers_rrset), empty_additionals - - return self._answer_question(answers_rrset, question) + if not known_answers.suppresses(dns_text): + answer_set[dns_text] = set() def response( # pylint: disable=unused-argument self, msgs: List[DNSIncoming], addr: Optional[str], port: int ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: """Deal with incoming query packets. Provides a response if possible.""" ucast_source = port != _MDNS_PORT + known_answers = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) query_res = _QueryResponse(self.cache, msgs[0], ucast_source) - answers_rrset = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) for question in itertools.chain(*[msg.questions for msg in msgs]): - all_answers = self._answer_any_question(answers_rrset, question) + answer_set: _AnswerWithAdditionalsType = {} + self._answer_question(question, answer_set, known_answers) if not ucast_source and question.unicast: - query_res.add_qu_question_response(*all_answers) + query_res.add_qu_question_response(answer_set) else: if ucast_source: - query_res.add_ucast_question_response(*all_answers) + query_res.add_ucast_question_response(answer_set) # We always multicast as well even if its a unicast # source as long as we haven't done it recently (75% of ttl) - query_res.add_mcast_question_response(*all_answers) + query_res.add_mcast_question_response(answer_set) return query_res.outgoing_unicast(), query_res.outgoing_multicast() From 32b7dc40e2c3621fcacb2f389d51408ab35ac832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 13:31:53 -1000 Subject: [PATCH 0401/1433] Fix off by 1 in test_tc_bit_defers_last_response_missing (#694) --- tests/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index 8f459da15..4d001208c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -594,7 +594,7 @@ async def make_query(): assert timer3.cancelled() assert timer4 != timer3 - for _ in range(7): + for _ in range(8): time.sleep(0.1) if source_ip not in zc._timers: break From 5cbaa3fc02f635e6c735e1ee5f1ca19b84c0a069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 14:04:35 -1000 Subject: [PATCH 0402/1433] Rollback data in one call instead of poping one byte at a time in DNSOutgoing (#696) --- zeroconf/_dns.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index b55f77f0f..e285829b8 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -823,7 +823,6 @@ def _write_record(self, record: DNSRecord, now: float) -> bool: else: self._write_int(record.get_remaining_ttl(now)) index = len(self.data) - self.write_short(0) # Will get replaced with the actual size record.write(self) # Adjust size for the short we will write before this record @@ -842,9 +841,7 @@ def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) return True log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) - - while len(self.data) > start_data_length: - self.data.pop() + del self.data[start_data_length:] self.size = start_size rollback_names = [name for name, idx in self.names.items() if idx >= start_size] From 767546b656d7db6df0cbf2b257953498f1bc3996 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 14:11:07 -1000 Subject: [PATCH 0403/1433] Use unique names in service types tests (#697) --- tests/services/test_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 6f6645db4..ba355bae5 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -19,7 +19,7 @@ class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-listen-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -50,7 +50,7 @@ def test_integration_with_listener(self): @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_v6_records(self): - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-listenv6rec-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com @@ -82,7 +82,7 @@ def test_integration_with_listener_v6_records(self): @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_integration_with_listener_ipv6(self): - type_ = "_test-srvc-type._tcp.local." + type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) @@ -111,7 +111,7 @@ def test_integration_with_listener_ipv6(self): def test_integration_with_subtype_and_listener(self): subtype_ = "_subtype._sub" - type_ = "_type._tcp.local." + type_ = "_listen._tcp.local." name = "xxxyyy" # Note: discovery returns only DNS-SD type not subtype discovery_type = "%s.%s" % (subtype_, type_) From 26fa2fb479fff87ca5af17c2c09a557c4b6176b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 14:25:17 -1000 Subject: [PATCH 0404/1433] Abstract DNSOutgoing ttl write into _write_ttl (#695) --- zeroconf/_dns.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index e285829b8..1b4a91844 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -810,6 +810,10 @@ def _write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: else: self.write_short(record.class_) + def _write_ttl(self, record: DNSRecord, now: float) -> None: + """Write out the record ttl.""" + self._write_int(record.ttl if now == 0 else record.get_remaining_ttl(now)) + def _write_record(self, record: DNSRecord, now: float) -> bool: """Writes a record (answer, authoritative answer, additional) to the packet. Returns True on success, or False if we did not @@ -818,10 +822,7 @@ def _write_record(self, record: DNSRecord, now: float) -> bool: self.write_name(record.name) self.write_short(record.type) self._write_record_class(record) - if now == 0: - self._write_int(record.ttl) - else: - self._write_int(record.get_remaining_ttl(now)) + self._write_ttl(record, now) index = len(self.data) self.write_short(0) # Will get replaced with the actual size record.write(self) From 7e308480238fdf2cfe08474d679121e77f746fa6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 23:11:35 -1000 Subject: [PATCH 0405/1433] Efficiently bucket queries with known answers (#698) --- tests/test_services.py | 25 ++++++++- zeroconf/_dns.py | 29 +++++++++- zeroconf/_services/__init__.py | 96 +++++++++++++++++++++++++++------- zeroconf/aio.py | 4 +- zeroconf/const.py | 2 + 5 files changed, 133 insertions(+), 23 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 521efd1f0..147c12256 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -16,7 +16,7 @@ import pytest import zeroconf as r -from zeroconf import DNSAddress, const +from zeroconf import DNSAddress, DNSPointer, DNSQuestion, const, current_time_millis import zeroconf._services as s from zeroconf import Zeroconf from zeroconf._services import ( @@ -1377,3 +1377,26 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): addresses=addresses, ) assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + + +def test_group_ptr_queries_with_known_answers(): + questions_with_known_answers: s._QuestionWithKnownAnswers = {} + now = current_time_millis() + for i in range(120): + name = f"_hap{i}._tcp._local." + questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = set( + DNSPointer( + name, + const._TYPE_PTR, + const._CLASS_IN, + 4500, + f"zoo{counter}.{name}", + ) + for counter in range(i) + ) + outs = s._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) + for out in outs: + packets = out.packets() + # If we generate multiple packets there must + # only be one question + assert len(packets) == 1 or len(out.questions) == 1 diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 1b4a91844..d6c12a710 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -34,6 +34,7 @@ _CLASSES, _CLASS_MASK, _CLASS_UNIQUE, + _DNS_PACKET_HEADER_LEN, _EXPIRE_FULL_TIME_PERCENT, _EXPIRE_STALE_TIME_PERCENT, _FLAGS_QR_MASK, @@ -54,6 +55,12 @@ _TYPE_TXT, ) +_LEN_BYTE = 1 +_LEN_SHORT = 2 +_LEN_INT = 4 + +_BASE_MAX_SIZE = _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT # type # class # ttl # length +_NAME_COMPRESSION_MIN_SIZE = _LEN_BYTE * 2 if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 @@ -118,6 +125,14 @@ def answered_by(self, rec: 'DNSRecord') -> bool: and self.name == rec.name ) + def __hash__(self) -> int: + return hash((self.name, self.class_, self.type)) + + @property + def max_size(self) -> int: + """Maximum size of the question in the packet.""" + return len(self.name.encode('utf-8')) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT # type # class + @property def unicast(self) -> bool: """Returns true if the QU (not QM) is set. @@ -291,6 +306,16 @@ def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> super().__init__(name, type_, class_, ttl) self.alias = alias + @property + def max_size_compressed(self) -> int: + """Maximum size of the record in the packet assuming the name has been compressed.""" + return ( + _BASE_MAX_SIZE + + _NAME_COMPRESSION_MIN_SIZE + + (len(self.alias) - len(self.name)) + + _NAME_COMPRESSION_MIN_SIZE + ) + def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" out.write_name(self.alias) @@ -590,7 +615,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: # these 3 are per-packet -- see also _reset_for_next_packet() self.names: Dict[str, int] = {} self.data: List[bytes] = [] - self.size: int = 12 + self.size: int = _DNS_PACKET_HEADER_LEN self.allow_long: bool = True self.state = self.State.init @@ -603,7 +628,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: def _reset_for_next_packet(self) -> None: self.names = {} self.data = [] - self.size = 12 + self.size = _DNS_PACKET_HEADER_LEN self.allow_long = True def __repr__(self) -> str: diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 19fdccaf8..818b3bb6c 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -44,9 +44,11 @@ _CLASS_UNIQUE, _DNS_HOST_TTL, _DNS_OTHER_TTL, + _DNS_PACKET_HEADER_LEN, _EXPIRE_REFRESH_TIME_PERCENT, _FLAGS_QR_QUERY, _LISTENER_TIME, + _MAX_MSG_TYPICAL, _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, @@ -63,6 +65,9 @@ from .._core import Zeroconf # pylint: disable=cyclic-import +_QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] + + @enum.unique class ServiceStateChange(enum.Enum): Added = 1 @@ -151,6 +156,67 @@ def update_records_complete(self) -> None: """ +class _DNSPointerOutgoingBucket: + """A DNSOutgoing bucket.""" + + def __init__(self, now: float, multicast: bool) -> None: + """Create a bucke to wrap a DNSOutgoing.""" + self.now = now + self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=multicast) + self.bytes = 0 + + def add(self, max_compressed_size: int, question: DNSQuestion, answers: Set[DNSPointer]) -> None: + """Add a new set of questions and known answers to the outgoing.""" + self.out.add_question(question) + for answer in answers: + self.out.add_answer_at_time(answer, self.now) + self.bytes += max_compressed_size + + +def _group_ptr_queries_with_known_answers( + now: float, multicast: bool, question_with_known_answers: _QuestionWithKnownAnswers +) -> List[DNSOutgoing]: + """Aggregate queries so that as many known answers as possible fit in the same packet + without having known answers spill over into the next packet unless the + question and known answers are always going to exceed the packet size. + + Some responders do not implement multi-packet known answer suppression + so we try to keep all the known answers in the same packet as the + questions. + """ + # This is the maximum size the query + known answers can be with name compression. + # The actual size of the query + known answers may be a bit smaller since other + # parts may be shared when the final DNSOutgoing packets are constructed. The + # goal of this algorithm is to quickly bucket the query + known answers without + # the overhead of actually constructing the packets. + query_by_size: Dict[DNSQuestion, int] = { + question: (question.max_size + sum([answer.max_size_compressed for answer in known_answers])) + for question, known_answers in question_with_known_answers.items() + } + max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN + query_buckets: List[_DNSPointerOutgoingBucket] = [] + for question in sorted( + query_by_size, + key=query_by_size.get, # type: ignore + reverse=True, + ): + max_compressed_size = query_by_size[question] + answers = question_with_known_answers[question] + for query_bucket in query_buckets: + if query_bucket.bytes + max_compressed_size <= max_bucket_size: + query_bucket.add(max_compressed_size, question, answers) + break + else: + # If a single question and known answers won't fit in a packet + # we will end up generating multiple packets, but there will never + # be multiple questions + query_bucket = _DNSPointerOutgoingBucket(now, multicast) + query_bucket.add(max_compressed_size, question, answers) + query_buckets.append(query_bucket) + + return [query_bucket.out for query_bucket in query_buckets] + + class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" @@ -174,9 +240,7 @@ def __init__( self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - self._services = { - check_type_: {} for check_type_ in self.types - } # type: Dict[str, Dict[str, DNSRecord]] + self._services: Dict[str, Dict[str, DNSPointer]] = {check_type_: {} for check_type_ in self.types} current_time = current_time_millis() self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} @@ -317,29 +381,25 @@ def run(self) -> None: questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] self.zc.add_listener(self, questions) - def generate_ready_queries(self) -> Optional[DNSOutgoing]: + def generate_ready_queries(self) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" - out = None now = current_time_millis() if min(self._next_time.values()) > now: - return out + return [] + + questions_with_known_answers: _QuestionWithKnownAnswers = {} for type_, due in self._next_time.items(): if due > now: continue - - if out is None: - out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=self.multicast) - out.add_question(DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)) - - for record in self._services[type_].values(): - if not record.is_stale(now): - out.add_answer_at_time(record, now) - + questions_with_known_answers[DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)] = set( + record for record in self._services[type_].values() if not record.is_stale(now) + ) self._next_time[type_] = now + self._delay[type_] self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - return out + + return _group_ptr_queries_with_known_answers(now, self.multicast, questions_with_known_answers) def _seconds_to_wait(self) -> Optional[float]: """Returns the number of seconds to wait for the next event.""" @@ -406,8 +466,8 @@ def run(self) -> None: if self.zc.done or self.done: return - out = self.generate_ready_queries() - if out: + outs = self.generate_ready_queries() + for out in outs: self.zc.send(out, addr=self.addr, port=self.port) if not self._handlers_to_call: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 01211bb4f..ae57d0142 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -159,8 +159,8 @@ async def async_run(self) -> None: if not self._handlers_to_call: await wait_condition_or_timeout(self.aiozc.condition, timeout) - out = self.generate_ready_queries() - if out: + outs = self.generate_ready_queries() + for out in outs: self.aiozc.zeroconf.async_send(out, addr=self.addr, port=self.port) if not self._handlers_to_call: diff --git a/zeroconf/const.py b/zeroconf/const.py index 96f536dfa..ba9d5309b 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -47,6 +47,8 @@ _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 _DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per RFC6762 +_DNS_PACKET_HEADER_LEN = 12 + _MAX_MSG_TYPICAL = 1460 # unused _MAX_MSG_ABSOLUTE = 8966 From c368e1c67c82598e920ca52b1f7a47ed6e1cf738 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Jun 2021 23:15:42 -1000 Subject: [PATCH 0406/1433] Update changelog (#699) --- README.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.rst b/README.rst index 06a6c64e4..eb730cdfd 100644 --- a/README.rst +++ b/README.rst @@ -201,6 +201,12 @@ Changelog * TRAFFIC REDUCTION: Avoid including additionals when the answer is suppressed by known-answer supression (#614) @bdraco +* TRAFFIC REDUCTION: Implement multi-packet known answer supression (#687) @bdraco + + Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 + +* TRAFFIC REDUCTION: Efficiently bucket queries with known answers (#698) @bdraco + * MAJOR BUG: Ensure matching PTR queries are returned with the ANY query (#618) @bdraco * MAJOR BUG: Fix lookup of uppercase names in registry (#597) @bdraco @@ -219,6 +225,16 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Abstract DNSOutgoing ttl write into _write_ttl (#695) @bdraco + +* Rollback data in one call instead of poping one byte at a time in DNS Outgoing (#696) @bdraco + +* Suppress additionals when answer is suppressed (#690) @bdraco + +* Move setting DNS created and ttl into its own function (#692) @bdraco + +* Add truncated property to DNSMessage to lookup the TC bit (#686) @bdraco + * Check if SO_REUSEPORT exists instead of using an exception catch (#682) @bdraco * Use DNSRRSet for known answer suppression (#680) @bdraco From f39bde0f6cba7a3c1b8fe8bc1a4ab4388801e486 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 09:11:34 -1000 Subject: [PATCH 0407/1433] Split DNSOutgoing/DNSIncoming/DNSMessage into zeroconf._protocol (#705) --- tests/test_dns.py | 695 +------------------------------ tests/test_protocol.py | 722 +++++++++++++++++++++++++++++++++ zeroconf/__init__.py | 4 +- zeroconf/_core.py | 3 +- zeroconf/_dns.py | 620 +--------------------------- zeroconf/_handlers.py | 3 +- zeroconf/_protocol.py | 644 +++++++++++++++++++++++++++++ zeroconf/_services/__init__.py | 3 +- 8 files changed, 1379 insertions(+), 1315 deletions(-) create mode 100644 tests/test_protocol.py create mode 100644 zeroconf/_protocol.py diff --git a/tests/test_dns.py b/tests/test_dns.py index 4096aa94c..557802e18 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -2,19 +2,16 @@ # -*- coding: utf-8 -*- -""" Unit tests for zeroconf.py """ +""" Unit tests for zeroconf._dns. """ -import copy import logging import socket -import struct import time import unittest import unittest.mock -from typing import Dict, cast # noqa # used in type hints import zeroconf as r -from zeroconf import DNSIncoming, const, current_time_millis +from zeroconf import const, current_time_millis from zeroconf._dns import DNSRRSet from zeroconf import ( DNSHinfo, @@ -162,454 +159,6 @@ def test_dns_record_is_recent(self): assert record.is_recent(now + (8 * 1000)) is False -class PacketGeneration(unittest.TestCase): - def test_parse_own_packet_simple(self): - generated = r.DNSOutgoing(0) - r.DNSIncoming(generated.packets()[0]) - - def test_parse_own_packet_simple_unicast(self): - generated = r.DNSOutgoing(0, False) - r.DNSIncoming(generated.packets()[0]) - - def test_parse_own_packet_flags(self): - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - r.DNSIncoming(generated.packets()[0]) - - def test_parse_own_packet_question(self): - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) - r.DNSIncoming(generated.packets()[0]) - - def test_parse_own_packet_response(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSService( - "æøå.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - 0, - ) - parsed = r.DNSIncoming(generated.packets()[0]) - assert len(generated.answers) == 1 - assert len(generated.answers) == len(parsed.answers) - - def test_adding_empty_answer(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - None, - 0, - ) - generated.add_answer_at_time( - r.DNSService( - "æøå.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - 0, - ) - parsed = r.DNSIncoming(generated.packets()[0]) - assert len(generated.answers) == 1 - assert len(generated.answers) == len(parsed.answers) - - def test_adding_expired_answer(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSService( - "æøå.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - current_time_millis() + 1000000, - ) - parsed = r.DNSIncoming(generated.packets()[0]) - assert len(generated.answers) == 0 - assert len(generated.answers) == len(parsed.answers) - - def test_match_question(self): - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) - generated.add_question(question) - parsed = r.DNSIncoming(generated.packets()[0]) - assert len(generated.questions) == 1 - assert len(generated.questions) == len(parsed.questions) - assert question == parsed.questions[0] - - def test_suppress_answer(self): - query_generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) - query_generated.add_question(question) - answer1 = r.DNSService( - "testname1.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ) - staleanswer2 = r.DNSService( - "testname2.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL / 2, - 0, - 0, - 80, - "foo.local.", - ) - answer2 = r.DNSService( - "testname2.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ) - query_generated.add_answer_at_time(answer1, 0) - query_generated.add_answer_at_time(staleanswer2, 0) - query = r.DNSIncoming(query_generated.packets()[0]) - - # Should be suppressed - response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - response.add_answer(query, answer1) - assert len(response.answers) == 0 - - # Should not be suppressed, TTL in query is too short - response.add_answer(query, answer2) - assert len(response.answers) == 1 - - # Should not be suppressed, name is different - tmp = copy.copy(answer1) - tmp.key = "testname3.local." - tmp.name = "testname3.local." - response.add_answer(query, tmp) - assert len(response.answers) == 2 - - # Should not be suppressed, type is different - tmp = copy.copy(answer1) - tmp.type = const._TYPE_A - response.add_answer(query, tmp) - assert len(response.answers) == 3 - - # Should not be suppressed, class is different - tmp = copy.copy(answer1) - tmp.class_ = const._CLASS_NONE - response.add_answer(query, tmp) - assert len(response.answers) == 4 - - # ::TODO:: could add additional tests for DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService - - def test_dns_hinfo(self): - generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) - parsed = r.DNSIncoming(generated.packets()[0]) - answer = cast(r.DNSHinfo, parsed.answers[0]) - assert answer.cpu == u'cpu' - assert answer.os == u'os' - - generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) - self.assertRaises(r.NamePartTooLongException, generated.packets) - - def test_many_questions(self): - """Test many questions get seperated into multiple packets.""" - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - questions = [] - for i in range(100): - question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) - generated.add_question(question) - questions.append(question) - assert len(generated.questions) == 100 - - packets = generated.packets() - assert len(packets) == 2 - assert len(packets[0]) < const._MAX_MSG_TYPICAL - assert len(packets[1]) < const._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert len(parsed1.questions) == 85 - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 15 - - def test_many_questions_with_many_known_answers(self): - """Test many questions and known answers get seperated into multiple packets.""" - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - questions = [] - for _ in range(30): - question = r.DNSQuestion(f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - generated.add_question(question) - questions.append(question) - assert len(generated.questions) == 30 - now = current_time_millis() - for _ in range(200): - known_answer = r.DNSPointer( - "myservice{i}_tcp._tcp.local.", - const._TYPE_PTR, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - '123.local.', - ) - generated.add_answer_at_time(known_answer, now) - packets = generated.packets() - assert len(packets) == 3 - assert len(packets[0]) <= const._MAX_MSG_TYPICAL - assert len(packets[1]) <= const._MAX_MSG_TYPICAL - assert len(packets[2]) <= const._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert len(parsed1.questions) == 30 - assert len(parsed1.answers) == 88 - assert parsed1.truncated - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 0 - assert len(parsed2.answers) == 101 - assert parsed2.truncated - parsed3 = r.DNSIncoming(packets[2]) - assert len(parsed3.questions) == 0 - assert len(parsed3.answers) == 11 - assert not parsed3.truncated - - def test_massive_probe_packet_split(self): - """Test probe with many authorative answers.""" - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) - questions = [] - for _ in range(30): - question = r.DNSQuestion( - f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE - ) - generated.add_question(question) - questions.append(question) - assert len(generated.questions) == 30 - now = current_time_millis() - for _ in range(200): - authorative_answer = r.DNSPointer( - "myservice{i}_tcp._tcp.local.", - const._TYPE_PTR, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - '123.local.', - ) - generated.add_authorative_answer(authorative_answer) - packets = generated.packets() - assert len(packets) == 3 - assert len(packets[0]) <= const._MAX_MSG_TYPICAL - assert len(packets[1]) <= const._MAX_MSG_TYPICAL - assert len(packets[2]) <= const._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert parsed1.questions[0].unicast is True - assert len(parsed1.questions) == 30 - assert parsed1.num_authorities == 88 - assert parsed1.truncated - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 0 - assert parsed2.num_authorities == 101 - assert parsed2.truncated - parsed3 = r.DNSIncoming(packets[2]) - assert len(parsed3.questions) == 0 - assert parsed3.num_authorities == 11 - assert not parsed3.truncated - - def test_only_one_answer_can_by_large(self): - """Test that only the first answer in each packet can be large. - - https://datatracker.ietf.org/doc/html/rfc6762#section-17 - """ - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packets()[0]) - for i in range(3): - generated.add_answer( - query, - r.DNSText( - "zoom._hap._tcp.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 1200, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, - ), - ) - generated.add_answer( - query, - r.DNSService( - "testname1.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - "foo.local.", - ), - ) - assert len(generated.answers) == 4 - - packets = generated.packets() - assert len(packets) == 4 - assert len(packets[0]) <= const._MAX_MSG_ABSOLUTE - assert len(packets[0]) > const._MAX_MSG_TYPICAL - - assert len(packets[1]) <= const._MAX_MSG_ABSOLUTE - assert len(packets[1]) > const._MAX_MSG_TYPICAL - - assert len(packets[2]) <= const._MAX_MSG_ABSOLUTE - assert len(packets[2]) > const._MAX_MSG_TYPICAL - - assert len(packets[3]) <= const._MAX_MSG_TYPICAL - - for packet in packets: - parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 - - def test_questions_do_not_end_up_every_packet(self): - """Test that questions are not sent again when multiple packets are needed. - - https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - Sometimes a Multicast DNS querier will already have too many answers - to fit in the Known-Answer Section of its query packets.... It MUST - immediately follow the packet with another query packet containing no - questions and as many more Known-Answer records as will fit. - """ - - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - for i in range(35): - question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) - generated.add_question(question) - answer = r.DNSService( - f"testname{i}.local.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - 80, - f"foo{i}.local.", - ) - generated.add_answer_at_time(answer, 0) - - assert len(generated.questions) == 35 - assert len(generated.answers) == 35 - - packets = generated.packets() - assert len(packets) == 2 - assert len(packets[0]) <= const._MAX_MSG_TYPICAL - assert len(packets[1]) <= const._MAX_MSG_TYPICAL - - parsed1 = r.DNSIncoming(packets[0]) - assert len(parsed1.questions) == 35 - assert len(parsed1.answers) == 33 - - parsed2 = r.DNSIncoming(packets[1]) - assert len(parsed2.questions) == 0 - assert len(parsed2.answers) == 2 - - -class PacketForm(unittest.TestCase): - def test_transaction_id(self): - """ID must be zero in a DNS-SD packet""" - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - bytes = generated.packets()[0] - id = bytes[0] << 8 | bytes[1] - assert id == 0 - - def test_setting_id(self): - """Test setting id in the constructor""" - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY, id_=4444) - assert generated.id == 4444 - - def test_query_header_bits(self): - generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - bytes = generated.packets()[0] - flags = bytes[2] << 8 | bytes[3] - assert flags == 0x0 - - def test_response_header_bits(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - bytes = generated.packets()[0] - flags = bytes[2] << 8 | bytes[3] - assert flags == 0x8000 - - def test_numbers(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - assert num_questions == 0 - assert num_answers == 0 - assert num_authorities == 0 - assert num_additionals == 0 - - def test_numbers_questions(self): - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) - for i in range(10): - generated.add_question(question) - bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) - assert num_questions == 10 - assert num_answers == 0 - assert num_authorities == 0 - assert num_additionals == 0 - - -class TestDnsIncoming(unittest.TestCase): - def test_incoming_exception_handling(self): - generated = r.DNSOutgoing(0) - packet = generated.packets()[0] - packet = packet[:8] + b'deadbeef' + packet[8:] - parsed = r.DNSIncoming(packet) - parsed = r.DNSIncoming(packet) - assert parsed.valid is False - - def test_incoming_unknown_type(self): - generated = r.DNSOutgoing(0) - answer = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - generated.add_additional_answer(answer) - packet = generated.packets()[0] - parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 0 - assert parsed.is_query() != parsed.is_response() - - def test_incoming_circular_reference(self): - assert not r.DNSIncoming( - bytes.fromhex( - '01005e0000fb542a1bf0577608004500006897934000ff11d81bc0a86a31e00000fb' - '14e914e90054f9b2000084000000000100000000095f7365727669636573075f646e' - '732d7364045f756470056c6f63616c00000c0001000011940018105f73706f746966' - '792d636f6e6e656374045f746370c023' - ) - ).valid - - def test_incoming_ipv6(self): - addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - packed = socket.inet_pton(socket.AF_INET6, addr) - generated = r.DNSOutgoing(0) - answer = r.DNSAddress('domain', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) - generated.add_additional_answer(answer) - packet = generated.packets()[0] - parsed = r.DNSIncoming(packet) - record = parsed.answers[0] - assert isinstance(record, r.DNSAddress) - assert record.address == packed - - class TestDNSCache(unittest.TestCase): def test_order(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') @@ -647,246 +196,6 @@ def test_cache_empty_multiple_calls_does_not_throw(self): assert 'a' not in cache.cache -def test_dns_compression_rollback_for_corruption(): - """Verify rolling back does not lead to dns compression corruption.""" - out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) - address = socket.inet_pton(socket.AF_INET, "192.168.208.5") - - additionals = [ - { - "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", - "address": address, - "port": 51832, - "text": b"\x13md=HASS Bridge" - b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=L0m/aQ==", - }, - { - "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", - "address": address, - "port": 51834, - "text": b"\x13md=HASS Bridge" - b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b2CnzQ==", - }, - { - "name": "Master Bed TV CEDB27._hap._tcp.local.", - "address": address, - "port": 51830, - "text": b"\x10md=Master Bed" - b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=CVj1kw==", - }, - { - "name": "Living Room TV 921B77._hap._tcp.local.", - "address": address, - "port": 51833, - "text": b"\x11md=Living Room" - b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=qU77SQ==", - }, - { - "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", - "address": address, - "port": 51829, - "text": b"\x13md=HASS Bridge" - b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b0QZlg==", - }, - { - "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", - "address": address, - "port": 51837, - "text": b"\x13md=HASS Bridge" - b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=ahAISA==", - }, - { - "name": "FrontdoorCamera 8941D1._hap._tcp.local.", - "address": address, - "port": 54898, - "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" - b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", - }, - { - "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - "address": address, - "port": 51836, - "text": b"\x13md=HASS Bridge" - b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=6fLM5A==", - }, - { - "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", - "address": address, - "port": 51838, - "text": b"\x13md=HASS Bridge" - b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=u3bdfw==", - }, - { - "name": "Snooze Room TV 6B89B0._hap._tcp.local.", - "address": address, - "port": 51835, - "text": b"\x11md=Snooze Room" - b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=xNTqsg==", - }, - { - "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", - "address": address, - "port": 54811, - "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" - b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", - }, - { - "name": "HASS Bridge OS95 39C053._hap._tcp.local.", - "address": address, - "port": 51831, - "text": b"\x13md=HASS Bridge" - b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" - b"\x04sf=0\x0bsh=Xfe5LQ==", - }, - ] - - out.add_answer_at_time( - DNSText( - "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - for record in additionals: - out.add_additional_answer( - r.DNSService( - record["name"], # type: ignore - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - record["port"], # type: ignore - record["name"], # type: ignore - ) - ) - out.add_additional_answer( - r.DNSText( - record["name"], # type: ignore - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - record["text"], # type: ignore - ) - ) - out.add_additional_answer( - r.DNSAddress( - record["name"], # type: ignore - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - record["address"], # type: ignore - ) - ) - - for packet in out.packets(): - # Verify we can process the packets we created to - # ensure there is no corruption with the dns compression - incoming = r.DNSIncoming(packet) - assert incoming.valid is True - - -def test_tc_bit_in_query_packet(): - """Verify the TC bit is set when known answers exceed the packet size.""" - out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) - type_ = "_hap._tcp.local." - out.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN)) - - for i in range(30): - out.add_answer_at_time( - DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - packets = out.packets() - assert len(packets) == 3 - - first_packet = r.DNSIncoming(packets[0]) - assert first_packet.truncated - assert first_packet.valid is True - - second_packet = r.DNSIncoming(packets[1]) - assert second_packet.truncated - assert second_packet.valid is True - - third_packet = r.DNSIncoming(packets[2]) - assert not third_packet.truncated - assert third_packet.valid is True - - -def test_tc_bit_not_set_in_answer_packet(): - """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" - out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) - for i in range(30): - out.add_answer_at_time( - DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - 0, - ) - - packets = out.packets() - assert len(packets) == 3 - - first_packet = r.DNSIncoming(packets[0]) - assert not first_packet.truncated - assert first_packet.valid is True - - second_packet = r.DNSIncoming(packets[1]) - assert not second_packet.truncated - assert second_packet.valid is True - - third_packet = r.DNSIncoming(packets[2]) - assert not third_packet.truncated - assert third_packet.valid is True - - -# 4003 15.973052 192.168.107.68 224.0.0.251 MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question -def test_qm_packet_parser(): - """Test we can parse a query packet with the QM bit.""" - qm_packet = ( - b'\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01' - ) - parsed = DNSIncoming(qm_packet) - assert parsed.questions[0].unicast is False - assert ",QM," in str(parsed.questions[0]) - - -# 389951 1450.577370 192.168.107.111 224.0.0.251 MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT -def test_qu_packet_parser(): - """Test we can parse a query packet with the QU bit.""" - qu_packet = b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' - parsed = DNSIncoming(qu_packet) - assert parsed.questions[0].unicast is True - assert ",QU," in str(parsed.questions[0]) - - def test_dns_record_hashablity_does_not_consider_ttl(): """Test DNSRecord are hashable.""" diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 000000000..8b4ebd043 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,722 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._protocol """ + +import copy +import logging +import socket +import struct +import unittest +import unittest.mock +from typing import cast + +import zeroconf as r +from zeroconf import DNSIncoming, const, current_time_millis +from zeroconf import ( + DNSHinfo, + DNSText, +) + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class PacketGeneration(unittest.TestCase): + def test_parse_own_packet_simple(self): + generated = r.DNSOutgoing(0) + r.DNSIncoming(generated.packets()[0]) + + def test_parse_own_packet_simple_unicast(self): + generated = r.DNSOutgoing(0, False) + r.DNSIncoming(generated.packets()[0]) + + def test_parse_own_packet_flags(self): + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + r.DNSIncoming(generated.packets()[0]) + + def test_parse_own_packet_question(self): + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) + r.DNSIncoming(generated.packets()[0]) + + def test_parse_own_packet_response(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + 0, + ) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.answers) == 1 + assert len(generated.answers) == len(parsed.answers) + + def test_adding_empty_answer(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + None, + 0, + ) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + 0, + ) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.answers) == 1 + assert len(generated.answers) == len(parsed.answers) + + def test_adding_expired_answer(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + "æøå.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + current_time_millis() + 1000000, + ) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.answers) == 0 + assert len(generated.answers) == len(parsed.answers) + + def test_match_question(self): + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) + generated.add_question(question) + parsed = r.DNSIncoming(generated.packets()[0]) + assert len(generated.questions) == 1 + assert len(generated.questions) == len(parsed.questions) + assert question == parsed.questions[0] + + def test_suppress_answer(self): + query_generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) + query_generated.add_question(question) + answer1 = r.DNSService( + "testname1.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ) + staleanswer2 = r.DNSService( + "testname2.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL / 2, + 0, + 0, + 80, + "foo.local.", + ) + answer2 = r.DNSService( + "testname2.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ) + query_generated.add_answer_at_time(answer1, 0) + query_generated.add_answer_at_time(staleanswer2, 0) + query = r.DNSIncoming(query_generated.packets()[0]) + + # Should be suppressed + response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + response.add_answer(query, answer1) + assert len(response.answers) == 0 + + # Should not be suppressed, TTL in query is too short + response.add_answer(query, answer2) + assert len(response.answers) == 1 + + # Should not be suppressed, name is different + tmp = copy.copy(answer1) + tmp.key = "testname3.local." + tmp.name = "testname3.local." + response.add_answer(query, tmp) + assert len(response.answers) == 2 + + # Should not be suppressed, type is different + tmp = copy.copy(answer1) + tmp.type = const._TYPE_A + response.add_answer(query, tmp) + assert len(response.answers) == 3 + + # Should not be suppressed, class is different + tmp = copy.copy(answer1) + tmp.class_ = const._CLASS_NONE + response.add_answer(query, tmp) + assert len(response.answers) == 4 + + # ::TODO:: could add additional tests for DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService + + def test_dns_hinfo(self): + generated = r.DNSOutgoing(0) + generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) + parsed = r.DNSIncoming(generated.packets()[0]) + answer = cast(r.DNSHinfo, parsed.answers[0]) + assert answer.cpu == u'cpu' + assert answer.os == u'os' + + generated = r.DNSOutgoing(0) + generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) + self.assertRaises(r.NamePartTooLongException, generated.packets) + + def test_many_questions(self): + """Test many questions get seperated into multiple packets.""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + questions = [] + for i in range(100): + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 100 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) < const._MAX_MSG_TYPICAL + assert len(packets[1]) < const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 85 + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 15 + + def test_many_questions_with_many_known_answers(self): + """Test many questions and known answers get seperated into multiple packets.""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + questions = [] + for _ in range(30): + question = r.DNSQuestion(f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 30 + now = current_time_millis() + for _ in range(200): + known_answer = r.DNSPointer( + "myservice{i}_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + '123.local.', + ) + generated.add_answer_at_time(known_answer, now) + packets = generated.packets() + assert len(packets) == 3 + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL + assert len(packets[2]) <= const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 30 + assert len(parsed1.answers) == 88 + assert parsed1.truncated + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert len(parsed2.answers) == 101 + assert parsed2.truncated + parsed3 = r.DNSIncoming(packets[2]) + assert len(parsed3.questions) == 0 + assert len(parsed3.answers) == 11 + assert not parsed3.truncated + + def test_massive_probe_packet_split(self): + """Test probe with many authorative answers.""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + questions = [] + for _ in range(30): + question = r.DNSQuestion( + f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE + ) + generated.add_question(question) + questions.append(question) + assert len(generated.questions) == 30 + now = current_time_millis() + for _ in range(200): + authorative_answer = r.DNSPointer( + "myservice{i}_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + '123.local.', + ) + generated.add_authorative_answer(authorative_answer) + packets = generated.packets() + assert len(packets) == 3 + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL + assert len(packets[2]) <= const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert parsed1.questions[0].unicast is True + assert len(parsed1.questions) == 30 + assert parsed1.num_authorities == 88 + assert parsed1.truncated + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert parsed2.num_authorities == 101 + assert parsed2.truncated + parsed3 = r.DNSIncoming(packets[2]) + assert len(parsed3.questions) == 0 + assert parsed3.num_authorities == 11 + assert not parsed3.truncated + + def test_only_one_answer_can_by_large(self): + """Test that only the first answer in each packet can be large. + + https://datatracker.ietf.org/doc/html/rfc6762#section-17 + """ + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packets()[0]) + for i in range(3): + generated.add_answer( + query, + r.DNSText( + "zoom._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 1200, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, + ), + ) + generated.add_answer( + query, + r.DNSService( + "testname1.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "foo.local.", + ), + ) + assert len(generated.answers) == 4 + + packets = generated.packets() + assert len(packets) == 4 + assert len(packets[0]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[0]) > const._MAX_MSG_TYPICAL + + assert len(packets[1]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[1]) > const._MAX_MSG_TYPICAL + + assert len(packets[2]) <= const._MAX_MSG_ABSOLUTE + assert len(packets[2]) > const._MAX_MSG_TYPICAL + + assert len(packets[3]) <= const._MAX_MSG_TYPICAL + + for packet in packets: + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + def test_questions_do_not_end_up_every_packet(self): + """Test that questions are not sent again when multiple packets are needed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + Sometimes a Multicast DNS querier will already have too many answers + to fit in the Known-Answer Section of its query packets.... It MUST + immediately follow the packet with another query packet containing no + questions and as many more Known-Answer records as will fit. + """ + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + for i in range(35): + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) + generated.add_question(question) + answer = r.DNSService( + f"testname{i}.local.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + 80, + f"foo{i}.local.", + ) + generated.add_answer_at_time(answer, 0) + + assert len(generated.questions) == 35 + assert len(generated.answers) == 35 + + packets = generated.packets() + assert len(packets) == 2 + assert len(packets[0]) <= const._MAX_MSG_TYPICAL + assert len(packets[1]) <= const._MAX_MSG_TYPICAL + + parsed1 = r.DNSIncoming(packets[0]) + assert len(parsed1.questions) == 35 + assert len(parsed1.answers) == 33 + + parsed2 = r.DNSIncoming(packets[1]) + assert len(parsed2.questions) == 0 + assert len(parsed2.answers) == 2 + + +class PacketForm(unittest.TestCase): + def test_transaction_id(self): + """ID must be zero in a DNS-SD packet""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + bytes = generated.packets()[0] + id = bytes[0] << 8 | bytes[1] + assert id == 0 + + def test_setting_id(self): + """Test setting id in the constructor""" + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY, id_=4444) + assert generated.id == 4444 + + def test_query_header_bits(self): + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + bytes = generated.packets()[0] + flags = bytes[2] << 8 | bytes[3] + assert flags == 0x0 + + def test_response_header_bits(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + bytes = generated.packets()[0] + flags = bytes[2] << 8 | bytes[3] + assert flags == 0x8000 + + def test_numbers(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + bytes = generated.packets()[0] + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + assert num_questions == 0 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 + + def test_numbers_questions(self): + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) + for i in range(10): + generated.add_question(question) + bytes = generated.packets()[0] + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + assert num_questions == 10 + assert num_answers == 0 + assert num_authorities == 0 + assert num_additionals == 0 + + +class TestDnsIncoming(unittest.TestCase): + def test_incoming_exception_handling(self): + generated = r.DNSOutgoing(0) + packet = generated.packets()[0] + packet = packet[:8] + b'deadbeef' + packet[8:] + parsed = r.DNSIncoming(packet) + parsed = r.DNSIncoming(packet) + assert parsed.valid is False + + def test_incoming_unknown_type(self): + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + generated.add_additional_answer(answer) + packet = generated.packets()[0] + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 0 + assert parsed.is_query() != parsed.is_response() + + def test_incoming_circular_reference(self): + assert not r.DNSIncoming( + bytes.fromhex( + '01005e0000fb542a1bf0577608004500006897934000ff11d81bc0a86a31e00000fb' + '14e914e90054f9b2000084000000000100000000095f7365727669636573075f646e' + '732d7364045f756470056c6f63616c00000c0001000011940018105f73706f746966' + '792d636f6e6e656374045f746370c023' + ) + ).valid + + def test_incoming_ipv6(self): + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + packed = socket.inet_pton(socket.AF_INET6, addr) + generated = r.DNSOutgoing(0) + answer = r.DNSAddress('domain', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) + generated.add_additional_answer(answer) + packet = generated.packets()[0] + parsed = r.DNSIncoming(packet) + record = parsed.answers[0] + assert isinstance(record, r.DNSAddress) + assert record.address == packed + + +def test_dns_compression_rollback_for_corruption(): + """Verify rolling back does not lead to dns compression corruption.""" + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + r.DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + r.DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + for packet in out.packets(): + # Verify we can process the packets we created to + # ensure there is no corruption with the dns compression + incoming = r.DNSIncoming(packet) + assert incoming.valid is True + + +def test_tc_bit_in_query_packet(): + """Verify the TC bit is set when known answers exceed the packet size.""" + out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + type_ = "_hap._tcp.local." + out.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN)) + + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert first_packet.truncated + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert second_packet.truncated + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert not third_packet.truncated + assert third_packet.valid is True + + +def test_tc_bit_not_set_in_answer_packet(): + """Verify the TC bit is not set when there are no questions and answers exceed the packet size.""" + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + packets = out.packets() + assert len(packets) == 3 + + first_packet = r.DNSIncoming(packets[0]) + assert not first_packet.truncated + assert first_packet.valid is True + + second_packet = r.DNSIncoming(packets[1]) + assert not second_packet.truncated + assert second_packet.valid is True + + third_packet = r.DNSIncoming(packets[2]) + assert not third_packet.truncated + assert third_packet.valid is True + + +# 4003 15.973052 192.168.107.68 224.0.0.251 MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question +def test_qm_packet_parser(): + """Test we can parse a query packet with the QM bit.""" + qm_packet = ( + b'\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01' + ) + parsed = DNSIncoming(qm_packet) + assert parsed.questions[0].unicast is False + assert ",QM," in str(parsed.questions[0]) + + +# 389951 1450.577370 192.168.107.111 224.0.0.251 MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT +def test_qu_packet_parser(): + """Test we can parse a query packet with the QU bit.""" + qu_packet = b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' + parsed = DNSIncoming(qu_packet) + assert parsed.questions[0].unicast is True + assert ",QU," in str(parsed.questions[0]) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 02d2afa16..ab2b0993e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -28,8 +28,6 @@ DNSAddress, DNSEntry, DNSHinfo, - DNSIncoming, - DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, @@ -46,6 +44,7 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) +from ._protocol import DNSIncoming, DNSOutgoing # noqa # import needed for backwards compat from ._services import ( # noqa # import needed for backwards compat instance_name_from_service_info, Signal, @@ -81,6 +80,7 @@ __all__ = [ "__version__", + "DNSOutgoing", "Zeroconf", "ServiceInfo", "ServiceBrowser", diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 6aef35a32..f4fb647aa 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -32,10 +32,11 @@ from typing import Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache -from ._dns import DNSIncoming, DNSOutgoing, DNSQuestion +from ._dns import DNSQuestion from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log +from ._protocol import DNSIncoming, DNSOutgoing from ._services import ( RecordUpdateListener, ServiceBrowser, diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index d6c12a710..9a6ef73d8 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -20,39 +20,21 @@ USA """ -import enum import socket -import struct -from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast -from ._exceptions import AbstractMethodException, IncomingDecodeError, NamePartTooLongException -from ._logger import QuietLogger, log +from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address -from ._utils.struct import int2byte from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _CLASSES, _CLASS_MASK, _CLASS_UNIQUE, - _DNS_PACKET_HEADER_LEN, _EXPIRE_FULL_TIME_PERCENT, _EXPIRE_STALE_TIME_PERCENT, - _FLAGS_QR_MASK, - _FLAGS_QR_QUERY, - _FLAGS_QR_RESPONSE, - _FLAGS_TC, - _MAX_MSG_ABSOLUTE, - _MAX_MSG_TYPICAL, _RECENT_TIME_PERCENT, _TYPES, - _TYPE_A, - _TYPE_AAAA, _TYPE_ANY, - _TYPE_CNAME, - _TYPE_HINFO, - _TYPE_PTR, - _TYPE_SRV, - _TYPE_TXT, ) _LEN_BYTE = 1 @@ -64,7 +46,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from ._cache import DNSCache # pylint: disable=cyclic-import + from ._protocol import DNSIncoming, DNSOutgoing # pylint: disable=cyclic-import class DNSEntry: @@ -409,602 +391,6 @@ def __repr__(self) -> str: return self.to_string("%s:%s" % (self.server, self.port)) -class DNSMessage: - """A base class for DNS messages.""" - - def __init__(self, flags: int) -> None: - """Construct a DNS message.""" - self.flags = flags - - def is_query(self) -> bool: - """Returns true if this is a query.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def is_response(self) -> bool: - """Returns true if this is a response.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - - @property - def truncated(self) -> bool: - """Returns true if this is a truncated.""" - return (self.flags & _FLAGS_TC) == _FLAGS_TC - - -class DNSIncoming(DNSMessage, QuietLogger): - - """Object representation of an incoming DNS packet""" - - def __init__(self, data: bytes) -> None: - """Constructor from string holding bytes of packet""" - super().__init__(0) - self.offset = 0 - self.data = data - self.questions = [] # type: List[DNSQuestion] - self.answers = [] # type: List[DNSRecord] - self.id = 0 - self.num_questions = 0 - self.num_answers = 0 - self.num_authorities = 0 - self.num_additionals = 0 - self.valid = False - - try: - self.read_header() - self.read_questions() - self.read_others() - self.valid = True - - except (IndexError, struct.error, IncomingDecodeError): - self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) - - def __repr__(self) -> str: - return '' % ', '.join( - [ - 'id=%s' % self.id, - 'flags=%s' % self.flags, - 'truncated=%s' % self.truncated, - 'n_q=%s' % self.num_questions, - 'n_ans=%s' % self.num_answers, - 'n_auth=%s' % self.num_authorities, - 'n_add=%s' % self.num_additionals, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - ] - ) - - def unpack(self, format_: bytes) -> tuple: - length = struct.calcsize(format_) - info = struct.unpack(format_, self.data[self.offset : self.offset + length]) - self.offset += length - return info - - def read_header(self) -> None: - """Reads header portion of packet""" - ( - self.id, - self.flags, - self.num_questions, - self.num_answers, - self.num_authorities, - self.num_additionals, - ) = self.unpack(b'!6H') - - def read_questions(self) -> None: - """Reads questions section of packet""" - for _ in range(self.num_questions): - name = self.read_name() - type_, class_ = self.unpack(b'!HH') - - question = DNSQuestion(name, type_, class_) - self.questions.append(question) - - # def read_int(self): - # """Reads an integer from the packet""" - # return self.unpack(b'!I')[0] - - def read_character_string(self) -> bytes: - """Reads a character string from the packet""" - length = self.data[self.offset] - self.offset += 1 - return self.read_string(length) - - def read_string(self, length: int) -> bytes: - """Reads a string of a given length from the packet""" - info = self.data[self.offset : self.offset + length] - self.offset += length - return info - - def read_unsigned_short(self) -> int: - """Reads an unsigned short from the packet""" - return cast(int, self.unpack(b'!H')[0]) - - def read_others(self) -> None: - """Reads the answers, authorities and additionals section of the - packet""" - n = self.num_answers + self.num_authorities + self.num_additionals - for _ in range(n): - domain = self.read_name() - type_, class_, ttl, length = self.unpack(b'!HHiH') - rec = None # type: Optional[DNSRecord] - if type_ == _TYPE_A: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) - elif type_ in (_TYPE_CNAME, _TYPE_PTR): - rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) - elif type_ == _TYPE_TXT: - rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) - elif type_ == _TYPE_SRV: - rec = DNSService( - domain, - type_, - class_, - ttl, - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_name(), - ) - elif type_ == _TYPE_HINFO: - rec = DNSHinfo( - domain, - type_, - class_, - ttl, - self.read_character_string().decode('utf-8'), - self.read_character_string().decode('utf-8'), - ) - elif type_ == _TYPE_AAAA: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) - else: - # Try to ignore types we don't know about - # Skip the payload for the resource record so the next - # records can be parsed correctly - self.offset += length - - if rec is not None: - self.answers.append(rec) - - def read_utf(self, offset: int, length: int) -> str: - """Reads a UTF-8 string of a given length from the packet""" - return str(self.data[offset : offset + length], 'utf-8', 'replace') - - def read_name(self) -> str: - """Reads a domain name from the packet""" - result = '' - off = self.offset - next_ = -1 - first = off - - while True: - length = self.data[off] - off += 1 - if length == 0: - break - t = length & 0xC0 - if t == 0x00: - result += self.read_utf(off, length) + '.' - off += length - elif t == 0xC0: - if next_ < 0: - next_ = off + 1 - off = ((length & 0x3F) << 8) | self.data[off] - if off >= first: - raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) - first = off - else: - raise IncomingDecodeError("Bad domain name at %s" % (off,)) - - if next_ >= 0: - self.offset = next_ - else: - self.offset = off - - return result - - -class DNSOutgoing(DNSMessage): - - """Object representation of an outgoing packet""" - - def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: - super().__init__(flags) - self.finished = False - self.id = id_ - self.multicast = multicast - self.packets_data: List[bytes] = [] - - # these 3 are per-packet -- see also _reset_for_next_packet() - self.names: Dict[str, int] = {} - self.data: List[bytes] = [] - self.size: int = _DNS_PACKET_HEADER_LEN - self.allow_long: bool = True - - self.state = self.State.init - - self.questions: List[DNSQuestion] = [] - self.answers: List[Tuple[DNSRecord, float]] = [] - self.authorities: List[DNSPointer] = [] - self.additionals: List[DNSRecord] = [] - - def _reset_for_next_packet(self) -> None: - self.names = {} - self.data = [] - self.size = _DNS_PACKET_HEADER_LEN - self.allow_long = True - - def __repr__(self) -> str: - return '' % ', '.join( - [ - 'multicast=%s' % self.multicast, - 'flags=%s' % self.flags, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - 'authorities=%s' % self.authorities, - 'additionals=%s' % self.additionals, - ] - ) - - class State(enum.Enum): - init = 0 - finished = 1 - - def add_question(self, record: DNSQuestion) -> None: - """Adds a question""" - self.questions.append(record) - - def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: - """Adds an answer""" - if not record.suppressed_by(inp): - self.add_answer_at_time(record, 0) - - def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: - """Adds an answer if it does not expire by a certain time""" - if record is not None and (now == 0 or not record.is_expired(now)): - self.answers.append((record, now)) - - def add_authorative_answer(self, record: DNSPointer) -> None: - """Adds an authoritative answer""" - self.authorities.append(record) - - def add_additional_answer(self, record: DNSRecord) -> None: - """Adds an additional answer - - From: RFC 6763, DNS-Based Service Discovery, February 2013 - - 12. DNS Additional Record Generation - - DNS has an efficiency feature whereby a DNS server may place - additional records in the additional section of the DNS message. - These additional records are records that the client did not - explicitly request, but the server has reasonable grounds to expect - that the client might request them shortly, so including them can - save the client from having to issue additional queries. - - This section recommends which additional records SHOULD be generated - to improve network efficiency, for both Unicast and Multicast DNS-SD - responses. - - 12.1. PTR Records - - When including a DNS-SD Service Instance Enumeration or Selective - Instance Enumeration (subtype) PTR record in a response packet, the - server/responder SHOULD include the following additional records: - - o The SRV record(s) named in the PTR rdata. - o The TXT record(s) named in the PTR rdata. - o All address records (type "A" and "AAAA") named in the SRV rdata. - - 12.2. SRV Records - - When including an SRV record in a response packet, the - server/responder SHOULD include the following additional records: - - o All address records (type "A" and "AAAA") named in the SRV rdata. - - """ - self.additionals.append(record) - - def add_question_or_one_cache( - self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int - ) -> None: - """Add a question if it is not already cached.""" - cached_entry = cache.get_by_details(name, type_, class_) - if not cached_entry: - self.add_question(DNSQuestion(name, type_, class_)) - else: - self.add_answer_at_time(cached_entry, now) - - def add_question_or_all_cache( - self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int - ) -> None: - """Add a question if it is not already cached. - This is currently only used for IPv6 addresses. - """ - cached_entries = cache.get_all_by_details(name, type_, class_) - if not cached_entries: - self.add_question(DNSQuestion(name, type_, class_)) - return - for cached_entry in cached_entries: - self.add_answer_at_time(cached_entry, now) - - def _pack(self, format_: Union[bytes, str], value: Any) -> None: - self.data.append(struct.pack(format_, value)) - self.size += struct.calcsize(format_) - - def _write_byte(self, value: int) -> None: - """Writes a single byte to the packet""" - self._pack(b'!c', int2byte(value)) - - def _insert_short_at_start(self, value: int) -> None: - """Inserts an unsigned short at the start of the packet""" - self.data.insert(0, struct.pack(b'!H', value)) - - def _replace_short(self, index: int, value: int) -> None: - """Replaces an unsigned short in a certain position in the packet""" - self.data[index] = struct.pack(b'!H', value) - - def write_short(self, value: int) -> None: - """Writes an unsigned short to the packet""" - self._pack(b'!H', value) - - def _write_int(self, value: Union[float, int]) -> None: - """Writes an unsigned integer to the packet""" - self._pack(b'!I', int(value)) - - def write_string(self, value: bytes) -> None: - """Writes a string to the packet""" - assert isinstance(value, bytes) - self.data.append(value) - self.size += len(value) - - def _write_utf(self, s: str) -> None: - """Writes a UTF-8 string of a given length to the packet""" - utfstr = s.encode('utf-8') - length = len(utfstr) - if length > 64: - raise NamePartTooLongException - self._write_byte(length) - self.write_string(utfstr) - - def write_character_string(self, value: bytes) -> None: - assert isinstance(value, bytes) - length = len(value) - if length > 256: - raise NamePartTooLongException - self._write_byte(length) - self.write_string(value) - - def write_name(self, name: str) -> None: - """ - Write names to packet - - 18.14. Name Compression - - When generating Multicast DNS messages, implementations SHOULD use - name compression wherever possible to compress the names of resource - records, by replacing some or all of the resource record name with a - compact two-byte reference to an appearance of that data somewhere - earlier in the message [RFC1035]. - """ - - # split name into each label - parts = name.split('.') - if not parts[-1]: - parts.pop() - - # construct each suffix - name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] - - # look for an existing name or suffix - for count, sub_name in enumerate(name_suffices): - if sub_name in self.names: - break - else: - count = len(name_suffices) - - # note the new names we are saving into the packet - name_length = len(name.encode('utf-8')) - for suffix in name_suffices[:count]: - self.names[suffix] = self.size + name_length - len(suffix.encode('utf-8')) - 1 - - # write the new names out. - for part in parts[:count]: - self._write_utf(part) - - # if we wrote part of the name, create a pointer to the rest - if count != len(name_suffices): - # Found substring in packet, create pointer - index = self.names[name_suffices[count]] - self._write_byte((index >> 8) | 0xC0) - self._write_byte(index & 0xFF) - else: - # this is the end of a name - self._write_byte(0) - - def _write_question(self, question: DNSQuestion) -> bool: - """Writes a question to the packet""" - start_data_length, start_size = len(self.data), self.size - self.write_name(question.name) - self.write_short(question.type) - self._write_record_class(question) - return self._check_data_limit_or_rollback(start_data_length, start_size) - - def _write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: - """Write out the record class including the unique/unicast (QU) bit.""" - if record.unique and self.multicast: - self.write_short(record.class_ | _CLASS_UNIQUE) - else: - self.write_short(record.class_) - - def _write_ttl(self, record: DNSRecord, now: float) -> None: - """Write out the record ttl.""" - self._write_int(record.ttl if now == 0 else record.get_remaining_ttl(now)) - - def _write_record(self, record: DNSRecord, now: float) -> bool: - """Writes a record (answer, authoritative answer, additional) to - the packet. Returns True on success, or False if we did not - because the packet because the record does not fit.""" - start_data_length, start_size = len(self.data), self.size - self.write_name(record.name) - self.write_short(record.type) - self._write_record_class(record) - self._write_ttl(record, now) - index = len(self.data) - self.write_short(0) # Will get replaced with the actual size - record.write(self) - # Adjust size for the short we will write before this record - length = sum((len(d) for d in self.data[index + 1 :])) - # Here we replace the 0 length short we wrote - # before with the actual length - self._replace_short(index, length) - return self._check_data_limit_or_rollback(start_data_length, start_size) - - def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: - """Check data limit, if we go over, then rollback and return False.""" - len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL - self.allow_long = False - - if self.size <= len_limit: - return True - - log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) - del self.data[start_data_length:] - self.size = start_size - - rollback_names = [name for name, idx in self.names.items() if idx >= start_size] - for name in rollback_names: - del self.names[name] - return False - - def _write_questions_from_offset(self, questions_offset: int) -> int: - questions_written = 0 - for question in self.questions[questions_offset:]: - if not self._write_question(question): - break - questions_written += 1 - return questions_written - - def _write_answers_from_offset(self, answer_offset: int) -> int: - answers_written = 0 - for answer, time_ in self.answers[answer_offset:]: - if not self._write_record(answer, time_): - break - answers_written += 1 - return answers_written - - def _write_authorities_from_offset(self, authority_offset: int) -> int: - authorities_written = 0 - for authority in self.authorities[authority_offset:]: - if not self._write_record(authority, 0): - break - authorities_written += 1 - return authorities_written - - def _write_additionals_from_offset(self, additional_offset: int) -> int: - additionals_written = 0 - for additional in self.additionals[additional_offset:]: - if not self._write_record(additional, 0): - break - additionals_written += 1 - return additionals_written - - def _has_more_to_add( - self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int - ) -> bool: - """Check if all questions, answers, authority, and additionals have been written to the packet.""" - return ( - questions_offset < len(self.questions) - or answer_offset < len(self.answers) - or authority_offset < len(self.authorities) - or additional_offset < len(self.additionals) - ) - - def packets(self) -> List[bytes]: - """Returns a list of bytestrings containing the packets' bytes - - No further parts should be added to the packet once this - is done. The packets are each restricted to _MAX_MSG_TYPICAL - or less in length, except for the case of a single answer which - will be written out to a single oversized packet no more than - _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP - fragmentation potentially).""" - - if self.state == self.State.finished: - return self.packets_data - - questions_offset = 0 - answer_offset = 0 - authority_offset = 0 - additional_offset = 0 - # we have to at least write out the question - first_time = True - - while first_time or self._has_more_to_add( - questions_offset, answer_offset, authority_offset, additional_offset - ): - first_time = False - log.debug( - "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) - log.debug( - "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", - len(self.questions), - len(self.answers), - len(self.authorities), - len(self.additionals), - ) - - questions_written = self._write_questions_from_offset(questions_offset) - answers_written = self._write_answers_from_offset(answer_offset) - authorities_written = self._write_authorities_from_offset(authority_offset) - additionals_written = self._write_additionals_from_offset(additional_offset) - - self._insert_short_at_start(additionals_written) - self._insert_short_at_start(authorities_written) - self._insert_short_at_start(answers_written) - self._insert_short_at_start(questions_written) - - questions_offset += questions_written - answer_offset += answers_written - authority_offset += authorities_written - additional_offset += additionals_written - log.debug( - "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) - - if self.is_query() and self._has_more_to_add( - questions_offset, answer_offset, authority_offset, additional_offset - ): - # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - log.debug("Setting TC flag") - self._insert_short_at_start(self.flags | _FLAGS_TC) - else: - self._insert_short_at_start(self.flags) - - if self.multicast: - self._insert_short_at_start(0) - else: - self._insert_short_at_start(self.id) - - self.packets_data.append(b''.join(self.data)) - self._reset_for_next_packet() - - if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( - len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) - ) > 0: - log.warning("packets() made no progress adding records; returning") - break - self.state = self.State.finished - return self.packets_data - - class DNSRRSet: """A set of dns records independent of the ttl.""" diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 042ab2a12..ad6f54fbb 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -24,8 +24,9 @@ from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union from ._cache import DNSCache -from ._dns import DNSAddress, DNSIncoming, DNSOutgoing, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord +from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord from ._logger import log +from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener from ._services.registry import ServiceRegistry from ._utils.net import IPVersion diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py new file mode 100644 index 000000000..64c65b96b --- /dev/null +++ b/zeroconf/_protocol.py @@ -0,0 +1,644 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import enum +import struct +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast + +from ._dns import DNSAddress, DNSHinfo, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from ._exceptions import IncomingDecodeError, NamePartTooLongException +from ._logger import QuietLogger, log +from ._utils.struct import int2byte +from .const import ( + _CLASS_UNIQUE, + _DNS_PACKET_HEADER_LEN, + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _FLAGS_TC, + _MAX_MSG_ABSOLUTE, + _MAX_MSG_TYPICAL, + _TYPE_A, + _TYPE_AAAA, + _TYPE_CNAME, + _TYPE_HINFO, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) + + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from ._cache import DNSCache # pylint: disable=cyclic-import + + +class DNSMessage: + """A base class for DNS messages.""" + + def __init__(self, flags: int) -> None: + """Construct a DNS message.""" + self.flags = flags + + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + @property + def truncated(self) -> bool: + """Returns true if this is a truncated.""" + return (self.flags & _FLAGS_TC) == _FLAGS_TC + + +class DNSIncoming(DNSMessage, QuietLogger): + + """Object representation of an incoming DNS packet""" + + def __init__(self, data: bytes) -> None: + """Constructor from string holding bytes of packet""" + super().__init__(0) + self.offset = 0 + self.data = data + self.questions: List[DNSQuestion] = [] + self.answers: List[DNSRecord] = [] + self.id = 0 + self.num_questions = 0 + self.num_answers = 0 + self.num_authorities = 0 + self.num_additionals = 0 + self.valid = False + + try: + self.read_header() + self.read_questions() + self.read_others() + self.valid = True + + except (IndexError, struct.error, IncomingDecodeError): + self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) + + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'id=%s' % self.id, + 'flags=%s' % self.flags, + 'truncated=%s' % self.truncated, + 'n_q=%s' % self.num_questions, + 'n_ans=%s' % self.num_answers, + 'n_auth=%s' % self.num_authorities, + 'n_add=%s' % self.num_additionals, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + ] + ) + + def unpack(self, format_: bytes) -> tuple: + length = struct.calcsize(format_) + info = struct.unpack(format_, self.data[self.offset : self.offset + length]) + self.offset += length + return info + + def read_header(self) -> None: + """Reads header portion of packet""" + ( + self.id, + self.flags, + self.num_questions, + self.num_answers, + self.num_authorities, + self.num_additionals, + ) = self.unpack(b'!6H') + + def read_questions(self) -> None: + """Reads questions section of packet""" + for _ in range(self.num_questions): + name = self.read_name() + type_, class_ = self.unpack(b'!HH') + + question = DNSQuestion(name, type_, class_) + self.questions.append(question) + + def read_character_string(self) -> bytes: + """Reads a character string from the packet""" + length = self.data[self.offset] + self.offset += 1 + return self.read_string(length) + + def read_string(self, length: int) -> bytes: + """Reads a string of a given length from the packet""" + info = self.data[self.offset : self.offset + length] + self.offset += length + return info + + def read_unsigned_short(self) -> int: + """Reads an unsigned short from the packet""" + return cast(int, self.unpack(b'!H')[0]) + + def read_others(self) -> None: + """Reads the answers, authorities and additionals section of the + packet""" + n = self.num_answers + self.num_authorities + self.num_additionals + for _ in range(n): + domain = self.read_name() + type_, class_, ttl, length = self.unpack(b'!HHiH') + rec: Optional[DNSRecord] = None + if type_ == _TYPE_A: + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) + elif type_ in (_TYPE_CNAME, _TYPE_PTR): + rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) + elif type_ == _TYPE_TXT: + rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) + elif type_ == _TYPE_SRV: + rec = DNSService( + domain, + type_, + class_, + ttl, + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_name(), + ) + elif type_ == _TYPE_HINFO: + rec = DNSHinfo( + domain, + type_, + class_, + ttl, + self.read_character_string().decode('utf-8'), + self.read_character_string().decode('utf-8'), + ) + elif type_ == _TYPE_AAAA: + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) + else: + # Try to ignore types we don't know about + # Skip the payload for the resource record so the next + # records can be parsed correctly + self.offset += length + + if rec is not None: + self.answers.append(rec) + + def read_utf(self, offset: int, length: int) -> str: + """Reads a UTF-8 string of a given length from the packet""" + return str(self.data[offset : offset + length], 'utf-8', 'replace') + + def read_name(self) -> str: + """Reads a domain name from the packet""" + result = '' + off = self.offset + next_ = -1 + first = off + + while True: + length = self.data[off] + off += 1 + if length == 0: + break + t = length & 0xC0 + if t == 0x00: + result += self.read_utf(off, length) + '.' + off += length + elif t == 0xC0: + if next_ < 0: + next_ = off + 1 + off = ((length & 0x3F) << 8) | self.data[off] + if off >= first: + raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) + first = off + else: + raise IncomingDecodeError("Bad domain name at %s" % (off,)) + + if next_ >= 0: + self.offset = next_ + else: + self.offset = off + + return result + + +class DNSOutgoing(DNSMessage): + + """Object representation of an outgoing packet""" + + def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: + super().__init__(flags) + self.finished = False + self.id = id_ + self.multicast = multicast + self.packets_data: List[bytes] = [] + + # these 3 are per-packet -- see also _reset_for_next_packet() + self.names: Dict[str, int] = {} + self.data: List[bytes] = [] + self.size: int = _DNS_PACKET_HEADER_LEN + self.allow_long: bool = True + + self.state = self.State.init + + self.questions: List[DNSQuestion] = [] + self.answers: List[Tuple[DNSRecord, float]] = [] + self.authorities: List[DNSPointer] = [] + self.additionals: List[DNSRecord] = [] + + def _reset_for_next_packet(self) -> None: + self.names = {} + self.data = [] + self.size = _DNS_PACKET_HEADER_LEN + self.allow_long = True + + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'multicast=%s' % self.multicast, + 'flags=%s' % self.flags, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + 'authorities=%s' % self.authorities, + 'additionals=%s' % self.additionals, + ] + ) + + class State(enum.Enum): + init = 0 + finished = 1 + + def add_question(self, record: DNSQuestion) -> None: + """Adds a question""" + self.questions.append(record) + + def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: + """Adds an answer""" + if not record.suppressed_by(inp): + self.add_answer_at_time(record, 0) + + def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: + """Adds an answer if it does not expire by a certain time""" + if record is not None and (now == 0 or not record.is_expired(now)): + self.answers.append((record, now)) + + def add_authorative_answer(self, record: DNSPointer) -> None: + """Adds an authoritative answer""" + self.authorities.append(record) + + def add_additional_answer(self, record: DNSRecord) -> None: + """Adds an additional answer + + From: RFC 6763, DNS-Based Service Discovery, February 2013 + + 12. DNS Additional Record Generation + + DNS has an efficiency feature whereby a DNS server may place + additional records in the additional section of the DNS message. + These additional records are records that the client did not + explicitly request, but the server has reasonable grounds to expect + that the client might request them shortly, so including them can + save the client from having to issue additional queries. + + This section recommends which additional records SHOULD be generated + to improve network efficiency, for both Unicast and Multicast DNS-SD + responses. + + 12.1. PTR Records + + When including a DNS-SD Service Instance Enumeration or Selective + Instance Enumeration (subtype) PTR record in a response packet, the + server/responder SHOULD include the following additional records: + + o The SRV record(s) named in the PTR rdata. + o The TXT record(s) named in the PTR rdata. + o All address records (type "A" and "AAAA") named in the SRV rdata. + + 12.2. SRV Records + + When including an SRV record in a response packet, the + server/responder SHOULD include the following additional records: + + o All address records (type "A" and "AAAA") named in the SRV rdata. + + """ + self.additionals.append(record) + + def add_question_or_one_cache( + self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached.""" + cached_entry = cache.get_by_details(name, type_, class_) + if not cached_entry: + self.add_question(DNSQuestion(name, type_, class_)) + else: + self.add_answer_at_time(cached_entry, now) + + def add_question_or_all_cache( + self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + ) -> None: + """Add a question if it is not already cached. + This is currently only used for IPv6 addresses. + """ + cached_entries = cache.get_all_by_details(name, type_, class_) + if not cached_entries: + self.add_question(DNSQuestion(name, type_, class_)) + return + for cached_entry in cached_entries: + self.add_answer_at_time(cached_entry, now) + + def _pack(self, format_: Union[bytes, str], value: Any) -> None: + self.data.append(struct.pack(format_, value)) + self.size += struct.calcsize(format_) + + def _write_byte(self, value: int) -> None: + """Writes a single byte to the packet""" + self._pack(b'!c', int2byte(value)) + + def _insert_short_at_start(self, value: int) -> None: + """Inserts an unsigned short at the start of the packet""" + self.data.insert(0, struct.pack(b'!H', value)) + + def _replace_short(self, index: int, value: int) -> None: + """Replaces an unsigned short in a certain position in the packet""" + self.data[index] = struct.pack(b'!H', value) + + def write_short(self, value: int) -> None: + """Writes an unsigned short to the packet""" + self._pack(b'!H', value) + + def _write_int(self, value: Union[float, int]) -> None: + """Writes an unsigned integer to the packet""" + self._pack(b'!I', int(value)) + + def write_string(self, value: bytes) -> None: + """Writes a string to the packet""" + assert isinstance(value, bytes) + self.data.append(value) + self.size += len(value) + + def _write_utf(self, s: str) -> None: + """Writes a UTF-8 string of a given length to the packet""" + utfstr = s.encode('utf-8') + length = len(utfstr) + if length > 64: + raise NamePartTooLongException + self._write_byte(length) + self.write_string(utfstr) + + def write_character_string(self, value: bytes) -> None: + assert isinstance(value, bytes) + length = len(value) + if length > 256: + raise NamePartTooLongException + self._write_byte(length) + self.write_string(value) + + def write_name(self, name: str) -> None: + """ + Write names to packet + + 18.14. Name Compression + + When generating Multicast DNS messages, implementations SHOULD use + name compression wherever possible to compress the names of resource + records, by replacing some or all of the resource record name with a + compact two-byte reference to an appearance of that data somewhere + earlier in the message [RFC1035]. + """ + + # split name into each label + parts = name.split('.') + if not parts[-1]: + parts.pop() + + # construct each suffix + name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] + + # look for an existing name or suffix + for count, sub_name in enumerate(name_suffices): + if sub_name in self.names: + break + else: + count = len(name_suffices) + + # note the new names we are saving into the packet + name_length = len(name.encode('utf-8')) + for suffix in name_suffices[:count]: + self.names[suffix] = self.size + name_length - len(suffix.encode('utf-8')) - 1 + + # write the new names out. + for part in parts[:count]: + self._write_utf(part) + + # if we wrote part of the name, create a pointer to the rest + if count != len(name_suffices): + # Found substring in packet, create pointer + index = self.names[name_suffices[count]] + self._write_byte((index >> 8) | 0xC0) + self._write_byte(index & 0xFF) + else: + # this is the end of a name + self._write_byte(0) + + def _write_question(self, question: DNSQuestion) -> bool: + """Writes a question to the packet""" + start_data_length, start_size = len(self.data), self.size + self.write_name(question.name) + self.write_short(question.type) + self._write_record_class(question) + return self._check_data_limit_or_rollback(start_data_length, start_size) + + def _write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: + """Write out the record class including the unique/unicast (QU) bit.""" + if record.unique and self.multicast: + self.write_short(record.class_ | _CLASS_UNIQUE) + else: + self.write_short(record.class_) + + def _write_ttl(self, record: DNSRecord, now: float) -> None: + """Write out the record ttl.""" + self._write_int(record.ttl if now == 0 else record.get_remaining_ttl(now)) + + def _write_record(self, record: DNSRecord, now: float) -> bool: + """Writes a record (answer, authoritative answer, additional) to + the packet. Returns True on success, or False if we did not + because the packet because the record does not fit.""" + start_data_length, start_size = len(self.data), self.size + self.write_name(record.name) + self.write_short(record.type) + self._write_record_class(record) + self._write_ttl(record, now) + index = len(self.data) + self.write_short(0) # Will get replaced with the actual size + record.write(self) + # Adjust size for the short we will write before this record + length = sum((len(d) for d in self.data[index + 1 :])) + # Here we replace the 0 length short we wrote + # before with the actual length + self._replace_short(index, length) + return self._check_data_limit_or_rollback(start_data_length, start_size) + + def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: + """Check data limit, if we go over, then rollback and return False.""" + len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL + self.allow_long = False + + if self.size <= len_limit: + return True + + log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) + del self.data[start_data_length:] + self.size = start_size + + rollback_names = [name for name, idx in self.names.items() if idx >= start_size] + for name in rollback_names: + del self.names[name] + return False + + def _write_questions_from_offset(self, questions_offset: int) -> int: + questions_written = 0 + for question in self.questions[questions_offset:]: + if not self._write_question(question): + break + questions_written += 1 + return questions_written + + def _write_answers_from_offset(self, answer_offset: int) -> int: + answers_written = 0 + for answer, time_ in self.answers[answer_offset:]: + if not self._write_record(answer, time_): + break + answers_written += 1 + return answers_written + + def _write_authorities_from_offset(self, authority_offset: int) -> int: + authorities_written = 0 + for authority in self.authorities[authority_offset:]: + if not self._write_record(authority, 0): + break + authorities_written += 1 + return authorities_written + + def _write_additionals_from_offset(self, additional_offset: int) -> int: + additionals_written = 0 + for additional in self.additionals[additional_offset:]: + if not self._write_record(additional, 0): + break + additionals_written += 1 + return additionals_written + + def _has_more_to_add( + self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int + ) -> bool: + """Check if all questions, answers, authority, and additionals have been written to the packet.""" + return ( + questions_offset < len(self.questions) + or answer_offset < len(self.answers) + or authority_offset < len(self.authorities) + or additional_offset < len(self.additionals) + ) + + def packets(self) -> List[bytes]: + """Returns a list of bytestrings containing the packets' bytes + + No further parts should be added to the packet once this + is done. The packets are each restricted to _MAX_MSG_TYPICAL + or less in length, except for the case of a single answer which + will be written out to a single oversized packet no more than + _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP + fragmentation potentially).""" + + if self.state == self.State.finished: + return self.packets_data + + questions_offset = 0 + answer_offset = 0 + authority_offset = 0 + additional_offset = 0 + # we have to at least write out the question + first_time = True + + while first_time or self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset + ): + first_time = False + log.debug( + "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + log.debug( + "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", + len(self.questions), + len(self.answers), + len(self.authorities), + len(self.additionals), + ) + + questions_written = self._write_questions_from_offset(questions_offset) + answers_written = self._write_answers_from_offset(answer_offset) + authorities_written = self._write_authorities_from_offset(authority_offset) + additionals_written = self._write_additionals_from_offset(additional_offset) + + self._insert_short_at_start(additionals_written) + self._insert_short_at_start(authorities_written) + self._insert_short_at_start(answers_written) + self._insert_short_at_start(questions_written) + + questions_offset += questions_written + answer_offset += answers_written + authority_offset += authorities_written + additional_offset += additionals_written + log.debug( + "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + + if self.is_query() and self._has_more_to_add( + questions_offset, answer_offset, authority_offset, additional_offset + ): + # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + log.debug("Setting TC flag") + self._insert_short_at_start(self.flags | _FLAGS_TC) + else: + self._insert_short_at_start(self.flags) + + if self.multicast: + self._insert_short_at_start(0) + else: + self._insert_short_at_start(self.id) + + self.packets_data.append(b''.join(self.data)) + self._reset_for_next_packet() + + if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( + len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) + ) > 0: + log.warning("packets() made no progress adding records; returning") + break + self.state = self.State.finished + return self.packets_data diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 818b3bb6c..70558c82f 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -27,8 +27,9 @@ from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast -from .._dns import DNSAddress, DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException +from .._protocol import DNSOutgoing from .._utils.name import service_type_name from .._utils.net import ( IPVersion, From dc0c6137742edf97626c972e5c9191dfbffaecdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 10:42:38 -1000 Subject: [PATCH 0408/1433] Fix thread safety in _ServiceBrowser.update_records_complete (#708) --- zeroconf/_services/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 70558c82f..b6481eb31 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -369,8 +369,15 @@ def update_records_complete(self) -> None: At this point the cache will have the new records. """ - self._handlers_to_call.update(self._pending_handlers) - self._pending_handlers.clear() + # Cannot use .update here since PyPy can fail with + # RuntimeError: dictionary changed size during iteration + # for threaded ServiceBrowsers + while self._pending_handlers: + try: + (name_type, state_change) = self._pending_handlers.popitem(False) + except KeyError: + return + self._handlers_to_call[name_type] = state_change def cancel(self) -> None: """Cancel the browser.""" From f3eeecd84413b510b9b8e05e2d1f6ad99d0dc37d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 10:42:50 -1000 Subject: [PATCH 0409/1433] Set stale unique records to expire 1s in the future instead of instant removal (#706) - Fixes #475 - https://tools.ietf.org/html/rfc6762#section-10.2 Queriers receiving a Multicast DNS response with a TTL of zero SHOULD NOT immediately delete the record from the cache, but instead record a TTL of 1 and then delete the record one second later. In the case of multiple Multicast DNS responders on the network described in Section 6.6 above, if one of the responders shuts down and incorrectly sends goodbye packets for its records, it gives the other cooperating responders one second to send out their own response to "rescue" the records before they expire and are deleted. --- tests/test_core.py | 4 ++-- tests/test_handlers.py | 4 ++-- zeroconf/_dns.py | 4 ++-- zeroconf/_handlers.py | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 4d001208c..f8577a6bf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -17,7 +17,7 @@ from typing import cast import zeroconf as r -from zeroconf import _core, const, ServiceBrowser, Zeroconf +from zeroconf import _core, const, ServiceBrowser, Zeroconf, current_time_millis from . import has_working_ipv6, _clear_cache, _inject_response @@ -241,7 +241,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # service removed _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) - assert dns_text is None + assert dns_text.is_expired(current_time_millis() + 1000) finally: zeroconf.close() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 11c077b40..502563619 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -735,7 +735,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # Add the A record to the cache with 50% ttl remaining a_record = info.dns_addresses()[0] - a_record._set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) + a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) assert not a_record.is_recent(current_time_millis()) zc.cache.add(a_record) @@ -776,7 +776,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # Remove the 100% PTR record and add a 50% PTR record zc.cache.remove(ptr_record) - ptr_record._set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) + ptr_record.set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) assert not ptr_record.is_recent(current_time_millis()) zc.cache.add(ptr_record) # With QU should respond to only multicast since the has less diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 9a6ef73d8..b5b2bb790 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -194,9 +194,9 @@ def is_recent(self, now: float) -> bool: def reset_ttl(self, other: 'DNSRecord') -> None: """Sets this record's TTL and created time to that of another record.""" - self._set_created_ttl(other.created, other.ttl) + self.set_created_ttl(other.created, other.ttl) - def _set_created_ttl(self, created: float, ttl: Union[float, int]) -> None: + def set_created_ttl(self, created: float, ttl: Union[float, int]) -> None: """Set the created and ttl of a record.""" self.created = created self.ttl = ttl diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index ad6f54fbb..f8e9c6dfb 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -298,7 +298,8 @@ def updates_from_response(self, msg: DNSIncoming) -> None: if entry == record: updated = False if record.created - entry.created > 1000 and entry not in msg.answers: - removes.append(entry) + # Expire in 1s + entry.set_created_ttl(now, 1) expired = record.is_expired(now) maybe_entry = self.cache.get(record) From c366c8cc45f565c4066fc72b481c6a960bac1cb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 11:13:53 -1000 Subject: [PATCH 0410/1433] Synchronize created time for incoming and outgoing queries (#709) --- tests/test_protocol.py | 26 ++++++++++++++++++++ zeroconf/_core.py | 11 +++++---- zeroconf/_dns.py | 33 ++++++++++++++++--------- zeroconf/_handlers.py | 44 ++++++++++++++++++++++------------ zeroconf/_protocol.py | 13 ++++++---- zeroconf/_services/__init__.py | 17 +++++++++---- 6 files changed, 104 insertions(+), 40 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8b4ebd043..ebdb71105 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -720,3 +720,29 @@ def test_qu_packet_parser(): parsed = DNSIncoming(qu_packet) assert parsed.questions[0].unicast is True assert ",QU," in str(parsed.questions[0]) + + +def test_records_same_packet_share_fate(): + """Test records in the same packet all have the same created time.""" + out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) + type_ = "_hap._tcp.local." + out.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN)) + + for i in range(30): + out.add_answer_at_time( + DNSText( + ("HASS Bridge W9DN %s._hap._tcp.local." % i), + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for packet in out.packets(): + dnsin = DNSIncoming(packet) + first_time = dnsin.answers[0].created + for answer in dnsin.answers: + assert answer.created == first_time diff --git a/zeroconf/_core.py b/zeroconf/_core.py index f4fb647aa..12f3c5f43 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -465,19 +465,20 @@ def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: d # # _CLASS_UNIQUE is the "QU" bit out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN | _CLASS_UNIQUE)) - out.add_authorative_answer(info.dns_pointer()) + out.add_authorative_answer(info.dns_pointer(created=current_time_millis())) return out def _add_broadcast_answer( # pylint: disable=no-self-use self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int] ) -> None: """Add answers to broadcast a service.""" + now = current_time_millis() other_ttl = info.other_ttl if override_ttl is None else override_ttl host_ttl = info.host_ttl if override_ttl is None else override_ttl - out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) - out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) - out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) - for dns_address in info.dns_addresses(override_ttl=host_ttl): + out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl, created=now), 0) + out.add_answer_at_time(info.dns_service(override_ttl=host_ttl, created=now), 0) + out.add_answer_at_time(info.dns_text(override_ttl=other_ttl, created=now), 0) + for dns_address in info.dns_addresses(override_ttl=host_ttl, created=now): out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index b5b2bb790..c6d0108e0 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -139,10 +139,12 @@ class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" # TODO: Switch to just int ttl - def __init__(self, name: str, type_: int, class_: int, ttl: Union[float, int]) -> None: + def __init__( + self, name: str, type_: int, class_: int, ttl: Union[float, int], created: Optional[float] = None + ) -> None: super().__init__(name, type_, class_) self.ttl = ttl - self.created = current_time_millis() + self.created = created or current_time_millis() self._expiration_time: Optional[float] = None self._stale_time: Optional[float] = None self._recent_time: Optional[float] = None @@ -218,8 +220,10 @@ class DNSAddress(DNSRecord): """A DNS address record""" - def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None: - super().__init__(name, type_, class_, ttl) + def __init__( + self, name: str, type_: int, class_: int, ttl: int, address: bytes, created: Optional[float] = None + ) -> None: + super().__init__(name, type_, class_, ttl, created) self.address = address def write(self, out: 'DNSOutgoing') -> None: @@ -252,8 +256,10 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" - def __init__(self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str) -> None: - super().__init__(name, type_, class_, ttl) + def __init__( + self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str, created: Optional[float] = None + ) -> None: + super().__init__(name, type_, class_, ttl, created) self.cpu = cpu self.os = os @@ -284,8 +290,10 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" - def __init__(self, name: str, type_: int, class_: int, ttl: int, alias: str) -> None: - super().__init__(name, type_, class_, ttl) + def __init__( + self, name: str, type_: int, class_: int, ttl: int, alias: str, created: Optional[float] = None + ) -> None: + super().__init__(name, type_, class_, ttl, created) self.alias = alias @property @@ -319,9 +327,11 @@ class DNSText(DNSRecord): """A DNS text record""" - def __init__(self, name: str, type_: int, class_: int, ttl: int, text: bytes) -> None: + def __init__( + self, name: str, type_: int, class_: int, ttl: int, text: bytes, created: Optional[float] = None + ) -> None: assert isinstance(text, (bytes, type(None))) - super().__init__(name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl, created) self.text = text def write(self, out: 'DNSOutgoing') -> None: @@ -357,8 +367,9 @@ def __init__( weight: int, port: int, server: str, + created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl) + super().__init__(name, type_, class_, ttl, created) self.priority = priority self.weight = weight self.port = port diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index f8e9c6dfb..1d6cac4c5 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -162,7 +162,7 @@ def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: self.cache = cache def _add_service_type_enumeration_query_answers( - self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float ) -> None: """Provide an answer to a service type enumeration query. @@ -170,47 +170,60 @@ def _add_service_type_enumeration_query_answers( """ for stype in self.registry.get_types(): dns_pointer = DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype + _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, now ) if not known_answers.suppresses(dns_pointer): answer_set[dns_pointer] = set() def _add_pointer_answers( - self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float ) -> None: """Answer PTR/ANY question.""" for service in self.registry.get_infos_type(name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. - dns_pointer = service.dns_pointer() + dns_pointer = service.dns_pointer(created=now) if not known_answers.suppresses(dns_pointer): answer_set[dns_pointer] = set( - [service.dns_service(), service.dns_text(), *service.dns_addresses()] + [ + service.dns_service(created=now), + service.dns_text(created=now), + *service.dns_addresses(created=now), + ] ) def _add_address_answers( - self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, type_: int + self, + name: str, + answer_set: _AnswerWithAdditionalsType, + known_answers: DNSRRSet, + now: float, + type_: int, ) -> None: """Answer A/AAAA/ANY question.""" for service in self.registry.get_infos_server(name): - for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_]): + for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_], created=now): if not known_answers.suppresses(dns_address): answer_set[dns_address] = set() def _answer_question( - self, question: DNSQuestion, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, + question: DNSQuestion, + answer_set: _AnswerWithAdditionalsType, + known_answers: DNSRRSet, + now: float, ) -> None: if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: - self._add_service_type_enumeration_query_answers(answer_set, known_answers) + self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) return type_ = question.type if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question.name, answer_set, known_answers) + self._add_pointer_answers(question.name, answer_set, known_answers, now) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question.name, answer_set, known_answers, type_) + self._add_address_answers(question.name, answer_set, known_answers, now, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): service = self.registry.get_info_name(question.name) # type: ignore @@ -218,11 +231,11 @@ def _answer_question( if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. - dns_service = service.dns_service() + dns_service = service.dns_service(created=now) if not known_answers.suppresses(dns_service): - answer_set[dns_service] = set(service.dns_addresses()) + answer_set[dns_service] = set(service.dns_addresses(created=now)) if type_ in (_TYPE_TXT, _TYPE_ANY): - dns_text = service.dns_text() + dns_text = service.dns_text(created=now) if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() @@ -233,10 +246,11 @@ def response( # pylint: disable=unused-argument ucast_source = port != _MDNS_PORT known_answers = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) query_res = _QueryResponse(self.cache, msgs[0], ucast_source) + now = current_time_millis() for question in itertools.chain(*[msg.questions for msg in msgs]): answer_set: _AnswerWithAdditionalsType = {} - self._answer_question(question, answer_set, known_answers) + self._answer_question(question, answer_set, known_answers, now) if not ucast_source and question.unicast: query_res.add_qu_question_response(answer_set) else: diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 64c65b96b..80ca7b886 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -24,10 +24,12 @@ import struct from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast + from ._dns import DNSAddress, DNSHinfo, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from ._exceptions import IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log from ._utils.struct import int2byte +from ._utils.time import current_time_millis from .const import ( _CLASS_UNIQUE, _DNS_PACKET_HEADER_LEN, @@ -90,6 +92,7 @@ def __init__(self, data: bytes) -> None: self.num_authorities = 0 self.num_additionals = 0 self.valid = False + self.now = current_time_millis() try: self.read_header() @@ -166,11 +169,11 @@ def read_others(self) -> None: type_, class_, ttl, length = self.unpack(b'!HHiH') rec: Optional[DNSRecord] = None if type_ == _TYPE_A: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4)) + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4), self.now) elif type_ in (_TYPE_CNAME, _TYPE_PTR): - rec = DNSPointer(domain, type_, class_, ttl, self.read_name()) + rec = DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) elif type_ == _TYPE_TXT: - rec = DNSText(domain, type_, class_, ttl, self.read_string(length)) + rec = DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) elif type_ == _TYPE_SRV: rec = DNSService( domain, @@ -181,6 +184,7 @@ def read_others(self) -> None: self.read_unsigned_short(), self.read_unsigned_short(), self.read_name(), + self.now, ) elif type_ == _TYPE_HINFO: rec = DNSHinfo( @@ -190,9 +194,10 @@ def read_others(self) -> None: ttl, self.read_character_string().decode('utf-8'), self.read_character_string().decode('utf-8'), + self.now, ) elif type_ == _TYPE_AAAA: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16)) + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16), self.now) else: # Try to ignore types we don't know about # Skip the payload for the resource record so the next diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index b6481eb31..c6efcc8e3 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -369,7 +369,7 @@ def update_records_complete(self) -> None: At this point the cache will have the new records. """ - # Cannot use .update here since PyPy can fail with + # Cannot use .update here since can fail with # RuntimeError: dictionary changed size during iteration # for threaded ServiceBrowsers while self._pending_handlers: @@ -722,7 +722,10 @@ def _process_record(self, record: DNSRecord, now: float) -> None: self._set_text(record.text) def dns_addresses( - self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All + self, + override_ttl: Optional[int] = None, + version: IPVersion = IPVersion.All, + created: Optional[float] = None, ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" return [ @@ -732,11 +735,12 @@ def dns_addresses( _CLASS_IN | _CLASS_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, address, + created, ) for address in self.addresses_by_version(version) ] - def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: + def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" return DNSPointer( self.type, @@ -744,9 +748,10 @@ def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: _CLASS_IN, override_ttl if override_ttl is not None else self.other_ttl, self.name, + created, ) - def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: + def dns_service(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSService: """Return DNSService from ServiceInfo.""" return DNSService( self.name, @@ -757,9 +762,10 @@ def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: self.weight, cast(int, self.port), self.server, + created, ) - def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: + def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSText: """Return DNSText from ServiceInfo.""" return DNSText( self.name, @@ -767,6 +773,7 @@ def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: _CLASS_IN | _CLASS_UNIQUE, override_ttl if override_ttl is not None else self.other_ttl, self.text, + created, ) def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: From aeb1b23defa2d5956a6f19acca4ce410d6a04cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 11:15:03 -1000 Subject: [PATCH 0411/1433] Add setter for DNSQuestion to easily make a QU question (#710) Closes #703 --- tests/test_handlers.py | 18 +++++++++--------- zeroconf/_dns.py | 5 +++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 502563619..c62f6a11f 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -415,7 +415,7 @@ def _validate_complete_response(query, out): # With QU should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) @@ -429,7 +429,7 @@ def _validate_complete_response(query, out): # With QU should respond to only multicast since the response hasn't been seen since 75% of the ttl query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) unicast_out, multicast_out = zc.query_handler.response( @@ -441,7 +441,7 @@ def _validate_complete_response(query, out): # With QU set and an authorative answer (probe) should respond to both unitcast and multicast since the response hasn't been seen since 75% of the ttl query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) query.add_authorative_answer(info2.dns_pointer()) @@ -455,7 +455,7 @@ def _validate_complete_response(query, out): # With the cache repopulated; should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) unicast_out, multicast_out = zc.query_handler.response( @@ -743,7 +743,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # even if the additional has not been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) @@ -763,7 +763,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # even if the additional has not been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) @@ -783,7 +783,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # than 75% of its ttl remaining query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) @@ -803,12 +803,12 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # than 75% of its ttl remaining query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) question = r.DNSQuestion(info2.type, const._TYPE_PTR, const._CLASS_IN) - question.unique = True # Set the QU bit + question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) zc.cache.add(info2.dns_pointer()) # Add 100% TTL for info2 to the cache diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index c6d0108e0..5b7fe70fe 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -124,6 +124,11 @@ def unicast(self) -> bool: """ return self.unique + @unicast.setter + def unicast(self, value: bool) -> None: + """Sets the QU bit (not QM).""" + self.unique = value + def __repr__(self) -> str: """String representation""" return "%s[question,%s,%s,%s]" % ( From 6b923deb3682088d0fe9182377b5603d0ade1e1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:03:45 -1000 Subject: [PATCH 0412/1433] Cleanup typing in zeroconf._services.registry (#712) --- zeroconf/_services/registry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 8ec34120a..20584b3a6 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -40,9 +40,9 @@ def __init__( self, ) -> None: """Create the ServiceRegistry class.""" - self._services = {} # type: Dict[str, ServiceInfo] - self.types = {} # type: Dict[str, List] - self.servers = {} # type: Dict[str, List] + self._services: Dict[str, ServiceInfo] = {} + self.types: Dict[str, List] = {} + self.servers: Dict[str, List] = {} self._lock = threading.Lock() # add and remove services thread safe def add(self, info: ServiceInfo) -> None: From a42512ca6a6a4c15f37ab623a96deb2aa06dd053 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:04:29 -1000 Subject: [PATCH 0413/1433] Cleanup typing in zeroconf._services (#711) --- zeroconf/_services/__init__.py | 63 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index c6efcc8e3..c71aaed46 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -99,7 +99,7 @@ def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: class Signal: def __init__(self) -> None: - self._handlers = [] # type: List[Callable[..., None]] + self._handlers: List[Callable[..., None]] = [] def fire(self, **kwargs: Any) -> None: for h in list(self._handlers): @@ -233,7 +233,7 @@ def __init__( ) -> None: """Creates a browser for a specific type""" assert handlers or listener, 'You need to specify at least one handler' - self.types = set(type_ if isinstance(type_, list) else [type_]) # type: Set[str] + self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) for check_type_ in self.types: # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) @@ -245,9 +245,8 @@ def __init__( current_time = current_time_millis() self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} - self._pending_handlers = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] - self._handlers_to_call = OrderedDict() # type: OrderedDict[Tuple[str, str], ServiceStateChange] - + self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() + self._handlers_to_call: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.done = False @@ -552,13 +551,13 @@ def __init__( self.port = port self.weight = weight self.priority = priority - if server: - self.server = server - else: - self.server = name + self.server = server if server else name self.server_key = self.server.lower() - self._properties = {} # type: Dict - self._set_properties(properties) + self._properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} + if isinstance(properties, bytes): + self._set_text(properties) + else: + self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl @@ -618,33 +617,33 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: for addr in result ] - def _set_properties(self, properties: Union[bytes, Dict]) -> None: + def _set_properties(self, properties: Dict) -> None: """Sets properties and text of this info from a dictionary""" - if isinstance(properties, dict): - self._properties = properties - list_ = [] - result = b'' - for key, value in properties.items(): - if isinstance(key, str): - key = key.encode('utf-8') - - record = key - if value is not None: - if not isinstance(value, bytes): - value = str(value).encode('utf-8') - record += b'=' + value - list_.append(record) - for item in list_: - result = b''.join((result, int2byte(len(item)), item)) - self.text = result - else: - self.text = properties + self._properties = properties + list_ = [] + result = b'' + for key, value in properties.items(): + if isinstance(key, str): + key = key.encode('utf-8') + + record = key + if value is not None: + if not isinstance(value, bytes): + value = str(value).encode('utf-8') + record += b'=' + value + list_.append(record) + for item in list_: + result = b''.join((result, int2byte(len(item)), item)) + self.text = result def _set_text(self, text: bytes) -> None: """Sets properties and text given a text field""" self.text = text - result = {} # type: Dict end = len(text) + if end == 0: + self._properties = {} + return + result: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} index = 0 strs = [] while index < end: From a50b3eeda5f275c31b36cdc1c8312f61599e72bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:04:38 -1000 Subject: [PATCH 0414/1433] Cleanup typing in zeroconf._utils.net (#713) --- zeroconf/_utils/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 8d1b60bc6..19500e0f7 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -135,7 +135,7 @@ def normalize_interface_choice( :param ip_address: IP version to use (ignored if `choice` is a list). :returns: List of IP addresses (for IPv4) and indexes (for IPv6). """ - result = [] # type: List[Union[str, Tuple[Tuple[str, int, int], int]]] + result: List[Union[str, Tuple[Tuple[str, int, int], int]]] = [] if choice is InterfaceChoice.Default: if ip_version != IPVersion.V4Only: # IPv6 multicast uses interface 0 to mean the default From 3fcdcfd9a3efc56a34f0334ffb8706613e07d19d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:15:22 -1000 Subject: [PATCH 0415/1433] Cleanup typing in zeroconf._logger (#715) --- zeroconf/_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index b7cb745a6..3577bb053 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -32,7 +32,7 @@ class QuietLogger: - _seen_logs = {} # type: Dict[str, Union[int, tuple]] + _seen_logs: Dict[str, Union[int, tuple]] = {} @classmethod def log_exception_warning(cls, *logger_data: Any) -> None: From 0f2f4e207cb5007112ba09e87a332b1a46cd1577 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:15:32 -1000 Subject: [PATCH 0416/1433] Update README (#716) --- README.rst | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index eb730cdfd..b67936901 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: * isn't tied to Bonjour or Avahi * doesn't use D-Bus -* doesn't force you to use particular event loop or Twisted +* doesn't force you to use particular event loop or Twisted (asyncio is used under the hood but not required) * is pip-installable * has PyPI distribution @@ -59,8 +59,14 @@ This project's versions follow the following pattern: MAJOR.MINOR.PATCH. Status ------ -There are some people using this package. I don't actively use it and as such -any help I can offer with regard to any issues is very limited. +This project is actively maintained. + +Traffic Reduction +----------------- + +Before version 0.32, most traffic reduction techniques described in https://datatracker.ietf.org/doc/html/rfc6762#section-7 +where not implemented which could lead to excessive network traffic. It is highly recommended that version 0.32 or later +is used if this is a concern. IPv6 support ------------ From 818364008e911757fca24e41a4eb36e0eef49bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:34:30 -1000 Subject: [PATCH 0417/1433] Cleanup typing in zero._core and document ignores (#714) --- zeroconf/_core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 12f3c5f43..553b349de 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -190,7 +190,7 @@ class AsyncListener(asyncio.Protocol, QuietLogger): def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc - self.data = None # type: Optional[bytes] + self.data: Optional[bytes] = None self.transport: Optional[asyncio.DatagramTransport] = None super().__init__() @@ -199,8 +199,10 @@ def datagram_received( ) -> None: assert self.transport is not None if len(addrs) == 2: + # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore elif len(addrs) == 4: + # https://github.com/python/mypy/issues/1178 addr, port, _flow, _scope = addrs # type: ignore else: return From 1ab685960bc0e412d36baf6794fde06350998474 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 12:34:38 -1000 Subject: [PATCH 0418/1433] Update changelog (#717) --- README.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.rst b/README.rst index b67936901..5e5c7ea7e 100644 --- a/README.rst +++ b/README.rst @@ -231,6 +231,34 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Cleanup typing in zero._core and document ignores (#714) @bdraco + +* Cleanup typing in zeroconf._logger (#715) @bdraco + +* Cleanup typing in zeroconf._utils.net (#713) @bdraco + +* Cleanup typing in zeroconf._services (#711) @bdraco + +* Cleanup typing in zeroconf._services.registry (#712) @bdraco + +* Add setter for DNSQuestion to easily make a QU question (#710) @bdraco + +* Set stale unique records to expire 1s in the future instead of instant removal (#706) @bdraco + + tools.ietf.org/html/rfc6762#section-10.2 + Queriers receiving a Multicast DNS response with a TTL of zero SHOULD + NOT immediately delete the record from the cache, but instead record + a TTL of 1 and then delete the record one second later. In the case + of multiple Multicast DNS responders on the network described in + Section 6.6 above, if one of the responders shuts down and + incorrectly sends goodbye packets for its records, it gives the other + cooperating responders one second to send out their own response to + "rescue" the records before they expire and are deleted. + +* Fix thread safety in _ServiceBrowser.update_records_complete (#708) @bdraco + +* Split DNSOutgoing/DNSIncoming/DNSMessage into zeroconf._protocol (#705) @bdraco + * Abstract DNSOutgoing ttl write into _write_ttl (#695) @bdraco * Rollback data in one call instead of poping one byte at a time in DNS Outgoing (#696) @bdraco From 18ddb8dbeef3edad3bb97131803dfecde4355467 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Jun 2021 13:58:07 -1000 Subject: [PATCH 0419/1433] Synchronize time for fate sharing (#718) --- zeroconf/_handlers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 1d6cac4c5..c42641f46 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -246,14 +246,14 @@ def response( # pylint: disable=unused-argument ucast_source = port != _MDNS_PORT known_answers = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) query_res = _QueryResponse(self.cache, msgs[0], ucast_source) - now = current_time_millis() - - for question in itertools.chain(*[msg.questions for msg in msgs]): - answer_set: _AnswerWithAdditionalsType = {} - self._answer_question(question, answer_set, known_answers, now) - if not ucast_source and question.unicast: - query_res.add_qu_question_response(answer_set) - else: + + for msg in msgs: + for question in msg.questions: + answer_set: _AnswerWithAdditionalsType = {} + self._answer_question(question, answer_set, known_answers, msg.now) + if not ucast_source and question.unicast: + query_res.add_qu_question_response(answer_set) + continue if ucast_source: query_res.add_ucast_question_response(answer_set) # We always multicast as well even if its a unicast @@ -298,7 +298,7 @@ def updates_from_response(self, msg: DNSIncoming) -> None: address_adds: List[DNSAddress] = [] other_adds: List[DNSRecord] = [] removes: List[DNSRecord] = [] - now = current_time_millis() + now = msg.now for record in msg.answers: updated = True From e2d4d98db70b376c53883367b3a24c1d2510c2b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 09:26:49 -1000 Subject: [PATCH 0420/1433] Relocate cache tests to tests/test_cache.py (#722) --- tests/test_cache.py | 63 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_dns.py | 37 -------------------------- 2 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 tests/test_cache.py diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..8580b3669 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._cache. """ + +import logging +import unittest +import unittest.mock + +import zeroconf as r +from zeroconf import const + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestDNSCache(unittest.TestCase): + def test_order(self): + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) + cached_record = cache.get(entry) + assert cached_record == record2 + + def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache + + def test_cache_empty_multiple_calls_does_not_throw(self): + record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + assert 'a' in cache.cache + cache.remove(record1) + cache.remove(record2) + # Ensure multiple removes does not throw + cache.remove(record1) + cache.remove(record2) + assert 'a' not in cache.cache diff --git a/tests/test_dns.py b/tests/test_dns.py index 557802e18..197357067 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -159,43 +159,6 @@ def test_dns_record_is_recent(self): assert record.is_recent(now + (8 * 1000)) is False -class TestDNSCache(unittest.TestCase): - def test_order(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) - cached_record = cache.get(entry) - assert cached_record == record2 - - def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) - assert 'a' not in cache.cache - - def test_cache_empty_multiple_calls_does_not_throw(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - cache = r.DNSCache() - cache.add(record1) - cache.add(record2) - assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) - # Ensure multiple removes does not throw - cache.remove(record1) - cache.remove(record2) - assert 'a' not in cache.cache - - def test_dns_record_hashablity_does_not_consider_ttl(): """Test DNSRecord are hashable.""" From 33385948da9123bc9348374edce7502abd898e82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 11:26:15 -1000 Subject: [PATCH 0421/1433] Fix ServiceInfo with multiple A records (#725) --- tests/test_services.py | 23 +++++++++++++++++++++++ zeroconf/_services/__init__.py | 12 +++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 147c12256..9b1f205e5 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -24,6 +24,7 @@ ServiceInfo, ServiceStateChange, ) +from zeroconf.aio import AsyncZeroconf from . import has_working_ipv6, _clear_cache, _inject_response @@ -947,6 +948,28 @@ def test_multiple_addresses(): assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_multiple_a_addresses(): + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + cache = aiozc.zeroconf.cache + host = "multahost.local." + record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'a') + record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'b') + cache.add(record1) + cache.add(record2) + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + info.load_from_cache(aiozc.zeroconf) + assert set(info.addresses) == set([b'a', b'b']) + await aiozc.async_close() + + def test_backoff(): got_query = Event() diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index c71aaed46..b2005d778 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -777,12 +777,10 @@ def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: """Get the address records from the cache.""" - address_records = [] - cached_a_record = zc.cache.get_by_details(self.server, _TYPE_A, _CLASS_IN) - if cached_a_record: - address_records.append(cached_a_record) - address_records.extend(zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN)) - return address_records + return [ + *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), + *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), + ] def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache.""" @@ -844,7 +842,7 @@ def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_one_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) return out From f91af79c8779ac235598f5584f439c78b3bdcca2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 11:36:54 -1000 Subject: [PATCH 0422/1433] Rename handlers and internals to make it clear what is threadsafe (#726) - It was too easy to get confused about what was threadsafe and what was not threadsafe which lead to unexpected failures. Rename functions to make it clear what will be run in the event loop and what is expected to be threadsafe --- tests/test_handlers.py | 52 +++++++++++++++++----------------- tests/test_services.py | 4 +-- zeroconf/_core.py | 8 +++--- zeroconf/_handlers.py | 36 +++++++++++++++-------- zeroconf/_services/__init__.py | 44 ++++++++++++++++++++-------- 5 files changed, 88 insertions(+), 56 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c62f6a11f..0645a24b9 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -96,7 +96,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - multicast_out = zc.query_handler.response( + multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT )[1] _process_outgoing_packet(multicast_out) @@ -134,7 +134,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) _process_outgoing_packet( - zc.query_handler.response( + zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT )[1] ) @@ -232,7 +232,7 @@ def test_ptr_optimization(): # Verify we won't respond for 1s with the same multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT ) assert unicast_out is None @@ -244,7 +244,7 @@ def test_ptr_optimization(): # Verify we will now respond query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT ) assert multicast_out.id == query.id @@ -287,7 +287,7 @@ def test_any_query_for_ptr(): question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.response( + _, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert multicast_out.answers[0][0].name == type_ @@ -313,7 +313,7 @@ def test_aaaa_query(): question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.response( + _, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert multicast_out.answers[0][0].address == ipv6_address @@ -342,7 +342,7 @@ def test_unicast_response(): # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", 1234 ) for out in (unicast_out, multicast_out): @@ -419,7 +419,7 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None @@ -432,7 +432,7 @@ def _validate_complete_response(query, out): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -445,7 +445,7 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) query.add_authorative_answer(info2.dns_pointer()) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) _validate_complete_response(query, unicast_out) @@ -458,7 +458,7 @@ def _validate_complete_response(query, out): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None @@ -487,7 +487,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -498,7 +498,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -510,7 +510,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -522,7 +522,7 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -533,7 +533,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -544,7 +544,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -556,7 +556,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -567,7 +567,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -620,7 +620,7 @@ def test_multi_packet_known_answer_supression(): generated.add_answer_at_time(info3.dns_pointer(), now) packets = generated.packets() assert len(packets) > 1 - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -661,7 +661,7 @@ def test_known_answer_supression_service_type_enumeration_query(): question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -691,7 +691,7 @@ def test_known_answer_supression_service_type_enumeration_query(): now, ) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT ) assert unicast_out is None @@ -747,7 +747,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None @@ -767,7 +767,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out is None @@ -787,7 +787,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out.answers[0][0] == ptr_record @@ -813,7 +813,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): query.add_question(question) zc.cache.add(info2.dns_pointer()) # Add 100% TTL for info2 to the cache - unicast_out, multicast_out = zc.query_handler.response( + unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT ) assert multicast_out.answers[0][0] == info.dns_pointer() diff --git a/tests/test_services.py b/tests/test_services.py index 9b1f205e5..f49535e58 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1242,7 +1242,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), ) - zc.wait(100) + time.sleep(0.1) assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] assert zc.get_service_info(type_, registration_name).port == 80 @@ -1252,7 +1252,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: zc, mock_incoming_msg([info.dns_service()]), ) - zc.wait(100) + time.sleep(0.1) assert callbacks == [ ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 553b349de..240270a98 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -146,8 +146,8 @@ async def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" while not self.zc.done: now = current_time_millis() - self.zc.record_manager.updates(now, list(self.zc.cache.expire(now))) - self.zc.record_manager.updates_complete() + self.zc.record_manager.async_updates(now, list(self.zc.cache.expire(now))) + self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) async def _async_close(self) -> None: @@ -565,7 +565,7 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" - self.record_manager.updates_from_response(msg) + self.record_manager.async_updates_from_response(msg) def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: """Deal with incoming query packets. Provides a response if @@ -594,7 +594,7 @@ def _respond_query(self, msg: Optional[DNSIncoming], addr: str, port: int) -> No if msg: packets.append(msg) - unicast_out, multicast_out = self.query_handler.response(packets, addr, port) + unicast_out, multicast_out = self.query_handler.async_response(packets, addr, port) if unicast_out: self.async_send(unicast_out, addr, port) if multicast_out: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index c42641f46..b5279654f 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -239,10 +239,14 @@ def _answer_question( if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() - def response( # pylint: disable=unused-argument + def async_response( # pylint: disable=unused-argument self, msgs: List[DNSIncoming], addr: Optional[str], port: int ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: - """Deal with incoming query packets. Provides a response if possible.""" + """Deal with incoming query packets. Provides a response if possible. + + This function must be run in the event loop as it is not + threadsafe. + """ ucast_source = port != _MDNS_PORT known_answers = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) query_res = _QueryResponse(self.cache, msgs[0], ucast_source) @@ -272,28 +276,36 @@ def __init__(self, zeroconf: 'Zeroconf') -> None: self.cache = zeroconf.cache self.listeners: List[RecordUpdateListener] = [] - def updates(self, now: float, rec: List[DNSRecord]) -> None: + def async_updates(self, now: float, rec: List[DNSRecord]) -> None: """Used to notify listeners of new information that has updated a record. This method must be called before the cache is updated. + + This method will be run in the event loop. """ for listener in self.listeners: - listener.update_records(self.zc, now, rec) + listener.async_update_records(self.zc, now, rec) - def updates_complete(self) -> None: + def async_updates_complete(self) -> None: """Used to notify listeners of new information that has updated a record. This method must be called after the cache is updated. + + This method will be run in the event loop. """ for listener in self.listeners: - listener.update_records_complete() + listener.async_update_records_complete() self.zc.notify_all() - def updates_from_response(self, msg: DNSIncoming) -> None: + def async_updates_from_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" + are held in the cache, and listeners are notified. + + This function must be run in the event loop as it is not + threadsafe. + """ updates: List[DNSRecord] = [] address_adds: List[DNSAddress] = [] other_adds: List[DNSRecord] = [] @@ -334,7 +346,7 @@ def updates_from_response(self, msg: DNSIncoming) -> None: if not updates and not address_adds and not other_adds and not removes: return - self.updates(now, updates) + self.async_updates(now, updates) # The cache adds must be processed AFTER we trigger # the updates since we compare existing data # with the new data and updating the cache @@ -355,7 +367,7 @@ def updates_from_response(self, msg: DNSIncoming) -> None: # ServiceInfo could generate an un-needed query # because the data was not yet populated. self.cache.remove_records(removes) - self.updates_complete() + self.async_updates_complete() def add_listener( self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] @@ -374,8 +386,8 @@ def add_listener( if single_question.answered_by(record) and not record.is_expired(now): records.append(record) if records: - listener.update_records(self.zc, now, records) - listener.update_records_complete() + listener.async_update_records(self.zc, now, records) + listener.async_update_records_complete() self.zc.notify_all() diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index b2005d778..80fdd5f73 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -134,7 +134,7 @@ def update_record( # pylint: disable=no-self-use """ raise RuntimeError("update_record is deprecated and will be removed in a future version.") - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: """Update multiple records in one shot. All records that are received in a single packet are passed @@ -146,14 +146,18 @@ def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) - NotImplementedError in a future version. At this point the cache will not have the new records + + This method will be run in the event loop. """ for record in records: self.update_record(zc, now, record) - def update_records_complete(self) -> None: + def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. At this point the cache will have the new records. + + This method will be run in the event loop. """ @@ -353,20 +357,24 @@ def _process_record_update(self, now: float, record: DNSRecord) -> None: if type_: self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. Ensures that there is are no unecessary duplicates in the list. + + This method will be run in the event loop. """ for record in records: self._process_record_update(now, record) - def update_records_complete(self) -> None: + def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. At this point the cache will have the new records. + + This method will be run in the event loop. """ # Cannot use .update here since can fail with # RuntimeError: dictionary changed size during iteration @@ -677,26 +685,35 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) This method is deprecated and will be removed in a future version. update_records should be implemented instead. + + This method will be run in the event loop. """ if record is not None: - self.update_records(zc, now, [record]) + self._process_records_threadsafe(zc, now, [record]) - def update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Updates service information from a DNS record.""" + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Updates service information from a DNS record. + + This method will be run in the event loop. + """ + self._process_records_threadsafe(zc, now, records) + + def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Thread safe record updating.""" update_addresses = False for record in records: if isinstance(record, DNSService): update_addresses = True - self._process_record(record, now) + self._process_record_threadsafe(record, now) # Only update addresses if the DNSService (.server) has changed if not update_addresses: return for record in self._get_address_records_from_cache(zc): - self._process_record(record, now) + self._process_record_threadsafe(record, now) - def _process_record(self, record: DNSRecord, now: float) -> None: + def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: if record.is_expired(now): return @@ -783,7 +800,10 @@ def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: ] def load_from_cache(self, zc: 'Zeroconf') -> bool: - """Populate the service info from the cache.""" + """Populate the service info from the cache. + + This method is designed to be threadsafe. + """ now = current_time_millis() record_updates = [] cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) @@ -796,7 +816,7 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: record_updates.append(cached_txt_record) - self.update_records(zc, now, record_updates) + self._process_records_threadsafe(zc, now, record_updates) return self._is_complete @property From 9cc834d501fa5e582adeb4468b02775288e1fa11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 11:37:30 -1000 Subject: [PATCH 0423/1433] Update changelog (#727) --- README.rst | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 5e5c7ea7e..5e69f6966 100644 --- a/README.rst +++ b/README.rst @@ -154,25 +154,31 @@ Changelog Python version eariler then 3.6 were likely broken with zeroconf already, however the version is now explictly checked. -* BREAKING CHANGE: RecordUpdateListener now uses update_records instead of update_record (#419) @bdraco +* BREAKING CHANGE: RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco This allows the listener to receive all the records that have been updated in a single transaction such as a packet or cache expiry. - update_record has been deprecated in favor of update_records + update_record has been deprecated in favor of async_update_records A compatibility shim exists to ensure classes that use RecordUpdateListener as a base class continue to have update_record called, however they should be updated as soon as possible. - A new method update_records_complete is now called on each + A new method async_update_records_complete is now called on each listener when all listeners have completed processing updates and the cache has been updated. This allows ServiceBrowsers to delay calling handlers until they are sure the cache has been updated as its a common pattern to call for ServiceInfo when a ServiceBrowser handler fires. + The async_ prefix was choosen to make it clear that these + functions run in the eventloop and should never do blocking + I/O. Before 0.32+ these functions ran in a select() loop and + should not have been doing any blocking I/O, but it was not + clear to implementors that I/O would block the loop. + * BREAKING CHANGE: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco When manually creating a zeroconf.Engine object, it is no longer started automatically. @@ -231,6 +237,10 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Fix ServiceInfo with multiple A records (#725) @bdraco + +* Synchronize time for fate sharing (#718) @bdraco + * Cleanup typing in zero._core and document ignores (#714) @bdraco * Cleanup typing in zeroconf._logger (#715) @bdraco From ceb79bd7f7bdad434cbe5b4846492cd434ea883b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 12:00:43 -1000 Subject: [PATCH 0424/1433] Add tests for the DNSCache class (#728) - There is currently a bug in the implementation where an entry can exist in two places in the cache with different TTLs. Since a known answer cannot be both expired and expired at the same time, this is a bug that needs to be fixed. --- tests/test_cache.py | 102 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/test_cache.py b/tests/test_cache.py index 8580b3669..aa6acf6c2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -37,6 +37,38 @@ def test_order(self): cached_record = cache.get(entry) assert cached_record == record2 + def test_adding_same_record_to_cache_different_ttls(self): + """We should always get back the last entry we added if there are different TTLs. + + This ensures we only have one source of truth for TTLs as a record cannot + be both expired and not expired. + """ + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + entry = r.DNSEntry(record2) + cached_record = cache.get(entry) + assert cached_record == record2 + + @unittest.skip('This bug in the implementation needs to be fixed.') + def test_adding_same_record_to_cache_different_ttls(self): + """Verify we only get one record back. + + The last record added should replace the previous since two + records with different ttls are __eq__. This ensures we + only have one source of truth for TTLs as a record cannot + be both expired and not expired. + """ + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') + cache = r.DNSCache() + cache.add(record1) + cache.add(record2) + cached_records = cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN) + assert cached_records == [record2] + def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') @@ -61,3 +93,73 @@ def test_cache_empty_multiple_calls_does_not_throw(self): cache.remove(record1) cache.remove(record2) assert 'a' not in cache.cache + + +# These functions have been seen in other projects so +# we try to maintain a stable API for all the threadsafe getters +class TestDNSCacheAPI(unittest.TestCase): + def test_get(self): + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert cache.get(record1) == record1 + assert cache.get(record2) == record2 + + def test_get_by_details(self): + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert cache.get_by_details('a', const._TYPE_A, const._CLASS_IN) == record2 + + def test_get_all_by_details(self): + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert set(cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == set([record1, record2]) + + def test_entries_with_server(self): + record1 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + ) + record2 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + ) + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert set(cache.entries_with_server('ab')) == set([record1, record2]) + + def test_entries_with_name(self): + record1 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + ) + record2 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + ) + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert set(cache.entries_with_name('irrelevant')) == set([record1, record2]) + + def test_current_entry_with_name_and_alias(self): + record1 = r.DNSPointer( + 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'x.irrelevant' + ) + record2 = r.DNSPointer( + 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'y.irrelevant' + ) + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert cache.current_entry_with_name_and_alias('irrelevant', 'x.irrelevant') == record1 + + def test_entries_with_name(self): + record1 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + ) + record2 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + ) + cache = r.DNSCache() + cache.add_records([record1, record2]) + assert cache.names() == ['irrelevant'] From 88aa610274bf79aef6c74998f2bfca8c8de0dccb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 12:14:48 -1000 Subject: [PATCH 0425/1433] Fix cache handling of records with different TTLs (#729) - There should only be one unique record in the cache at a time as having multiple unique records will different TTLs in the cache can result in unexpected behavior since some functions returned all matching records and some fetched from the right side of the list to return the newest record. Intead we now store the records in a dict to ensure that the newest record always replaces the same unique record and we never have a source of truth problem determining the TTL of a record from the cache. --- tests/test_cache.py | 27 ++++-------- zeroconf/_cache.py | 103 ++++++++++++++++++++++++++++++-------------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index aa6acf6c2..19033b5c9 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -31,8 +31,7 @@ def test_order(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add(record1) - cache.add(record2) + cache.add_records([record1, record2]) entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) cached_record = cache.get(entry) assert cached_record == record2 @@ -46,13 +45,11 @@ def test_adding_same_record_to_cache_different_ttls(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') cache = r.DNSCache() - cache.add(record1) - cache.add(record2) + cache.add_records([record1, record2]) entry = r.DNSEntry(record2) cached_record = cache.get(entry) assert cached_record == record2 - @unittest.skip('This bug in the implementation needs to be fixed.') def test_adding_same_record_to_cache_different_ttls(self): """Verify we only get one record back. @@ -64,8 +61,7 @@ def test_adding_same_record_to_cache_different_ttls(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') cache = r.DNSCache() - cache.add(record1) - cache.add(record2) + cache.add_records([record1, record2]) cached_records = cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN) assert cached_records == [record2] @@ -73,25 +69,18 @@ def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add(record1) - cache.add(record2) + cache.add_records([record1, record2]) assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) + cache.remove_records([record1, record2]) assert 'a' not in cache.cache - def test_cache_empty_multiple_calls_does_not_throw(self): + def test_cache_empty_multiple_calls(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add(record1) - cache.add(record2) + cache.add_records([record1, record2]) assert 'a' in cache.cache - cache.remove(record1) - cache.remove(record2) - # Ensure multiple removes does not throw - cache.remove(record1) - cache.remove(record2) + cache.remove_records([record1, record2]) assert 'a' not in cache.cache diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index 135b1884e..2e07a7a47 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -27,46 +27,83 @@ from .const import _TYPE_PTR +_DNSRecordCacheType = Dict[str, Dict[DNSRecord, DNSRecord]] + + +def _remove_key(cache: _DNSRecordCacheType, key: str, entry: DNSRecord) -> None: + """Remove a key from a DNSRecord cache + + This function must be run in from event loop. + """ + del cache[key][entry] + if not cache[key]: + del cache[key] + + class DNSCache: """A cache of DNS entries.""" def __init__(self) -> None: - self.cache: Dict[str, List[DNSRecord]] = {} - self.service_cache: Dict[str, List[DNSRecord]] = {} + self.cache: _DNSRecordCacheType = {} + self.service_cache: _DNSRecordCacheType = {} + + # Functions prefixed with are NOT threadsafe and must + # be run in the event loop. def add(self, entry: DNSRecord) -> None: - """Adds an entry""" - # Insert last in list, get will return newest entry - # iteration will result in last update winning - self.cache.setdefault(entry.key, []).append(entry) + """Adds an entry. + + This function must be run in from event loop. + """ + # Previously storage of records was implemented as a list + # instead a dict. Since DNSRecords are now hashable, the implementation + # uses a dict to ensure that adding a new record to the cache + # replaces any existing records that are __eq__ to each other which + # removes the risk that accessing the cache from the wrong + # direction would return the old incorrect entry. + self.cache.setdefault(entry.key, {})[entry] = entry if isinstance(entry, DNSService): - self.service_cache.setdefault(entry.server, []).append(entry) + self.service_cache.setdefault(entry.server, {})[entry] = entry def add_records(self, entries: Iterable[DNSRecord]) -> None: - """Add multiple records.""" + """Add multiple records. + + This function must be run in from event loop. + """ for entry in entries: self.add(entry) def remove(self, entry: DNSRecord) -> None: - """Removes an entry.""" + """Removes an entry. + + This function must be run in from event loop. + """ if isinstance(entry, DNSService): - DNSCache.remove_key(self.service_cache, entry.server, entry) - DNSCache.remove_key(self.cache, entry.key, entry) + _remove_key(self.service_cache, entry.server, entry) + _remove_key(self.cache, entry.key, entry) def remove_records(self, entries: Iterable[DNSRecord]) -> None: - """Remove multiple records.""" + """Remove multiple records. + + This function must be run in from event loop. + """ for entry in entries: self.remove(entry) - @staticmethod - def remove_key(cache: dict, key: str, entry: DNSRecord) -> None: - """Forgiving remove of a cache key.""" - try: - cache[key].remove(entry) - if not cache[key]: - del cache[key] - except (KeyError, ValueError): - pass + def expire(self, now: float) -> Iterable[DNSRecord]: + """Purge expired entries from the cache. + + This function must be run in from event loop. + """ + for name in self.names(): + for record in self.entries_with_name(name): + if record.is_expired(now): + self.remove(record) + yield record + + # The below functions are threadsafe and do not need to be run in the + # event loop, however they all make copies so they significantly + # inefficent def get(self, entry: DNSEntry) -> Optional[DNSRecord]: """Gets an entry by key. Will return None if there is no @@ -77,7 +114,17 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: return None def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: - """Gets the first matching entry by details. Returns None if no entries match.""" + """Gets the first matching entry by details. Returns None if no entries match. + + Calling this function is not recommended as it will only + return one record even if there are multiple entries. + + For example if there are multiple A or AAAA addresses this + function will return the last one that was added to the cache + which may not be the one you expect. + + Use get_all_by_details instead. + """ return self.get(DNSEntry(name, type_, class_)) def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: @@ -87,11 +134,11 @@ def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSReco def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" - return self.service_cache.get(server, [])[:] + return list(self.service_cache.get(server, {})) def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" - return self.cache.get(name.lower(), [])[:] + return list(self.cache.get(name.lower(), {})) def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() @@ -107,11 +154,3 @@ def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[D def names(self) -> List[str]: """Return a copy of the list of current cache names.""" return list(self.cache) - - def expire(self, now: float) -> Iterable[DNSRecord]: - """Purge expired entries from the cache.""" - for name in self.names(): - for record in self.entries_with_name(name): - if record.is_expired(now): - self.remove(record) - yield record From 3503e7614fc31bbfe2c919f13689468cc73179fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 12:23:26 -1000 Subject: [PATCH 0426/1433] Prefix cache functions that are non threadsafe with async_ (#724) --- tests/__init__.py | 4 +--- tests/test_cache.py | 28 ++++++++++++++-------------- tests/test_core.py | 22 ++++++++++++---------- tests/test_handlers.py | 25 +++++++++++++++---------- tests/test_services.py | 3 +-- zeroconf/_cache.py | 18 +++++++++--------- zeroconf/_core.py | 2 +- zeroconf/_handlers.py | 4 ++-- 8 files changed, 55 insertions(+), 51 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 3439a0446..86d7e1994 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -64,6 +64,4 @@ def has_working_ipv6(): def _clear_cache(zc): - for name in zc.cache.names(): - for record in zc.cache.entries_with_name(name): - zc.cache.remove(record) + zc.cache.cache.clear() diff --git a/tests/test_cache.py b/tests/test_cache.py index 19033b5c9..98da9dbbe 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -31,7 +31,7 @@ def test_order(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) cached_record = cache.get(entry) assert cached_record == record2 @@ -45,7 +45,7 @@ def test_adding_same_record_to_cache_different_ttls(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) entry = r.DNSEntry(record2) cached_record = cache.get(entry) assert cached_record == record2 @@ -61,7 +61,7 @@ def test_adding_same_record_to_cache_different_ttls(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) cached_records = cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN) assert cached_records == [record2] @@ -69,18 +69,18 @@ def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert 'a' in cache.cache - cache.remove_records([record1, record2]) + cache.async_remove_records([record1, record2]) assert 'a' not in cache.cache def test_cache_empty_multiple_calls(self): record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert 'a' in cache.cache - cache.remove_records([record1, record2]) + cache.async_remove_records([record1, record2]) assert 'a' not in cache.cache @@ -91,7 +91,7 @@ def test_get(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert cache.get(record1) == record1 assert cache.get(record2) == record2 @@ -99,14 +99,14 @@ def test_get_by_details(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert cache.get_by_details('a', const._TYPE_A, const._CLASS_IN) == record2 def test_get_all_by_details(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert set(cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == set([record1, record2]) def test_entries_with_server(self): @@ -117,7 +117,7 @@ def test_entries_with_server(self): 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' ) cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert set(cache.entries_with_server('ab')) == set([record1, record2]) def test_entries_with_name(self): @@ -128,7 +128,7 @@ def test_entries_with_name(self): 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' ) cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert set(cache.entries_with_name('irrelevant')) == set([record1, record2]) def test_current_entry_with_name_and_alias(self): @@ -139,7 +139,7 @@ def test_current_entry_with_name_and_alias(self): 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'y.irrelevant' ) cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert cache.current_entry_with_name_and_alias('irrelevant', 'x.irrelevant') == record1 def test_entries_with_name(self): @@ -150,5 +150,5 @@ def test_entries_with_name(self): 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' ) cache = r.DNSCache() - cache.add_records([record1, record2]) + cache.async_add_records([record1, record2]) assert cache.names() == ['irrelevant'] diff --git a/tests/test_core.py b/tests/test_core.py index f8577a6bf..819bbe68d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -18,6 +18,7 @@ import zeroconf as r from zeroconf import _core, const, ServiceBrowser, Zeroconf, current_time_millis +from zeroconf.aio import AsyncZeroconf from . import has_working_ipv6, _clear_cache, _inject_response @@ -36,22 +37,23 @@ def teardown_module(): log.setLevel(original_logging_level) -class TestReaper(unittest.TestCase): - @unittest.mock.patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10) - def test_reaper(self): - zeroconf = _core.Zeroconf(interfaces=['127.0.0.1']) +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_reaper(): + with unittest.mock.patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): + assert _core._CACHE_CLEANUP_INTERVAL == 10 + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf = aiozc.zeroconf cache = zeroconf.cache original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - zeroconf.cache.add(record_with_10s_ttl) - zeroconf.cache.add(record_with_1s_ttl) + zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - time.sleep(1) - zeroconf.notify_all() - time.sleep(0.1) + await asyncio.sleep(1.2) entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) - zeroconf.close() + await aiozc.async_close() assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 0645a24b9..f9e7639ea 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -14,6 +14,7 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const +from zeroconf.aio import AsyncZeroconf from . import _clear_cache, _inject_response @@ -703,10 +704,14 @@ def test_known_answer_supression_service_type_enumeration_query(): zc.close() -def test_qu_response_only_sends_additionals_if_sends_answer(): +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_qu_response_only_sends_additionals_if_sends_answer(): """Test that a QU response does not send additionals unless it sends the answer as well.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf type_ = "_addtest1._tcp.local." name = "knownname" @@ -731,13 +736,13 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): ptr_record = info.dns_pointer() # Add the PTR record to the cache - zc.cache.add(ptr_record) + zc.cache.async_add_records([ptr_record]) # Add the A record to the cache with 50% ttl remaining a_record = info.dns_addresses()[0] a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) assert not a_record.is_recent(current_time_millis()) - zc.cache.add(a_record) + zc.cache.async_add_records([a_record]) # With QU should respond to only unicast when the answer has been recently multicast # even if the additional has not been recently multicast @@ -755,10 +760,10 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): assert unicast_out.answers[0][0] == ptr_record # Remove the 50% A record and add a 100% A record - zc.cache.remove(a_record) + zc.cache.async_remove_records([a_record]) a_record = info.dns_addresses()[0] assert a_record.is_recent(current_time_millis()) - zc.cache.add(a_record) + zc.cache.async_add_records([a_record]) # With QU should respond to only unicast when the answer has been recently multicast # even if the additional has not been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -775,10 +780,10 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): assert unicast_out.answers[0][0] == ptr_record # Remove the 100% PTR record and add a 50% PTR record - zc.cache.remove(ptr_record) + zc.cache.async_remove_records([ptr_record]) ptr_record.set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) assert not ptr_record.is_recent(current_time_millis()) - zc.cache.add(ptr_record) + zc.cache.async_add_records([ptr_record]) # With QU should respond to only multicast since the has less # than 75% of its ttl remaining query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -811,7 +816,7 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - zc.cache.add(info2.dns_pointer()) # Add 100% TTL for info2 to the cache + zc.cache.async_add_records([info2.dns_pointer()]) # Add 100% TTL for info2 to the cache unicast_out, multicast_out = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT @@ -828,4 +833,4 @@ def test_qu_response_only_sends_additionals_if_sends_answer(): # unregister zc.registry.remove(info) - zc.close() + await aiozc.async_close() diff --git a/tests/test_services.py b/tests/test_services.py index f49535e58..305c3d968 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -960,8 +960,7 @@ async def test_multiple_a_addresses(): host = "multahost.local." record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'a') record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'b') - cache.add(record1) - cache.add(record2) + cache.async_add_records([record1, record2]) # New kwarg way info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index 2e07a7a47..e3dd45937 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -47,10 +47,10 @@ def __init__(self) -> None: self.cache: _DNSRecordCacheType = {} self.service_cache: _DNSRecordCacheType = {} - # Functions prefixed with are NOT threadsafe and must + # Functions prefixed with async_ are NOT threadsafe and must # be run in the event loop. - def add(self, entry: DNSRecord) -> None: + def _async_add(self, entry: DNSRecord) -> None: """Adds an entry. This function must be run in from event loop. @@ -65,15 +65,15 @@ def add(self, entry: DNSRecord) -> None: if isinstance(entry, DNSService): self.service_cache.setdefault(entry.server, {})[entry] = entry - def add_records(self, entries: Iterable[DNSRecord]) -> None: + def async_add_records(self, entries: Iterable[DNSRecord]) -> None: """Add multiple records. This function must be run in from event loop. """ for entry in entries: - self.add(entry) + self._async_add(entry) - def remove(self, entry: DNSRecord) -> None: + def _async_remove(self, entry: DNSRecord) -> None: """Removes an entry. This function must be run in from event loop. @@ -82,15 +82,15 @@ def remove(self, entry: DNSRecord) -> None: _remove_key(self.service_cache, entry.server, entry) _remove_key(self.cache, entry.key, entry) - def remove_records(self, entries: Iterable[DNSRecord]) -> None: + def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: """Remove multiple records. This function must be run in from event loop. """ for entry in entries: - self.remove(entry) + self._async_remove(entry) - def expire(self, now: float) -> Iterable[DNSRecord]: + def async_expire(self, now: float) -> Iterable[DNSRecord]: """Purge expired entries from the cache. This function must be run in from event loop. @@ -98,7 +98,7 @@ def expire(self, now: float) -> Iterable[DNSRecord]: for name in self.names(): for record in self.entries_with_name(name): if record.is_expired(now): - self.remove(record) + self._async_remove(record) yield record # The below functions are threadsafe and do not need to be run in the diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 240270a98..e5c92ce3f 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -146,7 +146,7 @@ async def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" while not self.zc.done: now = current_time_millis() - self.zc.record_manager.async_updates(now, list(self.zc.cache.expire(now))) + self.zc.record_manager.async_updates(now, list(self.zc.cache.async_expire(now))) self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index b5279654f..dd1a9ca05 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -362,11 +362,11 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # zc.get_service_info will see the cached value # but ONLY after all the record updates have been # processsed. - self.cache.add_records(itertools.chain(address_adds, other_adds)) + self.cache.async_add_records(itertools.chain(address_adds, other_adds)) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. - self.cache.remove_records(removes) + self.cache.async_remove_records(removes) self.async_updates_complete() def add_listener( From 733f79d28c7dd4500a1598b279ee638ead8bdd55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 12:23:35 -1000 Subject: [PATCH 0427/1433] Update changelog (#730) --- README.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.rst b/README.rst index 5e69f6966..3e3d0e645 100644 --- a/README.rst +++ b/README.rst @@ -143,6 +143,10 @@ Changelog 0.32.0 (Unreleased) =================== +Documentation for breaking changes era on the side of the caution and likely +overstates the risk on many of these. If you are not accessing zeroconf internals, +you can likely not be concerned with the breaking changes below: + * BREAKING CHANGE: zeroconf.asyncio has been renamed zeroconf.aio (#503) @bdraco The asyncio name could shadow system asyncio in some cases. If @@ -201,6 +205,19 @@ Changelog These functions are not intended to be used by external callers and the API is not likely to be stable in the future +* BREAKING CHANGE: Prefix cache functions that are non threadsafe with async_ (#724) @bdraco + + Adding (`zc.cache.add` -> `zc.cache.async_add_records`), removing (`zc.cache.remove` -> + `zc.cache.async_remove_records`), and expiring the cache (`zc.cache.expire` -> + `zc.cache.async_expire`) the cache is not threadsafe and must be called from the + event loop (previously the Engine select loop before 0.32) + + These functions should only be run from the event loop as they are NOT thread safe. + + We never expect these functions will be called externally, however it was possible so this + is documented as a breaking change. It is highly recommended that external callers do not + modify the cache directly. + * TRAFFIC REDUCTION: Add support for handling QU questions (#621) @bdraco Implements RFC 6762 sec 5.4: @@ -237,6 +254,10 @@ Changelog * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Fix cache handling of records with different TTLs (#729) @bdraco + +* Rename handlers and internals to make it clear what is threadsafe (#726) @bdraco + * Fix ServiceInfo with multiple A records (#725) @bdraco * Synchronize time for fate sharing (#718) @bdraco From 3ee9b650bedbe61d59838897f653ad43a6d51910 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 12:45:56 -1000 Subject: [PATCH 0428/1433] Fix server cache to be case-insensitive (#731) --- tests/test_cache.py | 4 +++- tests/test_services.py | 2 ++ zeroconf/_cache.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index 98da9dbbe..7c75866bc 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -119,6 +119,7 @@ def test_entries_with_server(self): cache = r.DNSCache() cache.async_add_records([record1, record2]) assert set(cache.entries_with_server('ab')) == set([record1, record2]) + assert set(cache.entries_with_server('AB')) == set([record1, record2]) def test_entries_with_name(self): record1 = r.DNSService( @@ -130,6 +131,7 @@ def test_entries_with_name(self): cache = r.DNSCache() cache.async_add_records([record1, record2]) assert set(cache.entries_with_name('irrelevant')) == set([record1, record2]) + assert set(cache.entries_with_name('Irrelevant')) == set([record1, record2]) def test_current_entry_with_name_and_alias(self): record1 = r.DNSPointer( @@ -142,7 +144,7 @@ def test_current_entry_with_name_and_alias(self): cache.async_add_records([record1, record2]) assert cache.current_entry_with_name_and_alias('irrelevant', 'x.irrelevant') == record1 - def test_entries_with_name(self): + def test_name(self): record1 = r.DNSService( 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' ) diff --git a/tests/test_services.py b/tests/test_services.py index 305c3d968..5d72a1aa6 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -857,6 +857,8 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service A updated service_updated_event.clear() service_address = '10.0.1.3' + # Verify we match on uppercase + service_server = service_server.upper() _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index e3dd45937..12e4aa649 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -134,11 +134,11 @@ def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSReco def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" - return list(self.service_cache.get(server, {})) + return list(self.service_cache.get(server.lower(), [])) def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" - return list(self.cache.get(name.lower(), {})) + return list(self.cache.get(name.lower(), [])) def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() From 50af94493ff6bf5d21445eaa80d3a96f348b0d11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 17:09:05 -1000 Subject: [PATCH 0429/1433] Add test coverage to ensure the cache flush bit is properly handled (#734) --- tests/test_handlers.py | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index f9e7639ea..92d95fa2f 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -4,6 +4,7 @@ """ Unit tests for zeroconf._handlers """ +import asyncio import logging import pytest import socket @@ -834,3 +835,83 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # unregister zc.registry.remove(info) await aiozc.async_close() + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_cache_flush_bit(): + """Test that the cache flush bit sets the TTL to one for matching records.""" + # instantiate a zeroconf instance + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + + type_ = "_cacheflush._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "server-uu1.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + a_record = info.dns_addresses()[0] + zc.cache.async_add_records([info.dns_pointer(), a_record, info.dns_text(), info.dns_service()]) + + info.addresses = [socket.inet_aton("10.0.1.5"), socket.inet_aton("10.0.1.6")] + new_records = info.dns_addresses() + for new_record in new_records: + assert new_record.unique is True + + original_a_record = zc.cache.get(a_record) + # Do the run within 1s to verify the original record is not going to be expired + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA, multicast=True) + for answer in new_records: + out.add_answer_at_time(answer, 0) + for packet in out.packets(): + zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) + assert zc.cache.get(a_record) is original_a_record + assert original_a_record.ttl != 1 + for record in new_records: + assert zc.cache.get(record) is not None + + original_a_record.created = current_time_millis() - 1001 + + # Do the run within 1s to verify the original record is not going to be expired + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA, multicast=True) + for answer in new_records: + out.add_answer_at_time(answer, 0) + for packet in out.packets(): + zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) + assert original_a_record.ttl == 1 + for record in new_records: + assert zc.cache.get(record) is not None + + cached_records = [zc.cache.get(record) for record in new_records] + for record in cached_records: + record.created = current_time_millis() - 1001 + + fresh_address = socket.inet_aton("4.4.4.4") + info.addresses = [fresh_address] + # Do the run within 1s to verify the two new records get marked as expired + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA, multicast=True) + for answer in info.dns_addresses(): + out.add_answer_at_time(answer, 0) + for packet in out.packets(): + zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) + for record in cached_records: + assert record.ttl == 1 + + for entry in zc.cache.get_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): + if entry.address == fresh_address: + assert entry.ttl > 1 + else: + assert entry.ttl == 1 + + # Wait for the ttl 1 records to expire + await asyncio.sleep(1.01) + + loaded_info = r.ServiceInfo(type_, registration_name) + loaded_info.load_from_cache(zc) + assert loaded_info.addresses == info.addresses + + await aiozc.async_close() From c035925f47732a889c76a2ff0989b92c6687c950 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 17:26:37 -1000 Subject: [PATCH 0430/1433] Switch to using DNSRRSet in RecordManager (#735) --- zeroconf/_dns.py | 7 ++++++ zeroconf/_handlers.py | 56 ++++++++++++++++++++++++------------------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 5b7fe70fe..66892d52f 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -423,3 +423,10 @@ def suppresses(self, record: DNSRecord) -> bool: self._lookup = {record: record for record in self._records} other = self._lookup.get(record) return bool(other and other.ttl > (record.ttl / 2)) + + def __contains__(self, record: DNSRecord) -> bool: + """Returns true if the rrset contains the record.""" + if self._lookup is None: + # Build the hash table so we can lookup the record independent of the ttl + self._lookup = {record: record for record in self._records} + return record in self._lookup diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index dd1a9ca05..03495b4e5 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -311,25 +311,14 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: other_adds: List[DNSRecord] = [] removes: List[DNSRecord] = [] now = msg.now - for record in msg.answers: - - updated = True + unique_types: Set[Tuple[str, int, int]] = set() + for record in msg.answers: if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - # rfc6762#section-10.2 para 2 - # Since unique is set, all old records with that name, rrtype, - # and rrclass that were received more than one second ago are declared - # invalid, and marked to expire from the cache in one second. - for entry in self.cache.get_all_by_details(record.name, record.type, record.class_): - if entry == record: - updated = False - if record.created - entry.created > 1000 and entry not in msg.answers: - # Expire in 1s - entry.set_created_ttl(now, 1) - - expired = record.is_expired(now) + unique_types.add((record.name, record.type, record.class_)) + maybe_entry = self.cache.get(record) - if not expired: + if not record.is_expired(now): if maybe_entry is not None: maybe_entry.reset_ttl(record) else: @@ -337,16 +326,18 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: address_adds.append(record) else: other_adds.append(record) - if updated: - updates.append(record) + updates.append(record) + # This is likely a goodbye since the record is + # expired and exists in the cache elif maybe_entry is not None: updates.append(record) removes.append(record) - if not updates and not address_adds and not other_adds and not removes: - return + if unique_types: + self._async_mark_unique_cached_records_older_than_1s_to_expire(unique_types, msg.answers, now) - self.async_updates(now, updates) + if updates: + self.async_updates(now, updates) # The cache adds must be processed AFTER we trigger # the updates since we compare existing data # with the new data and updating the cache @@ -362,12 +353,29 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # zc.get_service_info will see the cached value # but ONLY after all the record updates have been # processsed. - self.cache.async_add_records(itertools.chain(address_adds, other_adds)) + if other_adds or address_adds: + self.cache.async_add_records(itertools.chain(address_adds, other_adds)) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. - self.cache.async_remove_records(removes) - self.async_updates_complete() + if removes: + self.cache.async_remove_records(removes) + if updates: + self.async_updates_complete() + + def _async_mark_unique_cached_records_older_than_1s_to_expire( + self, unique_types: Set[Tuple[str, int, int]], answers: List[DNSRecord], now: float + ) -> None: + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + answers_rrset = DNSRRSet(answers) + for name, type_, class_ in unique_types: + for entry in self.cache.get_all_by_details(name, type_, class_): + if (now - entry.created > 1000) and entry not in answers_rrset: + # Expire in 1s + entry.set_created_ttl(now, 1) def add_listener( self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] From 9d31245f9ed4f6b1f7d9d7c51daf0ca394fd208f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 17:44:05 -1000 Subject: [PATCH 0431/1433] Add fast cache lookup functions (#732) --- tests/test_cache.py | 47 +++++++++++++++++++- tests/test_handlers.py | 12 ++--- zeroconf/_cache.py | 81 ++++++++++++++++++++++++++++------ zeroconf/_core.py | 2 +- zeroconf/_dns.py | 11 +++-- zeroconf/_handlers.py | 12 ++--- zeroconf/_services/__init__.py | 9 ++-- 7 files changed, 136 insertions(+), 38 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index 7c75866bc..4b3a8a18e 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -84,16 +84,61 @@ def test_cache_empty_multiple_calls(self): assert 'a' not in cache.cache +class TestDNSAsyncCacheAPI(unittest.TestCase): + def test_async_get_unique(self): + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.async_add_records([record1, record2]) + assert cache.async_get_unique(record1) == record1 + assert cache.async_get_unique(record2) == record2 + + def test_async_all_by_details(self): + record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') + record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + cache = r.DNSCache() + cache.async_add_records([record1, record2]) + assert set(cache.async_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == set([record1, record2]) + + def test_async_entries_with_server(self): + record1 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + ) + record2 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + ) + cache = r.DNSCache() + cache.async_add_records([record1, record2]) + assert set(cache.async_entries_with_server('ab')) == set([record1, record2]) + assert set(cache.async_entries_with_server('AB')) == set([record1, record2]) + + def test_async_entries_with_name(self): + record1 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + ) + record2 = r.DNSService( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + ) + cache = r.DNSCache() + cache.async_add_records([record1, record2]) + assert set(cache.async_entries_with_name('irrelevant')) == set([record1, record2]) + assert set(cache.async_entries_with_name('Irrelevant')) == set([record1, record2]) + + # These functions have been seen in other projects so # we try to maintain a stable API for all the threadsafe getters class TestDNSCacheAPI(unittest.TestCase): def test_get(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + record3 = r.DNSAddress('a', const._TYPE_AAAA, const._CLASS_IN, 1, b'ipv6') cache = r.DNSCache() - cache.async_add_records([record1, record2]) + cache.async_add_records([record1, record2, record3]) assert cache.get(record1) == record1 assert cache.get(record2) == record2 + assert cache.get(r.DNSEntry('a', const._TYPE_A, const._CLASS_IN)) == record2 + assert cache.get(r.DNSEntry('a', const._TYPE_AAAA, const._CLASS_IN)) == record3 + assert cache.get(r.DNSEntry('notthere', const._TYPE_A, const._CLASS_IN)) is None def test_get_by_details(self): record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 92d95fa2f..ddd8ffa47 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -862,17 +862,17 @@ async def test_cache_flush_bit(): for new_record in new_records: assert new_record.unique is True - original_a_record = zc.cache.get(a_record) + original_a_record = zc.cache.async_get_unique(a_record) # Do the run within 1s to verify the original record is not going to be expired out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA, multicast=True) for answer in new_records: out.add_answer_at_time(answer, 0) for packet in out.packets(): zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) - assert zc.cache.get(a_record) is original_a_record + assert zc.cache.async_get_unique(a_record) is original_a_record assert original_a_record.ttl != 1 for record in new_records: - assert zc.cache.get(record) is not None + assert zc.cache.async_get_unique(record) is not None original_a_record.created = current_time_millis() - 1001 @@ -884,9 +884,9 @@ async def test_cache_flush_bit(): zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) assert original_a_record.ttl == 1 for record in new_records: - assert zc.cache.get(record) is not None + assert zc.cache.async_get_unique(record) is not None - cached_records = [zc.cache.get(record) for record in new_records] + cached_records = [zc.cache.async_get_unique(record) for record in new_records] for record in cached_records: record.created = current_time_millis() - 1001 @@ -901,7 +901,7 @@ async def test_cache_flush_bit(): for record in cached_records: assert record.ttl == 1 - for entry in zc.cache.get_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): + for entry in zc.cache.async_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): if entry.address == fresh_address: assert entry.ttl > 1 else: diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index 12e4aa649..24b6a2337 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -20,13 +20,24 @@ USA """ -from typing import Dict, Iterable, List, Optional, cast - -from ._dns import DNSEntry, DNSPointer, DNSRecord, DNSService +import itertools +from typing import Dict, Iterable, Iterator, List, Optional, Union, cast + +from ._dns import ( + DNSAddress, + DNSEntry, + DNSHinfo, + DNSPointer, + DNSRecord, + DNSService, + DNSText, + dns_entry_matches, +) from ._utils.time import current_time_millis from .const import _TYPE_PTR - +_UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) +_UniqueRecordsType = Union[DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService] _DNSRecordCacheType = Dict[str, Dict[DNSRecord, DNSRecord]] @@ -90,16 +101,50 @@ def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: for entry in entries: self._async_remove(entry) - def async_expire(self, now: float) -> Iterable[DNSRecord]: + def async_expire(self, now: float) -> List[DNSRecord]: """Purge expired entries from the cache. This function must be run in from event loop. """ - for name in self.names(): - for record in self.entries_with_name(name): - if record.is_expired(now): - self._async_remove(record) - yield record + expired = [record for record in itertools.chain(*self.cache.values()) if record.is_expired(now)] + self.async_remove_records(expired) + return expired + + def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: + """Gets a unique entry by key. Will return None if there is no + matching entry. + + This function is not threadsafe and must be called from + the event loop. + """ + return self.cache.get(entry.key, {}).get(entry) + + def async_all_by_details(self, name: str, type_: int, class_: int) -> Iterator[DNSRecord]: + """Gets all matching entries by details. + + This function is not threadsafe and must be called from + the event loop. + """ + key = name.lower() + for entry in self.cache.get(key, []): + if dns_entry_matches(entry, key, type_, class_): + yield entry + + def async_entries_with_name(self, name: str) -> Dict[DNSRecord, DNSRecord]: + """Returns a dict of entries whose key matches the name. + + This function is not threadsafe and must be called from + the event loop. + """ + return self.cache.get(name.lower(), {}) + + def async_entries_with_server(self, name: str) -> Dict[DNSRecord, DNSRecord]: + """Returns a dict of entries whose key matches the server. + + This function is not threadsafe and must be called from + the event loop. + """ + return self.service_cache.get(name.lower(), {}) # The below functions are threadsafe and do not need to be run in the # event loop, however they all make copies so they significantly @@ -108,7 +153,9 @@ def async_expire(self, now: float) -> Iterable[DNSRecord]: def get(self, entry: DNSEntry) -> Optional[DNSRecord]: """Gets an entry by key. Will return None if there is no matching entry.""" - for cached_entry in reversed(self.entries_with_name(entry.key)): + if isinstance(entry, _UNIQUE_RECORD_TYPES): + return self.cache.get(entry.key, {}).get(entry) + for cached_entry in reversed(list(self.cache.get(entry.key, []))): if entry.__eq__(cached_entry): return cached_entry return None @@ -125,12 +172,18 @@ def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSReco Use get_all_by_details instead. """ - return self.get(DNSEntry(name, type_, class_)) + key = name.lower() + for cached_entry in reversed(list(self.cache.get(key, []))): + if dns_entry_matches(cached_entry, key, type_, class_): + return cached_entry + return None def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: """Gets all matching entries by details.""" - match_entry = DNSEntry(name, type_, class_) - return [entry for entry in self.entries_with_name(name) if match_entry.__eq__(entry)] + key = name.lower() + return [ + entry for entry in list(self.cache.get(key, [])) if dns_entry_matches(entry, key, type_, class_) + ] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" diff --git a/zeroconf/_core.py b/zeroconf/_core.py index e5c92ce3f..a7910591a 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -146,7 +146,7 @@ async def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" while not self.zc.done: now = current_time_millis() - self.zc.record_manager.async_updates(now, list(self.zc.cache.async_expire(now))) + self.zc.record_manager.async_updates(now, self.zc.cache.async_expire(now)) self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 66892d52f..e656bc519 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -49,6 +49,10 @@ from ._protocol import DNSIncoming, DNSOutgoing # pylint: disable=cyclic-import +def dns_entry_matches(record: 'DNSEntry', key: str, type_: int, class_: int) -> bool: + return key == record.key and type_ == record.type and class_ == record.class_ + + class DNSEntry: """A DNS entry""" @@ -66,12 +70,7 @@ def _entry_tuple(self) -> Tuple[str, int, int]: def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" - return ( - self.key == other.key - and self.type == other.type - and self.class_ == other.class_ - and isinstance(other, DNSEntry) - ) + return dns_entry_matches(other, self.key, self.type, self.class_) and isinstance(other, DNSEntry) @staticmethod def get_class_(class_: int) -> str: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 03495b4e5..66b8862f2 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -21,9 +21,9 @@ """ import itertools -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast -from ._cache import DNSCache +from ._cache import DNSCache, _UniqueRecordsType from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord from ._logger import log from ._protocol import DNSIncoming, DNSOutgoing @@ -141,7 +141,7 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: SHOULD instead multicast the response so as to keep all the peer caches up to date """ - maybe_entry = self._cache.get(record) + maybe_entry = self._cache.async_get_unique(cast(_UniqueRecordsType, record)) return bool(maybe_entry and maybe_entry.is_recent(self._now)) def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: @@ -149,7 +149,7 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: Protect the network against excessive packet flooding https://datatracker.ietf.org/doc/html/rfc6762#section-14 """ - maybe_entry = self._cache.get(record) + maybe_entry = self._cache.async_get_unique(cast(_UniqueRecordsType, record)) return bool(maybe_entry and self._now - maybe_entry.created < 1000) @@ -317,7 +317,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record.type, record.class_)) - maybe_entry = self.cache.get(record) + maybe_entry = self.cache.async_get_unique(cast(_UniqueRecordsType, record)) if not record.is_expired(now): if maybe_entry is not None: maybe_entry.reset_ttl(record) @@ -372,7 +372,7 @@ def _async_mark_unique_cached_records_older_than_1s_to_expire( # invalid, and marked to expire from the cache in one second. answers_rrset = DNSRRSet(answers) for name, type_, class_ in unique_types: - for entry in self.cache.get_all_by_details(name, type_, class_): + for entry in self.cache.async_all_by_details(name, type_, class_): if (now - entry.created > 1000) and entry not in answers_rrset: # Expire in 1s entry.set_created_ttl(now, 1) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 80fdd5f73..306b69990 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -27,6 +27,7 @@ from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from .._cache import _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing @@ -316,7 +317,7 @@ def _enqueue_callback( ): self._pending_handlers[key] = state_change - def _process_record_update(self, now: float, record: DNSRecord) -> None: + def _async_process_record_update(self, now: float, record: DNSRecord) -> None: """Process a single record update from a batch of updates.""" expired = record.is_expired(now) @@ -340,12 +341,12 @@ def _process_record_update(self, now: float, record: DNSRecord) -> None: return # If its expired or already exists in the cache it cannot be updated. - if expired or self.zc.cache.get(record): + if expired or self.zc.cache.async_get_unique(cast(_UniqueRecordsType, record)): return if isinstance(record, DNSAddress): # Iterate through the DNSCache and callback any services that use this address - for service in self.zc.cache.entries_with_server(record.name): + for service in self.zc.cache.async_entries_with_server(record.name): type_ = self._record_matching_type(service) if type_: self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) @@ -367,7 +368,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSReco This method will be run in the event loop. """ for record in records: - self._process_record_update(now, record) + self._async_process_record_update(now, record) def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. From 35ac7a39d1fab00898ed6075e7e930424716b627 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 18:12:00 -1000 Subject: [PATCH 0432/1433] Breakout ServiceBrowser handler from listener creation (#736) --- tests/test_services.py | 123 +++++++++++++++++++++++++++++++++ zeroconf/_services/__init__.py | 49 +++++++------ 2 files changed, 149 insertions(+), 23 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 5d72a1aa6..f972f9d24 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -515,6 +515,11 @@ def _mock_get_expiration_time(self, percent): assert service_added_count == 3 assert service_removed_count == 0 + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Updated, service_types[0], service_names[0], 0), + ) + # all three services removed _inject_response( zeroconf, @@ -1265,6 +1270,124 @@ def mock_incoming_msg(records) -> r.DNSIncoming: zc.close() +def test_service_browser_listeners_update_service(): + """Test that the ServiceBrowser ServiceListener that implements update_service.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + time.sleep(0.2) + + assert callbacks == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ] + browser.cancel() + + zc.close() + + +def test_service_browser_listeners_no_update_service(): + """Test that the ServiceBrowser ServiceListener that does not implement update_service.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener: + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + time.sleep(0.2) + + assert callbacks == [ + ('add', type_, registration_name), + ] + browser.cancel() + + zc.close() + + def test_changing_name_updates_serviceinfo_key(): """Verify a name change will adjust the underlying key value.""" type_ = "_homeassistant._tcp.local." diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 306b69990..20ed66514 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -223,6 +223,31 @@ def _group_ptr_queries_with_known_answers( return [query_bucket.out for query_bucket in query_buckets] +def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: + """Generate a service_state_changed handlers from a listener.""" + + def on_change( + zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange + ) -> None: + assert listener is not None + args = (zeroconf, service_type, name) + if state_change is ServiceStateChange.Added: + listener.add_service(*args) + elif state_change is ServiceStateChange.Removed: + listener.remove_service(*args) + elif state_change is ServiceStateChange.Updated: + if hasattr(listener, 'update_service'): + listener.update_service(*args) + else: + warnings.warn( + "%r has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory." % (listener,), + FutureWarning, + ) + + return on_change + + class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" @@ -263,29 +288,7 @@ def __init__( handlers = cast(List[Callable[..., None]], handlers or []) if listener: - - def on_change( - zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange - ) -> None: - assert listener is not None - args = (zeroconf, service_type, name) - if state_change is ServiceStateChange.Added: - listener.add_service(*args) - elif state_change is ServiceStateChange.Removed: - listener.remove_service(*args) - elif state_change is ServiceStateChange.Updated: - if hasattr(listener, 'update_service'): - listener.update_service(*args) - else: - warnings.warn( - "%r has no update_service method. Provide one (it can be empty if you " - "don't care about the updates), it'll become mandatory." % (listener,), - FutureWarning, - ) - else: - raise NotImplementedError(state_change) - - handlers.append(on_change) + handlers.append(_service_state_changed_from_listener(listener)) for h in handlers: self.service_state_changed.register_handler(h) From 5feda7e318f7d164d2b04b2d243a804372517da6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 18:48:26 -1000 Subject: [PATCH 0433/1433] Remove second level caching from ServiceBrowsers (#737) --- zeroconf/_services/__init__.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 20ed66514..9334bafcb 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -223,6 +223,21 @@ def _group_ptr_queries_with_known_answers( return [query_bucket.out for query_bucket in query_buckets] +def generate_service_query( + zc: 'Zeroconf', now: float, types_: List[str], multicast: bool = True +) -> List[DNSOutgoing]: + """Generate a service query for sending with zeroconf.send.""" + questions_with_known_answers: _QuestionWithKnownAnswers = {} + for type_ in types_: + question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) + questions_with_known_answers[question] = set( + cast(DNSPointer, record) + for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) + if not record.is_stale(now) + ) + return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) + + def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: """Generate a service_state_changed handlers from a listener.""" @@ -271,7 +286,6 @@ def __init__( self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - self._services: Dict[str, Dict[str, DNSPointer]] = {check_type_: {} for check_type_ in self.types} current_time = current_time_millis() self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} @@ -327,17 +341,14 @@ def _async_process_record_update(self, now: float, record: DNSRecord) -> None: if isinstance(record, DNSPointer): if record.name not in self.types: return - service_key = record.alias.lower() - services_by_type = self._services[record.name] - old_record = services_by_type.get(service_key) + old_record = self.zc.cache.async_get_unique( + DNSPointer(record.name, _TYPE_PTR, _CLASS_IN, 0, record.alias) + ) if old_record is None: - services_by_type[service_key] = record self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) elif expired: - del services_by_type[service_key] self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: - old_record.reset_ttl(record) expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) if expires < self._next_time[record.name]: self._next_time[record.name] = expires @@ -407,18 +418,17 @@ def generate_ready_queries(self) -> List[DNSOutgoing]: if min(self._next_time.values()) > now: return [] - questions_with_known_answers: _QuestionWithKnownAnswers = {} + ready_types = [] for type_, due in self._next_time.items(): if due > now: continue - questions_with_known_answers[DNSQuestion(type_, _TYPE_PTR, _CLASS_IN)] = set( - record for record in self._services[type_].values() if not record.is_stale(now) - ) + + ready_types.append(type_) self._next_time[type_] = now + self._delay[type_] self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - return _group_ptr_queries_with_known_answers(now, self.multicast, questions_with_known_answers) + return generate_service_query(self.zc, now, ready_types, self.multicast) def _seconds_to_wait(self) -> Optional[float]: """Returns the number of seconds to wait for the next event.""" From e227d6e4c337ef9d5aa626c41587a8046313e416 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 20:30:30 -1000 Subject: [PATCH 0434/1433] Fix flakey cache bit flush test (#739) --- tests/test_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ddd8ffa47..b86d253d0 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -908,7 +908,7 @@ async def test_cache_flush_bit(): assert entry.ttl == 1 # Wait for the ttl 1 records to expire - await asyncio.sleep(1.01) + await asyncio.sleep(1.1) loaded_info = r.ServiceInfo(type_, registration_name) loaded_info.load_from_cache(zc) From c8e15dd2bb5f6d2eb3a8ef5f26ad044517b70c47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 21:24:23 -1000 Subject: [PATCH 0435/1433] Run question answer callbacks from add_listener in the event loop (#740) --- tests/test_core.py | 12 +++++++++--- zeroconf/_handlers.py | 35 +++++++++++++++++++++++----------- zeroconf/_services/__init__.py | 8 ++------ zeroconf/aio.py | 1 - 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 819bbe68d..1f0884f03 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -249,10 +249,14 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS zeroconf.close() -def test_notify_listeners(): +# This test uses asyncio because it needs to verify the listeners +# run in the event loop +@pytest.mark.asyncio +async def test_notify_listeners(): """Test adding and removing notify listeners.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf notify_called = 0 class TestNotifyListener(r.NotifyListener): @@ -274,6 +278,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) browser.cancel() + await asyncio.sleep(0) # flush out any call_soon_threadsafe assert notify_called zc.remove_notify_listener(notify_listener) @@ -281,10 +286,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # start a browser browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) browser.cancel() + await asyncio.sleep(0) # flush out any call_soon_threadsafe assert not notify_called - zc.close() + await aiozc.async_close() def test_generate_service_query_set_qu_bit(): diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 66b8862f2..476217f6c 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -385,18 +385,31 @@ def add_listener( answer the question(s).""" self.listeners.append(listener) - if question is not None: - now = current_time_millis() - records = [] - questions = [question] if isinstance(question, DNSQuestion) else question - for single_question in questions: - for record in self.cache.entries_with_name(single_question.name): - if single_question.answered_by(record) and not record.is_expired(now): - records.append(record) - if records: - listener.async_update_records(self.zc, now, records) - listener.async_update_records_complete() + if question is None: + self.zc.notify_all() + return + questions = [question] if isinstance(question, DNSQuestion) else question + assert self.zc.loop is not None + self.zc.loop.call_soon_threadsafe(self._async_update_matching_records, listener, questions) + + def _async_update_matching_records( + self, listener: RecordUpdateListener, questions: List[DNSQuestion] + ) -> None: + """Calls back any existing entries in the cache that answer the question. + + This function must be run from the event loop. + """ + now = current_time_millis() + records = [] + for question in questions: + for record in self.cache.async_entries_with_name(question.name): + if not record.is_expired(now) and question.answered_by(record): + records.append(record) + if not records: + return + listener.async_update_records(self.zc, now, records) + listener.async_update_records_complete() self.zc.notify_all() def remove_listener(self, listener: RecordUpdateListener) -> None: diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 9334bafcb..04288a69c 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -307,6 +307,8 @@ def __init__( for h in handlers: self.service_state_changed.register_handler(h) + self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + @property def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface @@ -406,11 +408,6 @@ def cancel(self) -> None: self.done = True self.zc.remove_listener(self) - def run(self) -> None: - """Run the browser.""" - questions = [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] - self.zc.add_listener(self, questions) - def generate_ready_queries(self) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" now = current_time_millis() @@ -480,7 +477,6 @@ def cancel(self) -> None: def run(self) -> None: """Run the browser thread.""" - super().run() while True: timeout = self._seconds_to_wait() if timeout: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index ae57d0142..d5414d138 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -146,7 +146,6 @@ async def async_cancel(self) -> None: async def async_run(self) -> None: """Run the browser task.""" - self.run() await self.aiozc.zeroconf.async_wait_for_start() while True: timeout = self._seconds_to_wait() From f0d727bd9addd6dab373b75008f04a6f8547928b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 21:30:53 -1000 Subject: [PATCH 0436/1433] Relocate ServiceInfo to zeroconf._services.info (#741) --- tests/test_aio.py | 3 +- tests/test_services.py | 7 +- zeroconf/__init__.py | 6 +- zeroconf/_core.py | 9 +- zeroconf/_services/__init__.py | 421 +----------------------------- zeroconf/_services/info.py | 458 +++++++++++++++++++++++++++++++++ zeroconf/_services/registry.py | 2 +- zeroconf/aio.py | 3 +- 8 files changed, 472 insertions(+), 437 deletions(-) create mode 100644 zeroconf/_services/info.py diff --git a/tests/test_aio.py b/tests/test_aio.py index 47c1e2d9d..e41442500 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -16,7 +16,8 @@ from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered -from zeroconf._services import ServiceInfo, ServiceListener +from zeroconf._services import ServiceListener +from zeroconf._services.info import ServiceInfo from zeroconf._utils.time import current_time_millis from . import _clear_cache diff --git a/tests/test_services.py b/tests/test_services.py index f972f9d24..2a077329c 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -19,11 +19,8 @@ from zeroconf import DNSAddress, DNSPointer, DNSQuestion, const, current_time_millis import zeroconf._services as s from zeroconf import Zeroconf -from zeroconf._services import ( - ServiceBrowser, - ServiceInfo, - ServiceStateChange, -) +from zeroconf._services import ServiceBrowser, ServiceStateChange +from zeroconf._services.info import ServiceInfo from zeroconf.aio import AsyncZeroconf from . import has_working_ipv6, _clear_cache, _inject_response diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index ab2b0993e..e61a71193 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -46,15 +46,17 @@ ) from ._protocol import DNSIncoming, DNSOutgoing # noqa # import needed for backwards compat from ._services import ( # noqa # import needed for backwards compat - instance_name_from_service_info, Signal, SignalRegistrationInterface, RecordUpdateListener, ServiceBrowser, - ServiceInfo, ServiceListener, ServiceStateChange, ) +from ._services.info import ( # noqa # import needed for backwards compat + instance_name_from_service_info, + ServiceInfo, +) from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat from ._services.types import ZeroconfServiceTypes from ._utils.name import service_type_name # noqa # import needed for backwards compat diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a7910591a..675d7169d 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -37,13 +37,8 @@ from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing -from ._services import ( - RecordUpdateListener, - ServiceBrowser, - ServiceInfo, - ServiceListener, - instance_name_from_service_info, -) +from ._services import RecordUpdateListener, ServiceBrowser, ServiceListener +from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry from ._utils.aio import get_running_loop from ._utils.name import service_type_name diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 04288a69c..111ea4487 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -21,44 +21,28 @@ """ import enum -import socket import threading import warnings from collections import OrderedDict from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from .._cache import _UniqueRecordsType -from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText -from .._exceptions import BadTypeInNameException +from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord from .._protocol import DNSOutgoing from .._utils.name import service_type_name -from .._utils.net import ( - IPVersion, - _encode_address, - _is_v6_address, -) -from .._utils.struct import int2byte from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, _CLASS_IN, - _CLASS_UNIQUE, - _DNS_HOST_TTL, - _DNS_OTHER_TTL, _DNS_PACKET_HEADER_LEN, _EXPIRE_REFRESH_TIME_PERCENT, _FLAGS_QR_QUERY, - _LISTENER_TIME, _MAX_MSG_TYPICAL, _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, - _TYPE_A, - _TYPE_AAAA, _TYPE_PTR, - _TYPE_SRV, - _TYPE_TXT, ) @@ -77,16 +61,6 @@ class ServiceStateChange(enum.Enum): Updated = 3 -def instance_name_from_service_info(info: "ServiceInfo") -> str: - """Calculate the instance name from the ServiceInfo.""" - # This is kind of funky because of the subtype based tests - # need to make subtypes a first class citizen - service_name = service_type_name(info.name) - if not info.type.endswith(service_name): - raise BadTypeInNameException - return info.name[: -len(service_name) - 1] - - class ServiceListener: def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: raise NotImplementedError() @@ -505,396 +479,3 @@ def run(self) -> None: name=name_type[0], state_change=state_change, ) - - -class ServiceInfo(RecordUpdateListener): - """Service information. - - Constructor parameters are as follows: - - * `type_`: fully qualified service type name - * `name`: fully qualified service name - * `port`: port that the service runs on - * `weight`: weight of the service - * `priority`: priority of the service - * `properties`: dictionary of properties (or a bytes object holding the contents of the `text` field). - converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to - value-less attributes. - * `server`: fully qualified name for service host (defaults to name) - * `host_ttl`: ttl used for A/SRV records - * `other_ttl`: ttl used for PTR/TXT records - * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, - or in parsed form as text; at most one of those parameters can be provided) - - """ - - text = b'' - - def __init__( - self, - type_: str, - name: str, - port: Optional[int] = None, - weight: int = 0, - priority: int = 0, - properties: Union[bytes, Dict] = b'', - server: Optional[str] = None, - host_ttl: int = _DNS_HOST_TTL, - other_ttl: int = _DNS_OTHER_TTL, - *, - addresses: Optional[List[bytes]] = None, - parsed_addresses: Optional[List[str]] = None - ) -> None: - # Accept both none, or one, but not both. - if addresses is not None and parsed_addresses is not None: - raise TypeError("addresses and parsed_addresses cannot be provided together") - if not type_.endswith(service_type_name(name, strict=False)): - raise BadTypeInNameException - self.type = type_ - self._name = name - self.key = name.lower() - if addresses is not None: - self._addresses = addresses - elif parsed_addresses is not None: - self._addresses = [_encode_address(a) for a in parsed_addresses] - else: - self._addresses = [] - # This results in an ugly error when registering, better check now - invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] - if invalid: - raise TypeError( - 'Addresses must be bytes, got %s. Hint: convert string addresses ' - 'with socket.inet_pton' % invalid - ) - self.port = port - self.weight = weight - self.priority = priority - self.server = server if server else name - self.server_key = self.server.lower() - self._properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} - if isinstance(properties, bytes): - self._set_text(properties) - else: - self._set_properties(properties) - self.host_ttl = host_ttl - self.other_ttl = other_ttl - - @property - def name(self) -> str: - """The name of the service.""" - return self._name - - @name.setter - def name(self, name: str) -> None: - """Replace the the name and reset the key.""" - self._name = name - self.key = name.lower() - - @property - def addresses(self) -> List[bytes]: - """IPv4 addresses of this service. - - Only IPv4 addresses are returned for backward compatibility. - Use :meth:`addresses_by_version` or :meth:`parsed_addresses` to - include IPv6 addresses as well. - """ - return self.addresses_by_version(IPVersion.V4Only) - - @addresses.setter - def addresses(self, value: List[bytes]) -> None: - """Replace the addresses list. - - This replaces all currently stored addresses, both IPv4 and IPv6. - """ - self._addresses = value - - @property - def properties(self) -> Dict: - """If properties were set in the constructor this property returns the original dictionary - of type `Dict[Union[bytes, str], Any]`. - - If properties are coming from the network, after decoding a TXT record, the keys are always - bytes and the values are either bytes, if there was a value, even empty, or `None`, if there - was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. - """ - return self._properties - - def addresses_by_version(self, version: IPVersion) -> List[bytes]: - """List addresses matching IP version.""" - if version == IPVersion.V4Only: - return [addr for addr in self._addresses if not _is_v6_address(addr)] - if version == IPVersion.V6Only: - return list(filter(_is_v6_address, self._addresses)) - return self._addresses - - def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: - """List addresses in their parsed string form.""" - result = self.addresses_by_version(version) - return [ - socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) - for addr in result - ] - - def _set_properties(self, properties: Dict) -> None: - """Sets properties and text of this info from a dictionary""" - self._properties = properties - list_ = [] - result = b'' - for key, value in properties.items(): - if isinstance(key, str): - key = key.encode('utf-8') - - record = key - if value is not None: - if not isinstance(value, bytes): - value = str(value).encode('utf-8') - record += b'=' + value - list_.append(record) - for item in list_: - result = b''.join((result, int2byte(len(item)), item)) - self.text = result - - def _set_text(self, text: bytes) -> None: - """Sets properties and text given a text field""" - self.text = text - end = len(text) - if end == 0: - self._properties = {} - return - result: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} - index = 0 - strs = [] - while index < end: - length = text[index] - index += 1 - strs.append(text[index : index + length]) - index += length - - key: bytes - value: Optional[bytes] - for s in strs: - try: - key, value = s.split(b'=', 1) - except ValueError: - # No equals sign at all - key = s - value = None - - # Only update non-existent properties - if key and result.get(key) is None: - result[key] = value - - self._properties = result - - def get_name(self) -> str: - """Name accessor""" - return self.name[: len(self.name) - len(self.type) - 1] - - def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: - """Updates service information from a DNS record. - - This method is deprecated and will be removed in a future version. - update_records should be implemented instead. - - This method will be run in the event loop. - """ - if record is not None: - self._process_records_threadsafe(zc, now, [record]) - - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Updates service information from a DNS record. - - This method will be run in the event loop. - """ - self._process_records_threadsafe(zc, now, records) - - def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Thread safe record updating.""" - update_addresses = False - for record in records: - if isinstance(record, DNSService): - update_addresses = True - self._process_record_threadsafe(record, now) - - # Only update addresses if the DNSService (.server) has changed - if not update_addresses: - return - - for record in self._get_address_records_from_cache(zc): - self._process_record_threadsafe(record, now) - - def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: - if record.is_expired(now): - return - - if isinstance(record, DNSAddress): - if record.key == self.server_key and record.address not in self._addresses: - self._addresses.append(record.address) - return - - if isinstance(record, DNSService): - if record.key != self.key: - return - self.name = record.name - self.server = record.server - self.server_key = record.server.lower() - self.port = record.port - self.weight = record.weight - self.priority = record.priority - return - - if isinstance(record, DNSText): - if record.key == self.key: - self._set_text(record.text) - - def dns_addresses( - self, - override_ttl: Optional[int] = None, - version: IPVersion = IPVersion.All, - created: Optional[float] = None, - ) -> List[DNSAddress]: - """Return matching DNSAddress from ServiceInfo.""" - return [ - DNSAddress( - self.server, - _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.host_ttl, - address, - created, - ) - for address in self.addresses_by_version(version) - ] - - def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: - """Return DNSPointer from ServiceInfo.""" - return DNSPointer( - self.type, - _TYPE_PTR, - _CLASS_IN, - override_ttl if override_ttl is not None else self.other_ttl, - self.name, - created, - ) - - def dns_service(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSService: - """Return DNSService from ServiceInfo.""" - return DNSService( - self.name, - _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.host_ttl, - self.priority, - self.weight, - cast(int, self.port), - self.server, - created, - ) - - def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSText: - """Return DNSText from ServiceInfo.""" - return DNSText( - self.name, - _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.other_ttl, - self.text, - created, - ) - - def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: - """Get the address records from the cache.""" - return [ - *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), - *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), - ] - - def load_from_cache(self, zc: 'Zeroconf') -> bool: - """Populate the service info from the cache. - - This method is designed to be threadsafe. - """ - now = current_time_millis() - record_updates = [] - cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) - if cached_srv_record: - # If there is a srv record, A and AAAA will already - # be called and we do not want to do it twice - record_updates.append(cached_srv_record) - else: - record_updates.extend(self._get_address_records_from_cache(zc)) - cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) - if cached_txt_record: - record_updates.append(cached_txt_record) - self._process_records_threadsafe(zc, now, record_updates) - return self._is_complete - - @property - def _is_complete(self) -> bool: - """The ServiceInfo has all expected properties.""" - return not (self.text is None or not self._addresses) - - def request(self, zc: 'Zeroconf', timeout: float) -> bool: - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ - if self.load_from_cache(zc): - return True - - now = current_time_millis() - delay = _LISTENER_TIME - next_ = now - last = now + timeout - try: - # Do not set a question on the listener to preload from cache - # since we just checked it above in load_from_cache - zc.add_listener(self, None) - while not self._is_complete: - if last <= now: - return False - if next_ <= now: - out = self.generate_request_query(zc, now) - if not out.questions: - return True - zc.send(out) - next_ = now + delay - delay *= 2 - - zc.wait(min(next_, last) - now) - now = current_time_millis() - finally: - zc.remove_listener(self) - - return True - - def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: - """Generate the request query.""" - out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) - return out - - def __eq__(self, other: object) -> bool: - """Tests equality of service name""" - return isinstance(other, ServiceInfo) and other.name == self.name - - def __repr__(self) -> str: - """String representation""" - return '%s(%s)' % ( - type(self).__name__, - ', '.join( - '%s=%r' % (name, getattr(self, name)) - for name in ( - 'type', - 'name', - 'addresses', - 'port', - 'weight', - 'priority', - 'server', - 'properties', - ) - ), - ) diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py new file mode 100644 index 000000000..a3536ed1d --- /dev/null +++ b/zeroconf/_services/info.py @@ -0,0 +1,458 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import socket +from typing import Dict, List, Optional, TYPE_CHECKING, Union, cast + +from .._dns import DNSAddress, DNSPointer, DNSRecord, DNSService, DNSText +from .._exceptions import BadTypeInNameException +from .._protocol import DNSOutgoing +from .._services import RecordUpdateListener +from .._utils.name import service_type_name +from .._utils.net import ( + IPVersion, + _encode_address, + _is_v6_address, +) +from .._utils.struct import int2byte +from .._utils.time import current_time_millis +from ..const import ( + _CLASS_IN, + _CLASS_UNIQUE, + _DNS_HOST_TTL, + _DNS_OTHER_TTL, + _FLAGS_QR_QUERY, + _LISTENER_TIME, + _TYPE_A, + _TYPE_AAAA, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) + + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from .._core import Zeroconf # pylint: disable=cyclic-import + + +def instance_name_from_service_info(info: "ServiceInfo") -> str: + """Calculate the instance name from the ServiceInfo.""" + # This is kind of funky because of the subtype based tests + # need to make subtypes a first class citizen + service_name = service_type_name(info.name) + if not info.type.endswith(service_name): + raise BadTypeInNameException + return info.name[: -len(service_name) - 1] + + +class ServiceInfo(RecordUpdateListener): + """Service information. + + Constructor parameters are as follows: + + * `type_`: fully qualified service type name + * `name`: fully qualified service name + * `port`: port that the service runs on + * `weight`: weight of the service + * `priority`: priority of the service + * `properties`: dictionary of properties (or a bytes object holding the contents of the `text` field). + converted to str and then encoded to bytes using UTF-8. Keys with `None` values are converted to + value-less attributes. + * `server`: fully qualified name for service host (defaults to name) + * `host_ttl`: ttl used for A/SRV records + * `other_ttl`: ttl used for PTR/TXT records + * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, + or in parsed form as text; at most one of those parameters can be provided) + + """ + + text = b'' + + def __init__( + self, + type_: str, + name: str, + port: Optional[int] = None, + weight: int = 0, + priority: int = 0, + properties: Union[bytes, Dict] = b'', + server: Optional[str] = None, + host_ttl: int = _DNS_HOST_TTL, + other_ttl: int = _DNS_OTHER_TTL, + *, + addresses: Optional[List[bytes]] = None, + parsed_addresses: Optional[List[str]] = None + ) -> None: + # Accept both none, or one, but not both. + if addresses is not None and parsed_addresses is not None: + raise TypeError("addresses and parsed_addresses cannot be provided together") + if not type_.endswith(service_type_name(name, strict=False)): + raise BadTypeInNameException + self.type = type_ + self._name = name + self.key = name.lower() + if addresses is not None: + self._addresses = addresses + elif parsed_addresses is not None: + self._addresses = [_encode_address(a) for a in parsed_addresses] + else: + self._addresses = [] + # This results in an ugly error when registering, better check now + invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] + if invalid: + raise TypeError( + 'Addresses must be bytes, got %s. Hint: convert string addresses ' + 'with socket.inet_pton' % invalid + ) + self.port = port + self.weight = weight + self.priority = priority + self.server = server if server else name + self.server_key = self.server.lower() + self._properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} + if isinstance(properties, bytes): + self._set_text(properties) + else: + self._set_properties(properties) + self.host_ttl = host_ttl + self.other_ttl = other_ttl + + @property + def name(self) -> str: + """The name of the service.""" + return self._name + + @name.setter + def name(self, name: str) -> None: + """Replace the the name and reset the key.""" + self._name = name + self.key = name.lower() + + @property + def addresses(self) -> List[bytes]: + """IPv4 addresses of this service. + + Only IPv4 addresses are returned for backward compatibility. + Use :meth:`addresses_by_version` or :meth:`parsed_addresses` to + include IPv6 addresses as well. + """ + return self.addresses_by_version(IPVersion.V4Only) + + @addresses.setter + def addresses(self, value: List[bytes]) -> None: + """Replace the addresses list. + + This replaces all currently stored addresses, both IPv4 and IPv6. + """ + self._addresses = value + + @property + def properties(self) -> Dict: + """If properties were set in the constructor this property returns the original dictionary + of type `Dict[Union[bytes, str], Any]`. + + If properties are coming from the network, after decoding a TXT record, the keys are always + bytes and the values are either bytes, if there was a value, even empty, or `None`, if there + was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. + """ + return self._properties + + def addresses_by_version(self, version: IPVersion) -> List[bytes]: + """List addresses matching IP version.""" + if version == IPVersion.V4Only: + return [addr for addr in self._addresses if not _is_v6_address(addr)] + if version == IPVersion.V6Only: + return list(filter(_is_v6_address, self._addresses)) + return self._addresses + + def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + """List addresses in their parsed string form.""" + result = self.addresses_by_version(version) + return [ + socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) + for addr in result + ] + + def _set_properties(self, properties: Dict) -> None: + """Sets properties and text of this info from a dictionary""" + self._properties = properties + list_ = [] + result = b'' + for key, value in properties.items(): + if isinstance(key, str): + key = key.encode('utf-8') + + record = key + if value is not None: + if not isinstance(value, bytes): + value = str(value).encode('utf-8') + record += b'=' + value + list_.append(record) + for item in list_: + result = b''.join((result, int2byte(len(item)), item)) + self.text = result + + def _set_text(self, text: bytes) -> None: + """Sets properties and text given a text field""" + self.text = text + end = len(text) + if end == 0: + self._properties = {} + return + result: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} + index = 0 + strs = [] + while index < end: + length = text[index] + index += 1 + strs.append(text[index : index + length]) + index += length + + key: bytes + value: Optional[bytes] + for s in strs: + try: + key, value = s.split(b'=', 1) + except ValueError: + # No equals sign at all + key = s + value = None + + # Only update non-existent properties + if key and result.get(key) is None: + result[key] = value + + self._properties = result + + def get_name(self) -> str: + """Name accessor""" + return self.name[: len(self.name) - len(self.type) - 1] + + def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: + """Updates service information from a DNS record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + + This method will be run in the event loop. + """ + if record is not None: + self._process_records_threadsafe(zc, now, [record]) + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Updates service information from a DNS record. + + This method will be run in the event loop. + """ + self._process_records_threadsafe(zc, now, records) + + def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Thread safe record updating.""" + update_addresses = False + for record in records: + if isinstance(record, DNSService): + update_addresses = True + self._process_record_threadsafe(record, now) + + # Only update addresses if the DNSService (.server) has changed + if not update_addresses: + return + + for record in self._get_address_records_from_cache(zc): + self._process_record_threadsafe(record, now) + + def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: + if record.is_expired(now): + return + + if isinstance(record, DNSAddress): + if record.key == self.server_key and record.address not in self._addresses: + self._addresses.append(record.address) + return + + if isinstance(record, DNSService): + if record.key != self.key: + return + self.name = record.name + self.server = record.server + self.server_key = record.server.lower() + self.port = record.port + self.weight = record.weight + self.priority = record.priority + return + + if isinstance(record, DNSText): + if record.key == self.key: + self._set_text(record.text) + + def dns_addresses( + self, + override_ttl: Optional[int] = None, + version: IPVersion = IPVersion.All, + created: Optional[float] = None, + ) -> List[DNSAddress]: + """Return matching DNSAddress from ServiceInfo.""" + return [ + DNSAddress( + self.server, + _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + address, + created, + ) + for address in self.addresses_by_version(version) + ] + + def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: + """Return DNSPointer from ServiceInfo.""" + return DNSPointer( + self.type, + _TYPE_PTR, + _CLASS_IN, + override_ttl if override_ttl is not None else self.other_ttl, + self.name, + created, + ) + + def dns_service(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSService: + """Return DNSService from ServiceInfo.""" + return DNSService( + self.name, + _TYPE_SRV, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + self.priority, + self.weight, + cast(int, self.port), + self.server, + created, + ) + + def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSText: + """Return DNSText from ServiceInfo.""" + return DNSText( + self.name, + _TYPE_TXT, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.other_ttl, + self.text, + created, + ) + + def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: + """Get the address records from the cache.""" + return [ + *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), + *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), + ] + + def load_from_cache(self, zc: 'Zeroconf') -> bool: + """Populate the service info from the cache. + + This method is designed to be threadsafe. + """ + now = current_time_millis() + record_updates = [] + cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) + if cached_srv_record: + # If there is a srv record, A and AAAA will already + # be called and we do not want to do it twice + record_updates.append(cached_srv_record) + else: + record_updates.extend(self._get_address_records_from_cache(zc)) + cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) + if cached_txt_record: + record_updates.append(cached_txt_record) + self._process_records_threadsafe(zc, now, record_updates) + return self._is_complete + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return not (self.text is None or not self._addresses) + + def request(self, zc: 'Zeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + if self.load_from_cache(zc): + return True + + now = current_time_millis() + delay = _LISTENER_TIME + next_ = now + last = now + timeout + try: + # Do not set a question on the listener to preload from cache + # since we just checked it above in load_from_cache + zc.add_listener(self, None) + while not self._is_complete: + if last <= now: + return False + if next_ <= now: + out = self.generate_request_query(zc, now) + if not out.questions: + return True + zc.send(out) + next_ = now + delay + delay *= 2 + + zc.wait(min(next_, last) - now) + now = current_time_millis() + finally: + zc.remove_listener(self) + + return True + + def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: + """Generate the request query.""" + out = DNSOutgoing(_FLAGS_QR_QUERY) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) + return out + + def __eq__(self, other: object) -> bool: + """Tests equality of service name""" + return isinstance(other, ServiceInfo) and other.name == self.name + + def __repr__(self) -> str: + """String representation""" + return '%s(%s)' % ( + type(self).__name__, + ', '.join( + '%s=%r' % (name, getattr(self, name)) + for name in ( + 'type', + 'name', + 'addresses', + 'port', + 'weight', + 'priority', + 'server', + 'properties', + ) + ), + ) diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 20584b3a6..ebf5abbb6 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -24,8 +24,8 @@ from typing import Dict, List, Optional, Union +from .info import ServiceInfo from .._exceptions import ServiceNameAlreadyRegistered -from .._services import ServiceInfo class ServiceRegistry: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index d5414d138..e64c87c38 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,7 +26,8 @@ from ._core import NotifyListener, Zeroconf from ._exceptions import NonUniqueNameException -from ._services import ServiceInfo, _ServiceBrowserBase, instance_name_from_service_info +from ._services import _ServiceBrowserBase +from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.types import ZeroconfServiceTypes from ._utils.aio import wait_condition_or_timeout from ._utils.net import IPVersion, InterfaceChoice, InterfacesType From 368163d3c30325d60021203430711e10fd6d97e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 21:51:36 -1000 Subject: [PATCH 0437/1433] Relocate ServiceBrowser to zeroconf._services.browser (#744) --- tests/test_services.py | 25 +- zeroconf/__init__.py | 4 +- zeroconf/_core.py | 3 +- zeroconf/_services/__init__.py | 373 +----------------------------- zeroconf/_services/browser.py | 405 +++++++++++++++++++++++++++++++++ zeroconf/_services/types.py | 3 +- zeroconf/aio.py | 2 +- 7 files changed, 429 insertions(+), 386 deletions(-) create mode 100644 zeroconf/_services/browser.py diff --git a/tests/test_services.py b/tests/test_services.py index 2a077329c..88b490fb6 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -17,9 +17,10 @@ import zeroconf as r from zeroconf import DNSAddress, DNSPointer, DNSQuestion, const, current_time_millis -import zeroconf._services as s +import zeroconf._services.browser as _services_browser from zeroconf import Zeroconf -from zeroconf._services import ServiceBrowser, ServiceStateChange +from zeroconf._services import ServiceStateChange +from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo from zeroconf.aio import AsyncZeroconf @@ -984,7 +985,7 @@ def test_backoff(): time_offset = 0.0 start_time = time.time() * 1000 - initial_query_interval = s._BROWSER_TIME / 1000 + initial_query_interval = _services_browser._BROWSER_TIME / 1000 def current_time_millis(): """Current system time in milliseconds""" @@ -999,8 +1000,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf current_time_millis # patch the backoff limit to prevent test running forever with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( - s, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(s, "_BROWSER_BACKOFF_LIMIT", 10): + _services_browser, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -1024,7 +1025,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): if time_offset == expected_query_time: assert got_query.is_set() got_query.clear() - if next_query_interval == s._BROWSER_BACKOFF_LIMIT: + if next_query_interval == _services_browser._BROWSER_BACKOFF_LIMIT: # Only need to test up to the point where we've seen a query # after the backoff limit has been hit break @@ -1032,7 +1033,9 @@ def on_service_state_change(zeroconf, service_type, state_change, name): next_query_interval = initial_query_interval expected_query_time = initial_query_interval else: - next_query_interval = min(2 * next_query_interval, s._BROWSER_BACKOFF_LIMIT) + next_query_interval = min( + 2 * next_query_interval, _services_browser._BROWSER_BACKOFF_LIMIT + ) expected_query_time += next_query_interval else: assert not got_query.is_set() @@ -1090,8 +1093,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf current_time_millis # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( - s, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(s, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + _services_browser, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): service_added = Event() service_removed = Event() @@ -1524,7 +1527,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): def test_group_ptr_queries_with_known_answers(): - questions_with_known_answers: s._QuestionWithKnownAnswers = {} + questions_with_known_answers: _services_browser._QuestionWithKnownAnswers = {} now = current_time_millis() for i in range(120): name = f"_hap{i}._tcp._local." @@ -1538,7 +1541,7 @@ def test_group_ptr_queries_with_known_answers(): ) for counter in range(i) ) - outs = s._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) + outs = _services_browser._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) for out in outs: packets = out.packets() # If we generate multiple packets there must diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e61a71193..3715e1740 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -49,10 +49,12 @@ Signal, SignalRegistrationInterface, RecordUpdateListener, - ServiceBrowser, ServiceListener, ServiceStateChange, ) +from ._services.browser import ( # noqa # import needed for backwards compat + ServiceBrowser, +) from ._services.info import ( # noqa # import needed for backwards compat instance_name_from_service_info, ServiceInfo, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 675d7169d..ff54dc7e9 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -37,7 +37,8 @@ from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing -from ._services import RecordUpdateListener, ServiceBrowser, ServiceListener +from ._services import RecordUpdateListener, ServiceListener +from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry from ._utils.aio import get_running_loop diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 111ea4487..776d43a7c 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -21,39 +21,15 @@ """ import enum -import threading -import warnings -from collections import OrderedDict -from typing import Any, Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast - -from .._cache import _UniqueRecordsType -from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord -from .._protocol import DNSOutgoing -from .._utils.name import service_type_name -from .._utils.time import current_time_millis, millis_to_seconds -from ..const import ( - _BROWSER_BACKOFF_LIMIT, - _BROWSER_TIME, - _CLASS_IN, - _DNS_PACKET_HEADER_LEN, - _EXPIRE_REFRESH_TIME_PERCENT, - _FLAGS_QR_QUERY, - _MAX_MSG_TYPICAL, - _MDNS_ADDR, - _MDNS_ADDR6, - _MDNS_PORT, - _TYPE_PTR, -) +from typing import Any, Callable, List, TYPE_CHECKING +from .._dns import DNSRecord if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 from .._core import Zeroconf # pylint: disable=cyclic-import -_QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] - - @enum.unique class ServiceStateChange(enum.Enum): Added = 1 @@ -134,348 +110,3 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - - -class _DNSPointerOutgoingBucket: - """A DNSOutgoing bucket.""" - - def __init__(self, now: float, multicast: bool) -> None: - """Create a bucke to wrap a DNSOutgoing.""" - self.now = now - self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=multicast) - self.bytes = 0 - - def add(self, max_compressed_size: int, question: DNSQuestion, answers: Set[DNSPointer]) -> None: - """Add a new set of questions and known answers to the outgoing.""" - self.out.add_question(question) - for answer in answers: - self.out.add_answer_at_time(answer, self.now) - self.bytes += max_compressed_size - - -def _group_ptr_queries_with_known_answers( - now: float, multicast: bool, question_with_known_answers: _QuestionWithKnownAnswers -) -> List[DNSOutgoing]: - """Aggregate queries so that as many known answers as possible fit in the same packet - without having known answers spill over into the next packet unless the - question and known answers are always going to exceed the packet size. - - Some responders do not implement multi-packet known answer suppression - so we try to keep all the known answers in the same packet as the - questions. - """ - # This is the maximum size the query + known answers can be with name compression. - # The actual size of the query + known answers may be a bit smaller since other - # parts may be shared when the final DNSOutgoing packets are constructed. The - # goal of this algorithm is to quickly bucket the query + known answers without - # the overhead of actually constructing the packets. - query_by_size: Dict[DNSQuestion, int] = { - question: (question.max_size + sum([answer.max_size_compressed for answer in known_answers])) - for question, known_answers in question_with_known_answers.items() - } - max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN - query_buckets: List[_DNSPointerOutgoingBucket] = [] - for question in sorted( - query_by_size, - key=query_by_size.get, # type: ignore - reverse=True, - ): - max_compressed_size = query_by_size[question] - answers = question_with_known_answers[question] - for query_bucket in query_buckets: - if query_bucket.bytes + max_compressed_size <= max_bucket_size: - query_bucket.add(max_compressed_size, question, answers) - break - else: - # If a single question and known answers won't fit in a packet - # we will end up generating multiple packets, but there will never - # be multiple questions - query_bucket = _DNSPointerOutgoingBucket(now, multicast) - query_bucket.add(max_compressed_size, question, answers) - query_buckets.append(query_bucket) - - return [query_bucket.out for query_bucket in query_buckets] - - -def generate_service_query( - zc: 'Zeroconf', now: float, types_: List[str], multicast: bool = True -) -> List[DNSOutgoing]: - """Generate a service query for sending with zeroconf.send.""" - questions_with_known_answers: _QuestionWithKnownAnswers = {} - for type_ in types_: - question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) - questions_with_known_answers[question] = set( - cast(DNSPointer, record) - for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) - if not record.is_stale(now) - ) - return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) - - -def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: - """Generate a service_state_changed handlers from a listener.""" - - def on_change( - zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange - ) -> None: - assert listener is not None - args = (zeroconf, service_type, name) - if state_change is ServiceStateChange.Added: - listener.add_service(*args) - elif state_change is ServiceStateChange.Removed: - listener.remove_service(*args) - elif state_change is ServiceStateChange.Updated: - if hasattr(listener, 'update_service'): - listener.update_service(*args) - else: - warnings.warn( - "%r has no update_service method. Provide one (it can be empty if you " - "don't care about the updates), it'll become mandatory." % (listener,), - FutureWarning, - ) - - return on_change - - -class _ServiceBrowserBase(RecordUpdateListener): - """Base class for ServiceBrowser.""" - - def __init__( - self, - zc: 'Zeroconf', - type_: Union[str, list], - handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, - listener: Optional['ServiceListener'] = None, - addr: Optional[str] = None, - port: int = _MDNS_PORT, - delay: int = _BROWSER_TIME, - ) -> None: - """Creates a browser for a specific type""" - assert handlers or listener, 'You need to specify at least one handler' - self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) - for check_type_ in self.types: - # Will generate BadTypeInNameException on a bad name - service_type_name(check_type_, strict=False) - self.zc = zc - self.addr = addr - self.port = port - self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - current_time = current_time_millis() - self._next_time = {check_type_: current_time for check_type_ in self.types} - self._delay = {check_type_: delay for check_type_ in self.types} - self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() - self._handlers_to_call: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() - self._service_state_changed = Signal() - - self.done = False - - if hasattr(handlers, 'add_service'): - listener = cast('ServiceListener', handlers) - handlers = None - - handlers = cast(List[Callable[..., None]], handlers or []) - - if listener: - handlers.append(_service_state_changed_from_listener(listener)) - - for h in handlers: - self.service_state_changed.register_handler(h) - - self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) - - @property - def service_state_changed(self) -> SignalRegistrationInterface: - return self._service_state_changed.registration_interface - - def _record_matching_type(self, record: DNSRecord) -> Optional[str]: - """Return the type if the record matches one of the types we are browsing.""" - return next((type_ for type_ in self.types if record.name.endswith(type_)), None) - - def _enqueue_callback( - self, - state_change: ServiceStateChange, - type_: str, - name: str, - ) -> None: - # Code to ensure we only do a single update message - # Precedence is; Added, Remove, Update - key = (name, type_) - if ( - state_change is ServiceStateChange.Added - or ( - state_change is ServiceStateChange.Removed - and self._pending_handlers.get(key) != ServiceStateChange.Added - ) - or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) - ): - self._pending_handlers[key] = state_change - - def _async_process_record_update(self, now: float, record: DNSRecord) -> None: - """Process a single record update from a batch of updates.""" - expired = record.is_expired(now) - - if isinstance(record, DNSPointer): - if record.name not in self.types: - return - old_record = self.zc.cache.async_get_unique( - DNSPointer(record.name, _TYPE_PTR, _CLASS_IN, 0, record.alias) - ) - if old_record is None: - self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) - elif expired: - self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) - else: - expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - if expires < self._next_time[record.name]: - self._next_time[record.name] = expires - return - - # If its expired or already exists in the cache it cannot be updated. - if expired or self.zc.cache.async_get_unique(cast(_UniqueRecordsType, record)): - return - - if isinstance(record, DNSAddress): - # Iterate through the DNSCache and callback any services that use this address - for service in self.zc.cache.async_entries_with_server(record.name): - type_ = self._record_matching_type(service) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) - break - - return - - type_ = self._record_matching_type(record) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) - - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Callback invoked by Zeroconf when new information arrives. - - Updates information required by browser in the Zeroconf cache. - - Ensures that there is are no unecessary duplicates in the list. - - This method will be run in the event loop. - """ - for record in records: - self._async_process_record_update(now, record) - - def async_update_records_complete(self) -> None: - """Called when a record update has completed for all handlers. - - At this point the cache will have the new records. - - This method will be run in the event loop. - """ - # Cannot use .update here since can fail with - # RuntimeError: dictionary changed size during iteration - # for threaded ServiceBrowsers - while self._pending_handlers: - try: - (name_type, state_change) = self._pending_handlers.popitem(False) - except KeyError: - return - self._handlers_to_call[name_type] = state_change - - def cancel(self) -> None: - """Cancel the browser.""" - self.done = True - self.zc.remove_listener(self) - - def generate_ready_queries(self) -> List[DNSOutgoing]: - """Generate the service browser query for any type that is due.""" - now = current_time_millis() - - if min(self._next_time.values()) > now: - return [] - - ready_types = [] - - for type_, due in self._next_time.items(): - if due > now: - continue - - ready_types.append(type_) - self._next_time[type_] = now + self._delay[type_] - self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - - return generate_service_query(self.zc, now, ready_types, self.multicast) - - def _seconds_to_wait(self) -> Optional[float]: - """Returns the number of seconds to wait for the next event.""" - # If there are handlers to call - # we want to process them right away - if self._handlers_to_call: - return None - - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - now = current_time_millis() - - if next_time <= now: - return None - - return millis_to_seconds(next_time - now) - - -class ServiceBrowser(_ServiceBrowserBase, threading.Thread): - """Used to browse for a service of a specific type. - - The listener object will have its add_service() and - remove_service() methods called when this browser - discovers changes in the services availability.""" - - def __init__( - self, - zc: 'Zeroconf', - type_: Union[str, list], - handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, - listener: Optional['ServiceListener'] = None, - addr: Optional[str] = None, - port: int = _MDNS_PORT, - delay: int = _BROWSER_TIME, - ) -> None: - threading.Thread.__init__(self) - super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) - self.daemon = True - self.start() - self.name = "zeroconf-ServiceBrowser-%s-%s" % ( - '-'.join([type_[:-7] for type_ in self.types]), - getattr(self, 'native_id', self.ident), - ) - - def cancel(self) -> None: - """Cancel the browser.""" - super().cancel() - self.join() - - def run(self) -> None: - """Run the browser thread.""" - while True: - timeout = self._seconds_to_wait() - if timeout: - with self.zc.condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - self.zc.condition.wait(timeout) - - if self.zc.done or self.done: - return - - outs = self.generate_ready_queries() - for out in outs: - self.zc.send(out, addr=self.addr, port=self.port) - - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py new file mode 100644 index 000000000..b633df673 --- /dev/null +++ b/zeroconf/_services/browser.py @@ -0,0 +1,405 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import threading +import warnings +from collections import OrderedDict +from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast + +from .._cache import _UniqueRecordsType +from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord +from .._protocol import DNSOutgoing +from .._services import ( + RecordUpdateListener, + ServiceListener, + ServiceStateChange, + Signal, + SignalRegistrationInterface, +) +from .._utils.name import service_type_name +from .._utils.time import current_time_millis, millis_to_seconds +from ..const import ( + _BROWSER_BACKOFF_LIMIT, + _BROWSER_TIME, + _CLASS_IN, + _DNS_PACKET_HEADER_LEN, + _EXPIRE_REFRESH_TIME_PERCENT, + _FLAGS_QR_QUERY, + _MAX_MSG_TYPICAL, + _MDNS_ADDR, + _MDNS_ADDR6, + _MDNS_PORT, + _TYPE_PTR, +) + + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from .._core import Zeroconf # pylint: disable=cyclic-import + + +_QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] + + +class _DNSPointerOutgoingBucket: + """A DNSOutgoing bucket.""" + + def __init__(self, now: float, multicast: bool) -> None: + """Create a bucke to wrap a DNSOutgoing.""" + self.now = now + self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=multicast) + self.bytes = 0 + + def add(self, max_compressed_size: int, question: DNSQuestion, answers: Set[DNSPointer]) -> None: + """Add a new set of questions and known answers to the outgoing.""" + self.out.add_question(question) + for answer in answers: + self.out.add_answer_at_time(answer, self.now) + self.bytes += max_compressed_size + + +def _group_ptr_queries_with_known_answers( + now: float, multicast: bool, question_with_known_answers: _QuestionWithKnownAnswers +) -> List[DNSOutgoing]: + """Aggregate queries so that as many known answers as possible fit in the same packet + without having known answers spill over into the next packet unless the + question and known answers are always going to exceed the packet size. + + Some responders do not implement multi-packet known answer suppression + so we try to keep all the known answers in the same packet as the + questions. + """ + # This is the maximum size the query + known answers can be with name compression. + # The actual size of the query + known answers may be a bit smaller since other + # parts may be shared when the final DNSOutgoing packets are constructed. The + # goal of this algorithm is to quickly bucket the query + known answers without + # the overhead of actually constructing the packets. + query_by_size: Dict[DNSQuestion, int] = { + question: (question.max_size + sum([answer.max_size_compressed for answer in known_answers])) + for question, known_answers in question_with_known_answers.items() + } + max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN + query_buckets: List[_DNSPointerOutgoingBucket] = [] + for question in sorted( + query_by_size, + key=query_by_size.get, # type: ignore + reverse=True, + ): + max_compressed_size = query_by_size[question] + answers = question_with_known_answers[question] + for query_bucket in query_buckets: + if query_bucket.bytes + max_compressed_size <= max_bucket_size: + query_bucket.add(max_compressed_size, question, answers) + break + else: + # If a single question and known answers won't fit in a packet + # we will end up generating multiple packets, but there will never + # be multiple questions + query_bucket = _DNSPointerOutgoingBucket(now, multicast) + query_bucket.add(max_compressed_size, question, answers) + query_buckets.append(query_bucket) + + return [query_bucket.out for query_bucket in query_buckets] + + +def generate_service_query( + zc: 'Zeroconf', now: float, types_: List[str], multicast: bool = True +) -> List[DNSOutgoing]: + """Generate a service query for sending with zeroconf.send.""" + questions_with_known_answers: _QuestionWithKnownAnswers = {} + for type_ in types_: + question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) + questions_with_known_answers[question] = set( + cast(DNSPointer, record) + for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) + if not record.is_stale(now) + ) + return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) + + +def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: + """Generate a service_state_changed handlers from a listener.""" + + def on_change( + zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange + ) -> None: + assert listener is not None + args = (zeroconf, service_type, name) + if state_change is ServiceStateChange.Added: + listener.add_service(*args) + elif state_change is ServiceStateChange.Removed: + listener.remove_service(*args) + elif state_change is ServiceStateChange.Updated: + if hasattr(listener, 'update_service'): + listener.update_service(*args) + else: + warnings.warn( + "%r has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory." % (listener,), + FutureWarning, + ) + + return on_change + + +class _ServiceBrowserBase(RecordUpdateListener): + """Base class for ServiceBrowser.""" + + def __init__( + self, + zc: 'Zeroconf', + type_: Union[str, list], + handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, + listener: Optional['ServiceListener'] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + """Creates a browser for a specific type""" + assert handlers or listener, 'You need to specify at least one handler' + self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) + for check_type_ in self.types: + # Will generate BadTypeInNameException on a bad name + service_type_name(check_type_, strict=False) + self.zc = zc + self.addr = addr + self.port = port + self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) + current_time = current_time_millis() + self._next_time = {check_type_: current_time for check_type_ in self.types} + self._delay = {check_type_: delay for check_type_ in self.types} + self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() + self._handlers_to_call: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() + self._service_state_changed = Signal() + + self.done = False + + if hasattr(handlers, 'add_service'): + listener = cast('ServiceListener', handlers) + handlers = None + + handlers = cast(List[Callable[..., None]], handlers or []) + + if listener: + handlers.append(_service_state_changed_from_listener(listener)) + + for h in handlers: + self.service_state_changed.register_handler(h) + + self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + + @property + def service_state_changed(self) -> SignalRegistrationInterface: + return self._service_state_changed.registration_interface + + def _record_matching_type(self, record: DNSRecord) -> Optional[str]: + """Return the type if the record matches one of the types we are browsing.""" + return next((type_ for type_ in self.types if record.name.endswith(type_)), None) + + def _enqueue_callback( + self, + state_change: ServiceStateChange, + type_: str, + name: str, + ) -> None: + # Code to ensure we only do a single update message + # Precedence is; Added, Remove, Update + key = (name, type_) + if ( + state_change is ServiceStateChange.Added + or ( + state_change is ServiceStateChange.Removed + and self._pending_handlers.get(key) != ServiceStateChange.Added + ) + or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) + ): + self._pending_handlers[key] = state_change + + def _async_process_record_update(self, now: float, record: DNSRecord) -> None: + """Process a single record update from a batch of updates.""" + expired = record.is_expired(now) + + if isinstance(record, DNSPointer): + if record.name not in self.types: + return + old_record = self.zc.cache.async_get_unique( + DNSPointer(record.name, _TYPE_PTR, _CLASS_IN, 0, record.alias) + ) + if old_record is None: + self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) + elif expired: + self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) + else: + expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + if expires < self._next_time[record.name]: + self._next_time[record.name] = expires + return + + # If its expired or already exists in the cache it cannot be updated. + if expired or self.zc.cache.async_get_unique(cast(_UniqueRecordsType, record)): + return + + if isinstance(record, DNSAddress): + # Iterate through the DNSCache and callback any services that use this address + for service in self.zc.cache.async_entries_with_server(record.name): + type_ = self._record_matching_type(service) + if type_: + self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) + break + + return + + type_ = self._record_matching_type(record) + if type_: + self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + """Callback invoked by Zeroconf when new information arrives. + + Updates information required by browser in the Zeroconf cache. + + Ensures that there is are no unecessary duplicates in the list. + + This method will be run in the event loop. + """ + for record in records: + self._async_process_record_update(now, record) + + def async_update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + + This method will be run in the event loop. + """ + # Cannot use .update here since can fail with + # RuntimeError: dictionary changed size during iteration + # for threaded ServiceBrowsers + while self._pending_handlers: + try: + (name_type, state_change) = self._pending_handlers.popitem(False) + except KeyError: + return + self._handlers_to_call[name_type] = state_change + + def cancel(self) -> None: + """Cancel the browser.""" + self.done = True + self.zc.remove_listener(self) + + def generate_ready_queries(self) -> List[DNSOutgoing]: + """Generate the service browser query for any type that is due.""" + now = current_time_millis() + + if min(self._next_time.values()) > now: + return [] + + ready_types = [] + + for type_, due in self._next_time.items(): + if due > now: + continue + + ready_types.append(type_) + self._next_time[type_] = now + self._delay[type_] + self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + + return generate_service_query(self.zc, now, ready_types, self.multicast) + + def _seconds_to_wait(self) -> Optional[float]: + """Returns the number of seconds to wait for the next event.""" + # If there are handlers to call + # we want to process them right away + if self._handlers_to_call: + return None + + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + now = current_time_millis() + + if next_time <= now: + return None + + return millis_to_seconds(next_time - now) + + +class ServiceBrowser(_ServiceBrowserBase, threading.Thread): + """Used to browse for a service of a specific type. + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability.""" + + def __init__( + self, + zc: 'Zeroconf', + type_: Union[str, list], + handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, + listener: Optional['ServiceListener'] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + delay: int = _BROWSER_TIME, + ) -> None: + threading.Thread.__init__(self) + super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + self.daemon = True + self.start() + self.name = "zeroconf-ServiceBrowser-%s-%s" % ( + '-'.join([type_[:-7] for type_ in self.types]), + getattr(self, 'native_id', self.ident), + ) + + def cancel(self) -> None: + """Cancel the browser.""" + super().cancel() + self.join() + + def run(self) -> None: + """Run the browser thread.""" + while True: + timeout = self._seconds_to_wait() + if timeout: + with self.zc.condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + self.zc.condition.wait(timeout) + + if self.zc.done or self.done: + return + + outs = self.generate_ready_queries() + for out in outs: + self.zc.send(out, addr=self.addr, port=self.port) + + if not self._handlers_to_call: + continue + + (name_type, state_change) = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) diff --git a/zeroconf/_services/types.py b/zeroconf/_services/types.py index f611fc4c3..34b000f19 100644 --- a/zeroconf/_services/types.py +++ b/zeroconf/_services/types.py @@ -23,8 +23,9 @@ import time from typing import Optional, Set, Tuple, Union +from .browser import ServiceBrowser from .._core import Zeroconf -from .._services import ServiceBrowser, ServiceListener +from .._services import ServiceListener from .._utils.net import IPVersion, InterfaceChoice, InterfacesType from ..const import _SERVICE_TYPE_ENUMERATION_NAME diff --git a/zeroconf/aio.py b/zeroconf/aio.py index e64c87c38..00d428233 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,7 +26,7 @@ from ._core import NotifyListener, Zeroconf from ._exceptions import NonUniqueNameException -from ._services import _ServiceBrowserBase +from ._services.browser import _ServiceBrowserBase from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.types import ZeroconfServiceTypes from ._utils.aio import wait_condition_or_timeout From 869c95a51e228131eb7debe1acc47c105b9bf7b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 22:04:40 -1000 Subject: [PATCH 0438/1433] Relocate service browser tests to tests/services/test_browser.py (#745) --- tests/services/test_browser.py | 776 +++++++++++++++++++++++++++++++++ tests/test_services.py | 681 +---------------------------- 2 files changed, 777 insertions(+), 680 deletions(-) create mode 100644 tests/services/test_browser.py diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py new file mode 100644 index 000000000..ccdb312f0 --- /dev/null +++ b/tests/services/test_browser.py @@ -0,0 +1,776 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._services.browser. """ + +import logging +import socket +import time +import os +import unittest +from threading import Event + +import pytest + +import zeroconf as r +from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis +import zeroconf._services.browser as _services_browser +from zeroconf import Zeroconf +from zeroconf._services import ServiceStateChange +from zeroconf._services.browser import ServiceBrowser +from zeroconf._services.info import ServiceInfo + +from .. import has_working_ipv6, _inject_response + + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestServiceBrowser(unittest.TestCase): + def test_update_record(self): + enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + service_v6_address = "2001:db8::1" + service_v6_second_address = "6001:db8::1" + + service_added_count = 0 + service_removed_count = 0 + service_updated_count = 0 + service_add_event = Event() + service_removed_event = Event() + service_updated_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added_count + service_added_count += 1 + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_removed_count + service_removed_count += 1 + service_removed_event.set() + + def update_service(self, zc, type_, name) -> None: + nonlocal service_updated_count + service_updated_count += 1 + service_info = zc.get_service_info(type_, name) + assert socket.inet_aton(service_address) in service_info.addresses + if enable_ipv6: + assert socket.inet_pton( + socket.AF_INET6, service_v6_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) + assert socket.inet_pton( + socket.AF_INET6, service_v6_second_address + ) in service_info.addresses_by_version(r.IPVersion.V6Only) + assert service_info.text == service_text + assert service_info.server == service_server + service_updated_event.set() + + def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + assert generated.is_response() is True + + if service_state_change == r.ServiceStateChange.Removed: + ttl = 0 + else: + ttl = 120 + + generated.add_answer_at_time( + r.DNSText( + service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + ), + 0, + ) + + generated.add_answer_at_time( + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + 0, + ) + + # Send the IPv6 address first since we previously + # had a bug where the IPv4 would be missing if the + # IPv6 was seen first + if enable_ipv6: + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_v6_second_address), + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_aton(service_address), + ), + 0, + ) + + generated.add_answer_at_time( + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + ) + + return r.DNSIncoming(generated.packets()[0]) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) + + try: + wait_time = 3 + + # service added + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) + service_add_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 0 + assert service_removed_count == 0 + + # service SRV updated + service_updated_event.clear() + service_server = 'ash-2.local.' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 1 + assert service_removed_count == 0 + + # service TXT updated + service_updated_event.clear() + service_text = b'path=/~matt2/' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 2 + assert service_removed_count == 0 + + # service TXT updated - duplicate update should not trigger another service_updated + service_updated_event.clear() + service_text = b'path=/~matt2/' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 2 + assert service_removed_count == 0 + + # service A updated + service_updated_event.clear() + service_address = '10.0.1.3' + # Verify we match on uppercase + service_server = service_server.upper() + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 3 + assert service_removed_count == 0 + + # service all updated + service_updated_event.clear() + service_server = 'ash-3.local.' + service_text = b'path=/~matt3/' + service_address = '10.0.1.3' + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + service_updated_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 0 + + # service removed + _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) + service_removed_event.wait(wait_time) + assert service_added_count == 1 + assert service_updated_count == 4 + assert service_removed_count == 1 + + finally: + assert len(zeroconf.listeners) == 1 + service_browser.cancel() + assert len(zeroconf.listeners) == 0 + zeroconf.remove_all_service_listeners() + zeroconf.close() + + +class TestServiceBrowserMultipleTypes(unittest.TestCase): + def test_update_record(self): + + service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] + service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] + + service_added_count = 0 + service_removed_count = 0 + service_add_event = Event() + service_removed_event = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal service_added_count + service_added_count += 1 + if service_added_count == 3: + service_add_event.set() + + def remove_service(self, zc, type_, name) -> None: + nonlocal service_removed_count + service_removed_count += 1 + if service_removed_count == 3: + service_removed_event.set() + + def mock_incoming_msg( + service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int + ) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + ) + return r.DNSIncoming(generated.packets()[0]) + + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) + + try: + wait_time = 3 + + # all three services added + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), + ) + zeroconf.wait(100) + + called_with_refresh_time_check = False + + def _mock_get_expiration_time(self, percent): + nonlocal called_with_refresh_time_check + if percent == const._EXPIRE_REFRESH_TIME_PERCENT: + called_with_refresh_time_check = True + return 0 + return self.created + (percent * self.ttl * 10) + + # Set an expire time that will force a refresh + with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + ) + # Add the last record after updating the first one + # to ensure the service_add_event only gets set + # after the update + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), + ) + service_add_event.wait(wait_time) + assert called_with_refresh_time_check is True + assert service_added_count == 3 + assert service_removed_count == 0 + + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Updated, service_types[0], service_names[0], 0), + ) + + # all three services removed + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), + ) + _inject_response( + zeroconf, + mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), + ) + service_removed_event.wait(wait_time) + assert service_added_count == 3 + assert service_removed_count == 3 + + finally: + assert len(zeroconf.listeners) == 1 + service_browser.cancel() + assert len(zeroconf.listeners) == 0 + zeroconf.remove_all_service_listeners() + zeroconf.close() + + +def test_backoff(): + got_query = Event() + + type_ = "_http._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.send + + time_offset = 0.0 + start_time = time.time() * 1000 + initial_query_interval = _services_browser._BROWSER_TIME / 1000 + + def current_time_millis(): + """Current system time in milliseconds""" + return start_time + time_offset * 1000 + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + got_query.set() + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + # patch the zeroconf current_time_millis + # patch the backoff limit to prevent test running forever + with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + _services_browser, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + try: + # Test that queries are sent at increasing intervals + sleep_count = 0 + next_query_interval = 0.0 + expected_query_time = 0.0 + while True: + sleep_count += 1 + for _ in range(2): + # If the browser thread is starting up + # its possible we notify before the initial sleep + # which means the test will fail so we need to d + # this twice to eliminate the race condition + zeroconf_browser.notify_all() + got_query.wait(0.05) + if time_offset == expected_query_time: + assert got_query.is_set() + got_query.clear() + if next_query_interval == _services_browser._BROWSER_BACKOFF_LIMIT: + # Only need to test up to the point where we've seen a query + # after the backoff limit has been hit + break + elif next_query_interval == 0: + next_query_interval = initial_query_interval + expected_query_time = initial_query_interval + else: + next_query_interval = min( + 2 * next_query_interval, _services_browser._BROWSER_BACKOFF_LIMIT + ) + expected_query_time += next_query_interval + else: + assert not got_query.is_set() + time_offset += initial_query_interval + + finally: + browser.cancel() + zeroconf_browser.close() + + +def test_integration(): + service_added = Event() + service_removed = Event() + unexpected_ttl = Event() + got_query = Event() + + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + elif state_change is ServiceStateChange.Removed: + service_removed.set() + + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check packet sizes + old_send = zeroconf_browser.send + + time_offset = 0.0 + + def current_time_millis(): + """Current system time in milliseconds""" + return time.time() * 1000 + time_offset * 1000 + + expected_ttl = const._DNS_HOST_TTL + + nbr_answers = 0 + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + nonlocal nbr_answers + for answer in pout.answers: + nbr_answers += 1 + if not answer.ttl > expected_ttl / 2: + unexpected_ttl.set() + + got_query.set() + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + # patch the zeroconf current_time_millis + # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL + with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + _services_browser, "current_time_millis", current_time_millis + ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + service_added = Event() + service_removed = Event() + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zeroconf_registrar.register_service(info) + + try: + service_added.wait(1) + assert service_added.is_set() + + # Test that we receive queries containing answers only if the remaining TTL + # is greater than half the original TTL + sleep_count = 0 + test_iterations = 50 + while nbr_answers < test_iterations: + # Increase simulated time shift by 1/4 of the TTL in seconds + time_offset += expected_ttl / 4 + zeroconf_browser.notify_all() + sleep_count += 1 + got_query.wait(0.1) + got_query.clear() + # Prevent the test running indefinitely in an error condition + assert sleep_count < test_iterations * 4 + assert not unexpected_ttl.is_set() + + # Don't remove service, allow close() to cleanup + + finally: + zeroconf_registrar.close() + service_removed.wait(1) + assert service_removed.is_set() + browser.cancel() + zeroconf_browser.close() + + +def test_legacy_record_update_listener(): + """Test a RecordUpdateListener that does not implement update_records.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + with pytest.raises(RuntimeError): + r.RecordUpdateListener().update_record( + zc, 0, r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) + ) + + updates = [] + + class LegacyRecordUpdateListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: + nonlocal updates + updates.append(record) + + listener = LegacyRecordUpdateListener() + + zc.add_listener(listener, None) + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + zc.register_service(info_service) + + zc.wait(1) + + browser.cancel() + + assert len(updates) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + + zc.remove_listener(listener) + # Removing a second time should not throw + zc.remove_listener(listener) + + zc.close() + + +def test_service_browser_is_aware_of_port_changes(): + """Test that the ServiceBrowser is aware of port changes.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + callbacks = [] + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + nonlocal callbacks + if name == registration_name: + callbacks.append((service_type, state_change, name)) + + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.1) + + assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] + assert zc.get_service_info(type_, registration_name).port == 80 + + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + time.sleep(0.1) + + assert callbacks == [ + ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), + ('_hap._tcp.local.', ServiceStateChange.Updated, 'xxxyyy._hap._tcp.local.'), + ] + assert zc.get_service_info(type_, registration_name).port == 400 + browser.cancel() + + zc.close() + + +def test_service_browser_listeners_update_service(): + """Test that the ServiceBrowser ServiceListener that implements update_service.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + time.sleep(0.2) + + assert callbacks == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ] + browser.cancel() + + zc.close() + + +def test_service_browser_listeners_no_update_service(): + """Test that the ServiceBrowser ServiceListener that does not implement update_service.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener: + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + time.sleep(0.2) + + assert callbacks == [ + ('add', type_, registration_name), + ] + browser.cancel() + + zc.close() + + +def test_servicebrowser_uses_non_strict_names(): + """Verify we can look for technically invalid names as we cannot change what others do.""" + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + browser = ServiceBrowser(zc, ["_tivo-videostream._tcp.local."], [on_service_state_change]) + browser.cancel() + + # Still fail on completely invalid + with pytest.raises(r.BadTypeInNameException): + browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) + zc.close() + + +def test_group_ptr_queries_with_known_answers(): + questions_with_known_answers: _services_browser._QuestionWithKnownAnswers = {} + now = current_time_millis() + for i in range(120): + name = f"_hap{i}._tcp._local." + questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = set( + DNSPointer( + name, + const._TYPE_PTR, + const._CLASS_IN, + 4500, + f"zoo{counter}.{name}", + ) + for counter in range(i) + ) + outs = _services_browser._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) + for out in outs: + packets = out.packets() + # If we generate multiple packets there must + # only be one question + assert len(packets) == 1 or len(out.questions) == 1 diff --git a/tests/test_services.py b/tests/test_services.py index 88b490fb6..a22d6f6bb 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -16,10 +16,8 @@ import pytest import zeroconf as r -from zeroconf import DNSAddress, DNSPointer, DNSQuestion, const, current_time_millis -import zeroconf._services.browser as _services_browser +from zeroconf import DNSAddress, const from zeroconf import Zeroconf -from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo from zeroconf.aio import AsyncZeroconf @@ -436,113 +434,6 @@ def get_service_info_helper(zc, type, name): zc.close() -class TestServiceBrowserMultipleTypes(unittest.TestCase): - def test_update_record(self): - - service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] - service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] - - service_added_count = 0 - service_removed_count = 0 - service_add_event = Event() - service_removed_event = Event() - - class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: - nonlocal service_added_count - service_added_count += 1 - if service_added_count == 3: - service_add_event.set() - - def remove_service(self, zc, type_, name) -> None: - nonlocal service_removed_count - service_removed_count += 1 - if service_removed_count == 3: - service_removed_event.set() - - def mock_incoming_msg( - service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int - ) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 - ) - return r.DNSIncoming(generated.packets()[0]) - - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) - - try: - wait_time = 3 - - # all three services added - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), - ) - zeroconf.wait(100) - - called_with_refresh_time_check = False - - def _mock_get_expiration_time(self, percent): - nonlocal called_with_refresh_time_check - if percent == const._EXPIRE_REFRESH_TIME_PERCENT: - called_with_refresh_time_check = True - return 0 - return self.created + (percent * self.ttl * 10) - - # Set an expire time that will force a refresh - with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), - ) - # Add the last record after updating the first one - # to ensure the service_add_event only gets set - # after the update - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), - ) - service_add_event.wait(wait_time) - assert called_with_refresh_time_check is True - assert service_added_count == 3 - assert service_removed_count == 0 - - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Updated, service_types[0], service_names[0], 0), - ) - - # all three services removed - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), - ) - _inject_response( - zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), - ) - service_removed_event.wait(wait_time) - assert service_added_count == 3 - assert service_removed_count == 3 - - finally: - assert len(zeroconf.listeners) == 1 - service_browser.cancel() - assert len(zeroconf.listeners) == 0 - zeroconf.remove_all_service_listeners() - zeroconf.close() - - class ListenerTest(unittest.TestCase): def test_integration_with_listener_class(self): @@ -699,201 +590,6 @@ def update_service(self, zeroconf, type, name): zeroconf_browser.close() -class TestServiceBrowser(unittest.TestCase): - def test_update_record(self): - enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - service_v6_address = "2001:db8::1" - service_v6_second_address = "6001:db8::1" - - service_added_count = 0 - service_removed_count = 0 - service_updated_count = 0 - service_add_event = Event() - service_removed_event = Event() - service_updated_event = Event() - - class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: - nonlocal service_added_count - service_added_count += 1 - service_add_event.set() - - def remove_service(self, zc, type_, name) -> None: - nonlocal service_removed_count - service_removed_count += 1 - service_removed_event.set() - - def update_service(self, zc, type_, name) -> None: - nonlocal service_updated_count - service_updated_count += 1 - service_info = zc.get_service_info(type_, name) - assert socket.inet_aton(service_address) in service_info.addresses - if enable_ipv6: - assert socket.inet_pton( - socket.AF_INET6, service_v6_address - ) in service_info.addresses_by_version(r.IPVersion.V6Only) - assert socket.inet_pton( - socket.AF_INET6, service_v6_second_address - ) in service_info.addresses_by_version(r.IPVersion.V6Only) - assert service_info.text == service_text - assert service_info.server == service_server - service_updated_event.set() - - def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - assert generated.is_response() is True - - if service_state_change == r.ServiceStateChange.Removed: - ttl = 0 - else: - ttl = 120 - - generated.add_answer_at_time( - r.DNSText( - service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text - ), - 0, - ) - - generated.add_answer_at_time( - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - 0, - ) - - # Send the IPv6 address first since we previously - # had a bug where the IPv4 would be missing if the - # IPv6 was seen first - if enable_ipv6: - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_AAAA, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET6, service_v6_address), - ), - 0, - ) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_AAAA, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET6, service_v6_second_address), - ), - 0, - ) - generated.add_answer_at_time( - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_aton(service_address), - ), - 0, - ) - - generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 - ) - - return r.DNSIncoming(generated.packets()[0]) - - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) - - try: - wait_time = 3 - - # service added - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - service_add_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 0 - assert service_removed_count == 0 - - # service SRV updated - service_updated_event.clear() - service_server = 'ash-2.local.' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 1 - assert service_removed_count == 0 - - # service TXT updated - service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 2 - assert service_removed_count == 0 - - # service TXT updated - duplicate update should not trigger another service_updated - service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 2 - assert service_removed_count == 0 - - # service A updated - service_updated_event.clear() - service_address = '10.0.1.3' - # Verify we match on uppercase - service_server = service_server.upper() - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 3 - assert service_removed_count == 0 - - # service all updated - service_updated_event.clear() - service_server = 'ash-3.local.' - service_text = b'path=/~matt3/' - service_address = '10.0.1.3' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 4 - assert service_removed_count == 0 - - # service removed - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - service_removed_event.wait(wait_time) - assert service_added_count == 1 - assert service_updated_count == 4 - assert service_removed_count == 1 - - finally: - assert len(zeroconf.listeners) == 1 - service_browser.cancel() - assert len(zeroconf.listeners) == 0 - zeroconf.remove_all_service_listeners() - zeroconf.close() - - def test_multiple_addresses(): type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -974,168 +670,6 @@ async def test_multiple_a_addresses(): await aiozc.async_close() -def test_backoff(): - got_query = Event() - - type_ = "_http._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - - # we are going to patch the zeroconf send to check query transmission - old_send = zeroconf_browser.send - - time_offset = 0.0 - start_time = time.time() * 1000 - initial_query_interval = _services_browser._BROWSER_TIME / 1000 - - def current_time_millis(): - """Current system time in milliseconds""" - return start_time + time_offset * 1000 - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): - """Sends an outgoing packet.""" - got_query.set() - old_send(out, addr=addr, port=port) - - # patch the zeroconf send - # patch the zeroconf current_time_millis - # patch the backoff limit to prevent test running forever - with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( - _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - try: - # Test that queries are sent at increasing intervals - sleep_count = 0 - next_query_interval = 0.0 - expected_query_time = 0.0 - while True: - sleep_count += 1 - for _ in range(2): - # If the browser thread is starting up - # its possible we notify before the initial sleep - # which means the test will fail so we need to d - # this twice to eliminate the race condition - zeroconf_browser.notify_all() - got_query.wait(0.05) - if time_offset == expected_query_time: - assert got_query.is_set() - got_query.clear() - if next_query_interval == _services_browser._BROWSER_BACKOFF_LIMIT: - # Only need to test up to the point where we've seen a query - # after the backoff limit has been hit - break - elif next_query_interval == 0: - next_query_interval = initial_query_interval - expected_query_time = initial_query_interval - else: - next_query_interval = min( - 2 * next_query_interval, _services_browser._BROWSER_BACKOFF_LIMIT - ) - expected_query_time += next_query_interval - else: - assert not got_query.is_set() - time_offset += initial_query_interval - - finally: - browser.cancel() - zeroconf_browser.close() - - -def test_integration(): - service_added = Event() - service_removed = Event() - unexpected_ttl = Event() - got_query = Event() - - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ - - def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() - elif state_change is ServiceStateChange.Removed: - service_removed.set() - - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - - # we are going to patch the zeroconf send to check packet sizes - old_send = zeroconf_browser.send - - time_offset = 0.0 - - def current_time_millis(): - """Current system time in milliseconds""" - return time.time() * 1000 + time_offset * 1000 - - expected_ttl = const._DNS_HOST_TTL - - nbr_answers = 0 - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): - """Sends an outgoing packet.""" - pout = r.DNSIncoming(out.packets()[0]) - nonlocal nbr_answers - for answer in pout.answers: - nbr_answers += 1 - if not answer.ttl > expected_ttl / 2: - unexpected_ttl.set() - - got_query.set() - old_send(out, addr=addr, port=port) - - # patch the zeroconf send - # patch the zeroconf current_time_millis - # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( - _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): - service_added = Event() - service_removed = Event() - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - zeroconf_registrar.register_service(info) - - try: - service_added.wait(1) - assert service_added.is_set() - - # Test that we receive queries containing answers only if the remaining TTL - # is greater than half the original TTL - sleep_count = 0 - test_iterations = 50 - while nbr_answers < test_iterations: - # Increase simulated time shift by 1/4 of the TTL in seconds - time_offset += expected_ttl / 4 - zeroconf_browser.notify_all() - sleep_count += 1 - got_query.wait(0.1) - got_query.clear() - # Prevent the test running indefinitely in an error condition - assert sleep_count < test_iterations * 4 - assert not unexpected_ttl.is_set() - - # Don't remove service, allow close() to cleanup - - finally: - zeroconf_registrar.close() - service_removed.wait(1) - assert service_removed.is_set() - browser.cancel() - zeroconf_browser.close() - - def test_legacy_record_update_listener(): """Test a RecordUpdateListener that does not implement update_records.""" @@ -1215,179 +749,6 @@ def dns_addresses_to_addresses(dns_address: List[DNSAddress]): assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] -def test_service_browser_is_aware_of_port_changes(): - """Test that the ServiceBrowser is aware of port changes.""" - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - # start a browser - type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ - - callbacks = [] - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - nonlocal callbacks - if name == registration_name: - callbacks.append((service_type, state_change, name)) - - browser = ServiceBrowser(zc, type_, [on_service_state_change]) - - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" - address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - - def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - - _inject_response( - zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), - ) - time.sleep(0.1) - - assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] - assert zc.get_service_info(type_, registration_name).port == 80 - - info.port = 400 - _inject_response( - zc, - mock_incoming_msg([info.dns_service()]), - ) - time.sleep(0.1) - - assert callbacks == [ - ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), - ('_hap._tcp.local.', ServiceStateChange.Updated, 'xxxyyy._hap._tcp.local.'), - ] - assert zc.get_service_info(type_, registration_name).port == 400 - browser.cancel() - - zc.close() - - -def test_service_browser_listeners_update_service(): - """Test that the ServiceBrowser ServiceListener that implements update_service.""" - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - # start a browser - type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ - callbacks = [] - - class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: - nonlocal callbacks - if name == registration_name: - callbacks.append(("add", type_, name)) - - def remove_service(self, zc, type_, name) -> None: - nonlocal callbacks - if name == registration_name: - callbacks.append(("remove", type_, name)) - - def update_service(self, zc, type_, name) -> None: - nonlocal callbacks - if name == registration_name: - callbacks.append(("update", type_, name)) - - listener = MyServiceListener() - - browser = r.ServiceBrowser(zc, type_, None, listener) - - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" - address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - - def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - - _inject_response( - zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), - ) - time.sleep(0.2) - info.port = 400 - _inject_response( - zc, - mock_incoming_msg([info.dns_service()]), - ) - time.sleep(0.2) - - assert callbacks == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ] - browser.cancel() - - zc.close() - - -def test_service_browser_listeners_no_update_service(): - """Test that the ServiceBrowser ServiceListener that does not implement update_service.""" - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - # start a browser - type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ - callbacks = [] - - class MyServiceListener: - def add_service(self, zc, type_, name) -> None: - nonlocal callbacks - if name == registration_name: - callbacks.append(("add", type_, name)) - - def remove_service(self, zc, type_, name) -> None: - nonlocal callbacks - if name == registration_name: - callbacks.append(("remove", type_, name)) - - listener = MyServiceListener() - - browser = r.ServiceBrowser(zc, type_, None, listener) - - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" - address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - - def mock_incoming_msg(records) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - - _inject_response( - zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), - ) - time.sleep(0.2) - info.port = 400 - _inject_response( - zc, - mock_incoming_msg([info.dns_service()]), - ) - time.sleep(0.2) - - assert callbacks == [ - ('add', type_, registration_name), - ] - browser.cancel() - - zc.close() - - def test_changing_name_updates_serviceinfo_key(): """Verify a name change will adjust the underlying key value.""" type_ = "_homeassistant._tcp.local." @@ -1407,23 +768,6 @@ def test_changing_name_updates_serviceinfo_key(): assert info_service.key == "yourtesthome._homeassistant._tcp.local." -def test_servicebrowser_uses_non_strict_names(): - """Verify we can look for technically invalid names as we cannot change what others do.""" - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - browser = ServiceBrowser(zc, ["_tivo-videostream._tcp.local."], [on_service_state_change]) - browser.cancel() - - # Still fail on completely invalid - with pytest.raises(r.BadTypeInNameException): - browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) - zc.close() - - def test_servicelisteners_raise_not_implemented(): """Verify service listeners raise when one of the methods is not implemented.""" @@ -1524,26 +868,3 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): addresses=addresses, ) assert info_service.dns_text().text == b'\x0epath=/~paulsm/' - - -def test_group_ptr_queries_with_known_answers(): - questions_with_known_answers: _services_browser._QuestionWithKnownAnswers = {} - now = current_time_millis() - for i in range(120): - name = f"_hap{i}._tcp._local." - questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = set( - DNSPointer( - name, - const._TYPE_PTR, - const._CLASS_IN, - 4500, - f"zoo{counter}.{name}", - ) - for counter in range(i) - ) - outs = _services_browser._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) - for out in outs: - packets = out.packets() - # If we generate multiple packets there must - # only be one question - assert len(packets) == 1 or len(out.questions) == 1 From 541292e55fee8bbafe687afcb8d152f6fe0efb5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 22:14:54 -1000 Subject: [PATCH 0439/1433] Relocate service info tests to tests/services/test_info.py (#746) --- tests/services/test_info.py | 627 ++++++++++++++++++++++++++++++++++++ tests/test_services.py | 597 +--------------------------------- 2 files changed, 629 insertions(+), 595 deletions(-) create mode 100644 tests/services/test_info.py diff --git a/tests/services/test_info.py b/tests/services/test_info.py new file mode 100644 index 000000000..8f654a707 --- /dev/null +++ b/tests/services/test_info.py @@ -0,0 +1,627 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._services.info. """ + +import logging +import socket +import threading +import os +import unittest +from threading import Event +from typing import List + +import pytest + +import zeroconf as r +from zeroconf import DNSAddress, const +from zeroconf._services.info import ServiceInfo +from zeroconf.aio import AsyncZeroconf + +from .. import has_working_ipv6, _inject_response + + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +class TestServiceInfo(unittest.TestCase): + def test_get_name(self): + """Verify the name accessor can strip the type.""" + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + assert info.get_name() == "name" + + def test_service_info_rejects_non_matching_updates(self): + """Verify records with the wrong name are rejected.""" + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Verify backwards compatiblity with calling with None + info.update_record(zc, now, None) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.3") + info.update_record( + zc, + now, + r.DNSAddress( + 'ASH-2.local.', + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address in info.addresses + # Non-matching updates + info.update_record( + zc, + now, + r.DNSText( + "incorrect.name.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + info.update_record( + zc, + now, + r.DNSService( + "incorrect.name.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + ) + assert info.server_key == 'ash-2.local.' + assert info.server == 'ASH-2.local.' + new_address = socket.inet_aton("10.0.1.4") + info.update_record( + zc, + now, + r.DNSAddress( + "incorrect.name.", + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + new_address, + ), + ) + assert new_address not in info.addresses + zc.close() + + def test_service_info_rejects_expired_records(self): + """Verify records that are expired are rejected.""" + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + # Matching updates + info.update_record( + zc, + now, + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + ) + assert info.properties[b"ci"] == b"2" + # Expired record + expired_record = r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ) + expired_record.created = 1000 + expired_record._expiration_time = 1000 + info.update_record(zc, now, expired_record) + assert info.properties[b"ci"] == b"2" + zc.close() + + def test_get_info_partial(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # patch the zeroconf send + with unittest.mock.patch.object(zc, "send", send): + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packets()[0]) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for SRV, A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 3 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext query for A, AAAA + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 2 + assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + last_sent = None + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ) + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + def test_get_info_single(self): + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_text = b'path=/~matt1/' + service_address = '10.0.1.2' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent = None # type: Optional[r.DNSOutgoing] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # patch the zeroconf send + with unittest.mock.patch.object(zc, "send", send): + + def mock_incoming_msg(records) -> r.DNSIncoming: + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packets()[0]) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + ttl = 120 + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = 1 + + # Expext query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expext no further queries + last_sent = None + send_event.clear() + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ), + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ), + ] + ), + ) + send_event.wait(wait_time) + assert last_sent is None + assert service_info is not None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + + +def test_multiple_addresses(): + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) + + assert info.addresses == [address, address] + + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_parsed], + ) + assert info.addresses == [address, address] + + if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): + address_v6_parsed = "2001:db8::1" + address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) + infos = [ + ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[address, address_v6], + ), + ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + parsed_addresses=[address_parsed, address_v6_parsed], + ), + ] + for info in infos: + assert info.addresses == [address] + assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.V4Only) == [address] + assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] + assert info.parsed_addresses() == [address_parsed, address_v6_parsed] + assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_multiple_a_addresses(): + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + cache = aiozc.zeroconf.cache + host = "multahost.local." + record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'a') + record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'b') + cache.async_add_records([record1, record2]) + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + info.load_from_cache(aiozc.zeroconf) + assert set(info.addresses) == set([b'a', b'b']) + await aiozc.async_close() + + +def test_filter_address_by_type_from_service_info(): + """Verify dns_addresses can filter by ipversion.""" + desc = {'path': '/~paulsm/'} + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + registration_name = "%s.%s" % (name, type_) + ipv4 = socket.inet_aton("10.0.1.2") + ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) + + def dns_addresses_to_addresses(dns_address: List[DNSAddress]): + return [address.address for address in dns_address] + + assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ipv4, ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] + + +def test_changing_name_updates_serviceinfo_key(): + """Verify a name change will adjust the underlying key value.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + assert info_service.key == "mytesthome._homeassistant._tcp.local." + info_service.name = "YourTestHome._homeassistant._tcp.local." + assert info_service.key == "yourtesthome._homeassistant._tcp.local." + + +def test_serviceinfo_address_updates(): + """Verify adding/removing/setting addresses on ServiceInfo.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + + # Verify addresses and parsed_addresses are mutually exclusive + with pytest.raises(TypeError): + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + parsed_addresses=["10.0.1.2"], + ) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info_service.addresses = [socket.inet_aton("10.0.1.3")] + assert info_service.addresses == [socket.inet_aton("10.0.1.3")] + + +def test_serviceinfo_accepts_bytes_or_string_dict(): + """Verify a bytes or string dict can be passed to ServiceInfo.""" + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + addresses = [socket.inet_aton("10.0.1.2")] + server_name = "ash-2.local." + info_service = ServiceInfo( + type_, '%s.%s' % (name, type_), 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {b'path': '/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': b'/~paulsm/'}, + server_name, + addresses=addresses, + ) + assert info_service.dns_text().text == b'\x0epath=/~paulsm/' diff --git a/tests/test_services.py b/tests/test_services.py index a22d6f6bb..1a3ada236 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,23 +6,20 @@ import logging import socket -import threading import time import os import unittest from threading import Event -from typing import List import pytest import zeroconf as r -from zeroconf import DNSAddress, const +from zeroconf import const from zeroconf import Zeroconf from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo -from zeroconf.aio import AsyncZeroconf -from . import has_working_ipv6, _clear_cache, _inject_response +from . import has_working_ipv6, _clear_cache log = logging.getLogger('zeroconf') @@ -40,400 +37,6 @@ def teardown_module(): log.setLevel(original_logging_level) -class TestServiceInfo(unittest.TestCase): - def test_get_name(self): - """Verify the name accessor can strip the type.""" - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - assert info.get_name() == "name" - - def test_service_info_rejects_non_matching_updates(self): - """Verify records with the wrong name are rejected.""" - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - ttl = 120 - now = r.current_time_millis() - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - # Verify backwards compatiblity with calling with None - info.update_record(zc, now, None) - # Matching updates - info.update_record( - zc, - now, - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - info.update_record( - zc, - now, - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), - ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' - new_address = socket.inet_aton("10.0.1.3") - info.update_record( - zc, - now, - r.DNSAddress( - 'ASH-2.local.', - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - new_address, - ), - ) - assert new_address in info.addresses - # Non-matching updates - info.update_record( - zc, - now, - r.DNSText( - "incorrect.name.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - info.update_record( - zc, - now, - r.DNSService( - "incorrect.name.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), - ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' - new_address = socket.inet_aton("10.0.1.4") - info.update_record( - zc, - now, - r.DNSAddress( - "incorrect.name.", - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - new_address, - ), - ) - assert new_address not in info.addresses - zc.close() - - def test_service_info_rejects_expired_records(self): - """Verify records that are expired are rejected.""" - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_address = socket.inet_aton("10.0.1.2") - ttl = 120 - now = r.current_time_millis() - info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] - ) - # Matching updates - info.update_record( - zc, - now, - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), - ) - assert info.properties[b"ci"] == b"2" - # Expired record - expired_record = r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', - ) - expired_record.created = 1000 - expired_record._expiration_time = 1000 - info.update_record(zc, now, expired_record) - assert info.properties[b"ci"] == b"2" - zc.close() - - def test_get_info_partial(self): - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - - service_info = None - send_event = Event() - service_info_event = Event() - - last_sent = None # type: Optional[r.DNSOutgoing] - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): - """Sends an outgoing packet.""" - nonlocal last_sent - - last_sent = out - send_event.set() - - # patch the zeroconf send - with unittest.mock.patch.object(zc, "send", send): - - def mock_incoming_msg(records) -> r.DNSIncoming: - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packets()[0]) - - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() - - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for SRV, A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - service_text, - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 3 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext query for A, AAAA - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 2 - assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - last_sent = None - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ) - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None - - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() - - def test_get_info_single(self): - - zc = r.Zeroconf(interfaces=['127.0.0.1']) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - - service_info = None - send_event = Event() - service_info_event = Event() - - last_sent = None # type: Optional[r.DNSOutgoing] - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): - """Sends an outgoing packet.""" - nonlocal last_sent - - last_sent = out - send_event.set() - - # patch the zeroconf send - with unittest.mock.patch.object(zc, "send", send): - - def mock_incoming_msg(records) -> r.DNSIncoming: - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packets()[0]) - - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() - - try: - ttl = 120 - helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) - ) - helper_thread.start() - wait_time = 1 - - # Expext query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - - # Expext no further queries - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - service_text, - ), - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ), - ] - ), - ) - send_event.wait(wait_time) - assert last_sent is None - assert service_info is not None - - finally: - helper_thread.join() - zc.remove_all_service_listeners() - zc.close() - - class ListenerTest(unittest.TestCase): def test_integration_with_listener_class(self): @@ -590,86 +193,6 @@ def update_service(self, zeroconf, type, name): zeroconf_browser.close() -def test_multiple_addresses(): - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" - address = socket.inet_aton(address_parsed) - - # New kwarg way - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) - - assert info.addresses == [address, address] - - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - parsed_addresses=[address_parsed, address_parsed], - ) - assert info.addresses == [address, address] - - if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): - address_v6_parsed = "2001:db8::1" - address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) - infos = [ - ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[address, address_v6], - ), - ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - parsed_addresses=[address_parsed, address_v6_parsed], - ), - ] - for info in infos: - assert info.addresses == [address] - assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] - assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] - assert info.parsed_addresses() == [address_parsed, address_v6_parsed] - assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] - - -# This test uses asyncio because it needs to access the cache directly -# which is not threadsafe -@pytest.mark.asyncio -async def test_multiple_a_addresses(): - type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - cache = aiozc.zeroconf.cache - host = "multahost.local." - record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'a') - record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'b') - cache.async_add_records([record1, record2]) - - # New kwarg way - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) - info.load_from_cache(aiozc.zeroconf) - assert set(info.addresses) == set([b'a', b'b']) - await aiozc.async_close() - - def test_legacy_record_update_listener(): """Test a RecordUpdateListener that does not implement update_records.""" @@ -730,44 +253,6 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.close() -def test_filter_address_by_type_from_service_info(): - """Verify dns_addresses can filter by ipversion.""" - desc = {'path': '/~paulsm/'} - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - registration_name = "%s.%s" % (name, type_) - ipv4 = socket.inet_aton("10.0.1.2") - ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) - - def dns_addresses_to_addresses(dns_address: List[DNSAddress]): - return [address.address for address in dns_address] - - assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ipv4, ipv6] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] - - -def test_changing_name_updates_serviceinfo_key(): - """Verify a name change will adjust the underlying key value.""" - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - assert info_service.key == "mytesthome._homeassistant._tcp.local." - info_service.name = "YourTestHome._homeassistant._tcp.local." - assert info_service.key == "yourtesthome._homeassistant._tcp.local." - - def test_servicelisteners_raise_not_implemented(): """Verify service listeners raise when one of the methods is not implemented.""" @@ -790,81 +275,3 @@ class MyPartialListener(r.ServiceListener): ) zc.close() - - -def test_serviceinfo_address_updates(): - """Verify adding/removing/setting addresses on ServiceInfo.""" - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - - # Verify addresses and parsed_addresses are mutually exclusive - with pytest.raises(TypeError): - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - parsed_addresses=["10.0.1.2"], - ) - - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - info_service.addresses = [socket.inet_aton("10.0.1.3")] - assert info_service.addresses == [socket.inet_aton("10.0.1.3")] - - -def test_serviceinfo_accepts_bytes_or_string_dict(): - """Verify a bytes or string dict can be passed to ServiceInfo.""" - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - addresses = [socket.inet_aton("10.0.1.2")] - server_name = "ash-2.local." - info_service = ServiceInfo( - type_, '%s.%s' % (name, type_), 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses - ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - server_name, - addresses=addresses, - ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {b'path': '/~paulsm/'}, - server_name, - addresses=addresses, - ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': b'/~paulsm/'}, - server_name, - addresses=addresses, - ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' From 0909c80c67287ba92ed334ab6896136aec0f3f24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jun 2021 22:23:56 -1000 Subject: [PATCH 0440/1433] Update changelog (#747) --- README.rst | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.rst b/README.rst index 3e3d0e645..3b7dbf227 100644 --- a/README.rst +++ b/README.rst @@ -254,12 +254,71 @@ you can likely not be concerned with the breaking changes below: * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Relocate service browser tests to tests/services/test_browser.py (#745) @bdraco + +* Relocate ServiceInfo to zeroconf._services.info (#741) @bdraco + +* Run question answer callbacks from add_listener in the event loop (#740) @bdraco + + Calling async_update_records and async_update_records_complete should always + happen in the event loop to ensure implementers do not need to worry about + thread safety + +* Remove second level caching from ServiceBrowsers (#737) @bdraco + + The ServiceBrowser had its own cache of the last time it + saw a service which was reimplementing the DNSCache and + presenting a source of truth problem that lead to unexpected + queries when the two disagreed. + +* Breakout ServiceBrowser handler from listener creation (#736) @bdraco + + Add coverage for the handler from listener + +* Add fast cache lookup functions (#732) @bdraco + + The majority of our lookups happen in the event loop so there is no need + for them to be threadsafe. Now that the codebase is more clear about what + needs to be threadsafe and what does not need to be threadsafe we can use + the much faster non-threadsafe versions in the places where we are calling + from the event loop. + +* Switch to using DNSRRSet in RecordManager (#735) @bdraco + + DNSRRSet is able to do O(1) lookups of records assuming + there are no collisions. + +* Fix server cache to be case-insensitive (#731) @bdraco + + If the server name had uppercase chars and any of the + matching records were lowercase, the server would not be + found + * Fix cache handling of records with different TTLs (#729) @bdraco + There should only be one unique record in the cache at + a time as having multiple unique records will different + TTLs in the cache can result in unexpected behavior since + some functions returned all matching records and some + fetched from the right side of the list to return the + newest record. Intead we now store the records in a dict + to ensure that the newest record always replaces the same + unique record and we never have a source of truth problem + determining the TTL of a record from the cache. + * Rename handlers and internals to make it clear what is threadsafe (#726) @bdraco + It was too easy to get confused about what was threadsafe and + what was not threadsafe which lead to unexpected failures. + Rename functions to make it clear what will be run in the event + loop and what is expected to be threadsafe + * Fix ServiceInfo with multiple A records (#725) @bdraco + If there were multiple A records for the host, ServiceInfo + would always return the last one that was in the incoming + packet which was usually not the one that was wanted. + * Synchronize time for fate sharing (#718) @bdraco * Cleanup typing in zero._core and document ignores (#714) @bdraco From 7b3b4b5b8303a684165fcd53c0d9c36a1b8dda3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 08:30:32 -1000 Subject: [PATCH 0441/1433] Remove support for notify listeners (#733) --- tests/test_aio.py | 6 +++--- tests/test_core.py | 44 -------------------------------------- zeroconf/__init__.py | 2 +- zeroconf/_core.py | 48 ++++++++++++++++++++++-------------------- zeroconf/_handlers.py | 2 +- zeroconf/_utils/aio.py | 24 ++++++++++++++++++++- zeroconf/aio.py | 36 +++++-------------------------- 7 files changed, 58 insertions(+), 104 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index e41442500..bd4f7d2dd 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -312,12 +312,12 @@ async def test_async_wait_unblocks_on_update() -> None: # Should unblock due to update from the # registration now = current_time_millis() - await aiozc.async_wait(50000) + await aiozc.zeroconf.async_wait(50000) assert current_time_millis() - now < 3000 await task now = current_time_millis() - await aiozc.async_wait(50) + await aiozc.zeroconf.async_wait(50) assert current_time_millis() - now < 1000 await aiozc.async_close() @@ -481,7 +481,7 @@ def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: await task task = await aiozc.async_unregister_service(new_info) await task - await aiozc.async_wait(1) + await aiozc.zeroconf.async_wait(1) await aiozc.async_close() assert calls == [ diff --git a/tests/test_core.py b/tests/test_core.py index 1f0884f03..13c6ec706 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -249,50 +249,6 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS zeroconf.close() -# This test uses asyncio because it needs to verify the listeners -# run in the event loop -@pytest.mark.asyncio -async def test_notify_listeners(): - """Test adding and removing notify listeners.""" - # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - zc = aiozc.zeroconf - notify_called = 0 - - class TestNotifyListener(r.NotifyListener): - def notify_all(self): - nonlocal notify_called - notify_called += 1 - - with pytest.raises(NotImplementedError): - r.NotifyListener().notify_all() - - notify_listener = TestNotifyListener() - - zc.add_notify_listener(notify_listener) - - def on_service_state_change(zeroconf, service_type, state_change, name): - """Dummy service callback.""" - - # start a browser - browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) - browser.cancel() - - await asyncio.sleep(0) # flush out any call_soon_threadsafe - assert notify_called - zc.remove_notify_listener(notify_listener) - - notify_called = 0 - # start a browser - browser = ServiceBrowser(zc, "_http._tcp.local.", [on_service_state_change]) - browser.cancel() - await asyncio.sleep(0) # flush out any call_soon_threadsafe - - assert not notify_called - - await aiozc.async_close() - - def test_generate_service_query_set_qu_bit(): """Test generate_service_query sets the QU bit.""" diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3715e1740..e3d7ddfb4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,7 +23,7 @@ import sys from ._cache import DNSCache # noqa # import needed for backwards compat -from ._core import NotifyListener, Zeroconf # noqa # import needed for backwards compat +from ._core import Zeroconf # noqa # import needed for backwards compat from ._dns import ( # noqa # import needed for backwards compat DNSAddress, DNSEntry, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index ff54dc7e9..1ef3a843f 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -41,7 +41,7 @@ from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry -from ._utils.aio import get_running_loop +from ._utils.aio import get_running_loop, shutdown_loop, wait_condition_or_timeout from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -72,14 +72,6 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) -class NotifyListener: - """Receive notifications Zeroconf.notify_all is called.""" - - def notify_all(self) -> None: - """Called when Zeroconf.notify_all is called.""" - raise NotImplementedError() - - class AsyncEngine: """An engine wraps sockets in the event loop.""" @@ -293,7 +285,6 @@ def __init__( self.engine = AsyncEngine(self, listen_socket, respond_sockets) - self._notify_listeners: List[NotifyListener] = [] self.browsers: Dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() self.cache = DNSCache() @@ -301,6 +292,7 @@ def __init__( self.record_manager = RecordManager(self) self.condition = threading.Condition() + self.async_condition: Optional[asyncio.Condition] = None self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None @@ -313,6 +305,7 @@ def start(self) -> None: """Start Zeroconf.""" self.loop = get_running_loop() if self.loop: + self.async_condition = asyncio.Condition() self.engine.setup(self.loop, None) return self._start_thread() @@ -324,6 +317,7 @@ def _start_thread(self) -> None: def _run_loop() -> None: self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) + self.async_condition = asyncio.Condition() self.engine.setup(self.loop, loop_thread_ready) self.loop.run_forever() @@ -349,12 +343,28 @@ def wait(self, timeout: float) -> None: with self.condition: self.condition.wait(millis_to_seconds(timeout)) + async def async_wait(self, timeout: float) -> None: + """Calling task waits for a given number of milliseconds or until notified.""" + assert self.async_condition is not None + async with self.async_condition: + await wait_condition_or_timeout(self.async_condition, millis_to_seconds(timeout)) + def notify_all(self) -> None: - """Notifies all waiting threads""" + """Notifies all waiting threads and notify listeners.""" + assert self.loop is not None + self.loop.call_soon_threadsafe(self.async_notify_all) + + def async_notify_all(self) -> None: + """Schedule an async_notify_all.""" + asyncio.ensure_future(self._async_notify_all()) + + async def _async_notify_all(self) -> None: + """Notify all async listeners.""" + assert self.async_condition is not None with self.condition: self.condition.notify_all() - for listener in self._notify_listeners: - listener.notify_all() + async with self.async_condition: + self.async_condition.notify_all() def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular @@ -365,15 +375,6 @@ def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Option return info return None - def add_notify_listener(self, listener: NotifyListener) -> None: - """Adds a listener to receive notify_all events.""" - self._notify_listeners.append(listener) - - def remove_notify_listener(self, listener: NotifyListener) -> None: - """Removes a listener from the set that is currently listening.""" - with contextlib.suppress(ValueError): - self._notify_listeners.remove(listener) - def add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when @@ -652,8 +653,9 @@ def _shutdown_threads(self) -> None: if not self._loop_thread: return assert self.loop is not None - self.loop.call_soon_threadsafe(self.loop.stop) + shutdown_loop(self.loop) self._loop_thread.join() + self._loop_thread = None def close(self) -> None: """Ends the background threads, and prevent this instance from diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 476217f6c..db5948c60 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -297,7 +297,7 @@ def async_updates_complete(self) -> None: """ for listener in self.listeners: listener.async_update_records_complete() - self.zc.notify_all() + self.zc.async_notify_all() def async_updates_from_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index 87a79f265..e1bd12d0b 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -22,7 +22,7 @@ import asyncio import contextlib -from typing import Optional, cast +from typing import Optional, Set, cast # Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed @@ -54,6 +54,28 @@ def _handle_wait_complete(_: asyncio.Task) -> None: await condition_wait +async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: + """Return all tasks running.""" + if hasattr(asyncio, 'all_tasks'): + return cast(Set[asyncio.Task], asyncio.all_tasks(loop)) # type: ignore # pylint: disable=no-member + return cast(Set[asyncio.Task], asyncio.Task.all_tasks(loop)) # type: ignore # pylint: disable=no-member + + +async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: + """Wait for the event loop thread we started to shutdown.""" + await asyncio.wait(wait_tasks, timeout=1) + + +def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: + """Wait for pending tasks and stop an event loop.""" + pending_tasks = asyncio.run_coroutine_threadsafe(_get_all_tasks(loop), loop).result() + done_tasks = set(task for task in pending_tasks if not task.done()) + pending_tasks -= done_tasks + if pending_tasks: + asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result() + loop.call_soon_threadsafe(loop.stop) + + # Remove the call to _get_running_loop once we drop python 3.6 support def get_running_loop() -> Optional[asyncio.AbstractEventLoop]: """Check if an event loop is already running.""" diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 00d428233..a5cf06054 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -24,7 +24,7 @@ from types import TracebackType # noqa # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union -from ._core import NotifyListener, Zeroconf +from ._core import Zeroconf from ._exceptions import NonUniqueNameException from ._services.browser import _ServiceBrowserBase from ._services.info import ServiceInfo, instance_name_from_service_info @@ -52,24 +52,6 @@ ] -class AsyncNotifyListener(NotifyListener): - """A NotifyListener that async code can use to wait for events.""" - - def __init__(self, aiozc: 'AsyncZeroconf') -> None: - """Create an event for async listeners to wait for.""" - self.aiozc = aiozc - self.loop = asyncio.get_event_loop() - - def notify_all(self) -> None: - """Schedule an async_notify_all.""" - self.loop.call_soon_threadsafe(asyncio.ensure_future, self._async_notify_all()) - - async def _async_notify_all(self) -> None: - """Notify all async listeners.""" - async with self.aiozc.condition: - self.aiozc.condition.notify_all() - - class AsyncServiceListener: def add_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: raise NotImplementedError() @@ -109,7 +91,7 @@ async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: next_ = now + delay delay *= 2 - await aiozc.async_wait(min(next_, last) - now) + await aiozc.zeroconf.async_wait(min(next_, last) - now) now = current_time_millis() finally: aiozc.zeroconf.remove_listener(self) @@ -148,16 +130,17 @@ async def async_cancel(self) -> None: async def async_run(self) -> None: """Run the browser task.""" await self.aiozc.zeroconf.async_wait_for_start() + assert self.aiozc.zeroconf.async_condition is not None while True: timeout = self._seconds_to_wait() if timeout: - async with self.aiozc.condition: + async with self.aiozc.zeroconf.async_condition: # We must check again while holding the condition # in case the other thread has added to _handlers_to_call # between when we checked above when we were not # holding the condition if not self._handlers_to_call: - await wait_condition_or_timeout(self.aiozc.condition, timeout) + await wait_condition_or_timeout(self.aiozc.zeroconf.async_condition, timeout) outs = self.generate_ready_queries() for out in outs: @@ -253,10 +236,7 @@ def __init__( apple_p2p=apple_p2p, ) self.loop = asyncio.get_event_loop() - self.async_notify = AsyncNotifyListener(self) - self.zeroconf.add_notify_listener(self.async_notify) self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} - self.condition = asyncio.Condition() async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: """Send a broadcasts to announce a service at intervals.""" @@ -343,7 +323,6 @@ async def async_close(self) -> None: with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) await self.async_remove_all_service_listeners() - self.zeroconf.remove_notify_listener(self.async_notify) await self.async_unregister_all_services() await self.zeroconf._async_close() # pylint: disable=protected-access @@ -358,11 +337,6 @@ async def async_get_service_info( return info return None - async def async_wait(self, timeout: float) -> None: - """Calling task waits for a given number of milliseconds or until notified.""" - async with self.condition: - await wait_condition_or_timeout(self.condition, millis_to_seconds(timeout)) - async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when From 0dbcabfade41057a055ebefffd410d1afc3eb0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 09:01:32 -1000 Subject: [PATCH 0442/1433] Run ServiceInfo requests in the event loop (#748) --- examples/async_service_info_request.py | 2 +- tests/services/test_info.py | 4 +-- tests/test_aio.py | 2 +- zeroconf/_services/info.py | 17 ++++++++---- zeroconf/aio.py | 37 ++------------------------ 5 files changed, 18 insertions(+), 44 deletions(-) diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 838545cee..b73f27dc2 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -28,7 +28,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: if not name.endswith(HAP_TYPE): continue infos.append(AsyncServiceInfo(HAP_TYPE, name)) - tasks = [info.async_request(aiozc, 3000) for info in infos] + tasks = [info.async_request(aiozc.zeroconf, 3000) for info in infos] await asyncio.gather(*tasks) for info in infos: print("Info for %s" % (info.name)) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 8f654a707..8fb45f22d 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -216,7 +216,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): send_event.set() # patch the zeroconf send - with unittest.mock.patch.object(zc, "send", send): + with unittest.mock.patch.object(zc, "async_send", send): def mock_incoming_msg(records) -> r.DNSIncoming: @@ -353,7 +353,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): send_event.set() # patch the zeroconf send - with unittest.mock.patch.object(zc, "send", send): + with unittest.mock.patch.object(zc, "async_send", send): def mock_incoming_msg(records) -> r.DNSIncoming: diff --git a/tests/test_aio.py b/tests/test_aio.py index bd4f7d2dd..9b3711fef 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -409,7 +409,7 @@ async def test_service_info_async_request() -> None: # Generating the race condition is almost impossible # without patching since its a TOCTOU race with unittest.mock.patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc, 3000) + await aiosinfo.async_request(aiozc.zeroconf, 3000) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index a3536ed1d..5a3319270 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -20,6 +20,7 @@ USA """ +import asyncio import socket from typing import Dict, List, Optional, TYPE_CHECKING, Union, cast @@ -393,6 +394,13 @@ def _is_complete(self) -> bool: return not (self.text is None or not self._addresses) def request(self, zc: 'Zeroconf', timeout: float) -> bool: + """Returns true if the service could be discovered on the + network, and updates this object with details discovered. + """ + assert zc.loop is not None + return asyncio.run_coroutine_threadsafe(self.async_request(zc, timeout), zc.loop).result() + + async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ @@ -403,9 +411,8 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: delay = _LISTENER_TIME next_ = now last = now + timeout + await zc.async_wait_for_start() try: - # Do not set a question on the listener to preload from cache - # since we just checked it above in load_from_cache zc.add_listener(self, None) while not self._is_complete: if last <= now: @@ -413,12 +420,12 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: if next_ <= now: out = self.generate_request_query(zc, now) if not out.questions: - return True - zc.send(out) + return self.load_from_cache(zc) + zc.async_send(out) next_ = now + delay delay *= 2 - zc.wait(min(next_, last) - now) + await zc.async_wait(min(next_, last) - now) now = current_time_millis() finally: zc.remove_listener(self) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index a5cf06054..a0c908b6e 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -31,11 +31,10 @@ from ._services.types import ZeroconfServiceTypes from ._utils.aio import wait_condition_or_timeout from ._utils.net import IPVersion, InterfaceChoice, InterfacesType -from ._utils.time import current_time_millis, millis_to_seconds +from ._utils.time import millis_to_seconds from .const import ( _BROWSER_TIME, _CHECK_TIME, - _LISTENER_TIME, _MDNS_PORT, _REGISTER_TIME, _SERVICE_TYPE_ENUMERATION_NAME, @@ -66,38 +65,6 @@ def update_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: class AsyncServiceInfo(ServiceInfo): """An async version of ServiceInfo.""" - async def async_request(self, aiozc: 'AsyncZeroconf', timeout: float) -> bool: - """Returns true if the service could be discovered on the - network, and updates this object with details discovered. - """ - if self.load_from_cache(aiozc.zeroconf): - return True - - now = current_time_millis() - delay = _LISTENER_TIME - next_ = now - last = now + timeout - await aiozc.zeroconf.async_wait_for_start() - try: - aiozc.zeroconf.add_listener(self, None) - while not self._is_complete: - if last <= now: - return False - if next_ <= now: - out = self.generate_request_query(aiozc.zeroconf, now) - if not out.questions: - return self.load_from_cache(aiozc.zeroconf) - aiozc.zeroconf.async_send(out) - next_ = now + delay - delay *= 2 - - await aiozc.zeroconf.async_wait(min(next_, last) - now) - now = current_time_millis() - finally: - aiozc.zeroconf.remove_listener(self) - - return True - class AsyncServiceBrowser(_ServiceBrowserBase): """Used to browse for a service of a specific type. @@ -333,7 +300,7 @@ async def async_get_service_info( name and type, or None if no service matches by the timeout, which defaults to 3 seconds.""" info = AsyncServiceInfo(type_, name) - if await info.async_request(self, timeout): + if await info.async_request(self.zeroconf, timeout): return info return None From 0f702c6a41bb33ed63872249b82d1111bdac4fa6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 09:11:38 -1000 Subject: [PATCH 0443/1433] Update async_service_info_request example to ensure it runs in the right event loop (#749) --- examples/async_service_info_request.py | 48 +++++++++++++++++--------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index b73f27dc2..8ea961eb7 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -9,7 +9,7 @@ import argparse import asyncio import logging -from typing import cast +from typing import Any, Optional, cast from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf @@ -48,6 +48,33 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: print('\n') +class AsyncRunner: + def __init__(self, args: Any) -> None: + self.args = args + self.threaded_browser: Optional[ServiceBrowser] = None + self.aiozc: Optional[AsyncZeroconf] = None + + async def async_run(self) -> None: + self.aiozc = AsyncZeroconf(ip_version=ip_version) + assert self.aiozc is not None + + def on_service_state_change( + zeroconf: Zeroconf, service_type: str, state_change: ServiceStateChange, name: str + ) -> None: + """Dummy handler.""" + + self.threaded_browser = ServiceBrowser( + self.aiozc.zeroconf, [HAP_TYPE], handlers=[on_service_state_change] + ) + await async_watch_services(self.aiozc) + + async def async_close(self) -> None: + assert self.aiozc is not None + assert self.threaded_browser is not None + self.threaded_browser.cancel() + await self.aiozc.async_close() + + if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) @@ -67,23 +94,10 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: else: ip_version = IPVersion.V4Only - aiozc = AsyncZeroconf(ip_version=ip_version) - - def on_service_state_change( - zeroconf: Zeroconf, service_type: str, state_change: ServiceStateChange, name: str - ) -> None: - """Dummy handler.""" - print(f"Services with {HAP_TYPE} will be shown every 5s, press Ctrl-C to exit...") - # ServiceBrowser currently is only offered in sync context. - # ServiceInfo has an AsyncServiceInfo counterpart that can be used - # to fetch service info in parallel - browser = ServiceBrowser(aiozc.zeroconf, [HAP_TYPE], handlers=[on_service_state_change]) loop = asyncio.get_event_loop() + runner = AsyncRunner(args) try: - loop.run_until_complete(async_watch_services(aiozc)) + loop.run_until_complete(runner.async_run()) except KeyboardInterrupt: - pass - finally: - browser.cancel() - loop.run_until_complete(aiozc.async_close()) + loop.run_until_complete(runner.async_close()) From 3b9baf07278290b2b4eb8ac5850bccfbd8b107d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 09:11:47 -1000 Subject: [PATCH 0444/1433] Fix warning about Zeroconf._async_notify_all not being awaited in sync shutdown (#750) --- zeroconf/_utils/aio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index e1bd12d0b..320ff7c39 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -56,6 +56,7 @@ def _handle_wait_complete(_: asyncio.Task) -> None: async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: """Return all tasks running.""" + await asyncio.sleep(0) # flush out any call_soon_threadsafe if hasattr(asyncio, 'all_tasks'): return cast(Set[asyncio.Task], asyncio.all_tasks(loop)) # type: ignore # pylint: disable=no-member return cast(Set[asyncio.Task], asyncio.Task.all_tasks(loop)) # type: ignore # pylint: disable=no-member From e7adce2bf6ea0b4af1709369a36421acd9757b4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 09:28:00 -1000 Subject: [PATCH 0445/1433] Remove unused argument from AsyncZeroconf (#751) --- zeroconf/aio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index a0c908b6e..ffb7b0601 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -202,7 +202,6 @@ def __init__( ip_version=ip_version, apple_p2p=apple_p2p, ) - self.loop = asyncio.get_event_loop() self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: From 4d0a8f3c643a0fc5c3a40420bab96ef18dddaecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 10:55:57 -1000 Subject: [PATCH 0446/1433] Run ServiceBrowser queries in the event loop (#752) --- tests/services/test_browser.py | 8 +-- tests/test_aio.py | 1 + tests/test_init.py | 4 +- zeroconf/_core.py | 14 ++---- zeroconf/_handlers.py | 2 +- zeroconf/_services/browser.py | 91 ++++++++++++++++++++++++++-------- zeroconf/_utils/aio.py | 8 +++ zeroconf/aio.py | 42 ++-------------- 8 files changed, 95 insertions(+), 75 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index ccdb312f0..342ced691 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -348,7 +348,7 @@ def test_backoff(): zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) # we are going to patch the zeroconf send to check query transmission - old_send = zeroconf_browser.send + old_send = zeroconf_browser.async_send time_offset = 0.0 start_time = time.time() * 1000 @@ -366,7 +366,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send # patch the zeroconf current_time_millis # patch the backoff limit to prevent test running forever - with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( _services_browser, "current_time_millis", current_time_millis ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): # dummy service callback @@ -432,7 +432,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) # we are going to patch the zeroconf send to check packet sizes - old_send = zeroconf_browser.send + old_send = zeroconf_browser.async_send time_offset = 0.0 @@ -459,7 +459,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send # patch the zeroconf current_time_millis # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - with unittest.mock.patch.object(zeroconf_browser, "send", send), unittest.mock.patch.object( + with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( _services_browser, "current_time_millis", current_time_millis ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): service_added = Event() diff --git a/tests/test_aio.py b/tests/test_aio.py index 9b3711fef..6cfbcfb20 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -109,6 +109,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: calls.append(("update", type, name)) listener = MyListener() + aiozc.zeroconf.add_service_listener(type_, listener) desc = {'path': '/~paulsm/'} diff --git a/tests/test_init.py b/tests/test_init.py index 3cc16b220..30149b846 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -82,7 +82,7 @@ def test_lots_of_names(self): self.verify_name_change(zc, type_, name, server_count) # we are going to patch the zeroconf send to check packet sizes - old_send = zc.send + old_send = zc.async_send longest_packet_len = 0 longest_packet = None # type: Optional[r.DNSOutgoing] @@ -97,7 +97,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zc, "send", send): + with unittest.mock.patch.object(zc, "async_send", send): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1ef3a843f..9dab6a27e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -291,7 +291,6 @@ def __init__( self.query_handler = QueryHandler(self.registry, self.cache) self.record_manager = RecordManager(self) - self.condition = threading.Condition() self.async_condition: Optional[asyncio.Condition] = None self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None @@ -338,10 +337,9 @@ def listeners(self) -> List[RecordUpdateListener]: return self.record_manager.listeners def wait(self, timeout: float) -> None: - """Calling thread waits for a given number of milliseconds or - until notified.""" - with self.condition: - self.condition.wait(millis_to_seconds(timeout)) + """Calling task waits for a given number of milliseconds or until notified.""" + assert self.loop is not None + asyncio.run_coroutine_threadsafe(self.async_wait(timeout), self.loop).result() async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" @@ -361,10 +359,8 @@ def async_notify_all(self) -> None: async def _async_notify_all(self) -> None: """Notify all async listeners.""" assert self.async_condition is not None - with self.condition: - self.condition.notify_all() - async with self.async_condition: - self.async_condition.notify_all() + async with self.async_condition: + self.async_condition.notify_all() def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index db5948c60..2a58850d2 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -410,7 +410,7 @@ def _async_update_matching_records( return listener.async_update_records(self.zc, now, records) listener.async_update_records_complete() - self.zc.notify_all() + self.zc.async_notify_all() def remove_listener(self, listener: RecordUpdateListener) -> None: """Removes a listener.""" diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index b633df673..1e44679fd 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -20,6 +20,9 @@ USA """ +import asyncio +import contextlib +import queue import threading import warnings from collections import OrderedDict @@ -35,6 +38,7 @@ Signal, SignalRegistrationInterface, ) +from .._utils.aio import get_best_available_queue, get_running_loop, wait_condition_or_timeout from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( @@ -180,6 +184,7 @@ def __init__( for check_type_ in self.types: # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) + self._browser_task: Optional[asyncio.Task] = None self.zc = zc self.addr = addr self.port = port @@ -190,7 +195,7 @@ def __init__( self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._handlers_to_call: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() - + self.queue: Optional[queue.Queue] = None self.done = False if hasattr(handlers, 'add_service'): @@ -341,6 +346,47 @@ def _seconds_to_wait(self) -> Optional[float]: return millis_to_seconds(next_time - now) + async def async_browser_task(self) -> None: + """Run the browser task.""" + await self.zc.async_wait_for_start() + assert self.zc.async_condition is not None + while True: + timeout = self._seconds_to_wait() + if timeout: + async with self.zc.async_condition: + # We must check again while holding the condition + # in case the other thread has added to _handlers_to_call + # between when we checked above when we were not + # holding the condition + if not self._handlers_to_call: + await wait_condition_or_timeout(self.zc.async_condition, timeout) + + outs = self.generate_ready_queries() + for out in outs: + self.zc.async_send(out, addr=self.addr, port=self.port) + + if not self._handlers_to_call: + continue + + (name_type, state_change) = self._handlers_to_call.popitem(False) + if self.queue: + self.queue.put((name_type, state_change)) + continue + + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) + + async def _async_cancel_browser(self) -> None: + """Cancel the browser.""" + assert self._browser_task is not None + self._browser_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._browser_task + class ServiceBrowser(_ServiceBrowserBase, threading.Thread): """Used to browse for a service of a specific type. @@ -361,42 +407,45 @@ def __init__( ) -> None: threading.Thread.__init__(self) super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + self.queue = get_best_available_queue() self.daemon = True self.start() self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) + assert self.zc.loop is not None + if get_running_loop() == self.zc.loop: + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + return + self._browser_task = cast( + asyncio.Task, + asyncio.run_coroutine_threadsafe(self._async_browser_task(), self.zc.loop).result(), + ) + + async def _async_browser_task(self) -> asyncio.Task: + return cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) def cancel(self) -> None: """Cancel the browser.""" + assert self.zc.loop is not None + assert self.queue is not None + self.queue.put(None) + if get_running_loop() == self.zc.loop: + asyncio.ensure_future(self._async_cancel_browser()) + else: + asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() super().cancel() self.join() def run(self) -> None: """Run the browser thread.""" + assert self.queue is not None while True: - timeout = self._seconds_to_wait() - if timeout: - with self.zc.condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - self.zc.condition.wait(timeout) - - if self.zc.done or self.done: + event = self.queue.get() + if event is None: return - - outs = self.generate_ready_queries() - for out in outs: - self.zc.send(out, addr=self.addr, port=self.port) - - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) + name_type, state_change = event self._service_state_changed.fire( zeroconf=self.zc, service_type=name_type[1], diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index 320ff7c39..d4adffa8e 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -22,9 +22,17 @@ import asyncio import contextlib +import queue from typing import Optional, Set, cast +def get_best_available_queue() -> queue.Queue: + """Create the best available queue type.""" + if hasattr(queue, "SimpleQueue"): + return queue.SimpleQueue() # type: ignore # pylint: disable=all + return queue.Queue() + + # Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: """Wait for a condition or timeout.""" diff --git a/zeroconf/aio.py b/zeroconf/aio.py index ffb7b0601..e5b6a96a2 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -22,14 +22,13 @@ import asyncio import contextlib from types import TracebackType # noqa # used in type hints -from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union, cast from ._core import Zeroconf from ._exceptions import NonUniqueNameException from ._services.browser import _ServiceBrowserBase from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.types import ZeroconfServiceTypes -from ._utils.aio import wait_condition_or_timeout from ._utils.net import IPVersion, InterfaceChoice, InterfacesType from ._utils.time import millis_to_seconds from .const import ( @@ -83,46 +82,13 @@ def __init__( port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, ) -> None: - self.aiozc = aiozc super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore - self._browser_task = asyncio.ensure_future(self.async_run()) + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) async def async_cancel(self) -> None: """Cancel the browser.""" - self.cancel() - self._browser_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._browser_task - - async def async_run(self) -> None: - """Run the browser task.""" - await self.aiozc.zeroconf.async_wait_for_start() - assert self.aiozc.zeroconf.async_condition is not None - while True: - timeout = self._seconds_to_wait() - if timeout: - async with self.aiozc.zeroconf.async_condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - await wait_condition_or_timeout(self.aiozc.zeroconf.async_condition, timeout) - - outs = self.generate_ready_queries() - for out in outs: - self.aiozc.zeroconf.async_send(out, addr=self.addr, port=self.port) - - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.aiozc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) + await self._async_cancel_browser() + super().cancel() class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): From 04cd2688022ebd07c1f875fefc73f8d15c4ed56c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 11:15:36 -1000 Subject: [PATCH 0447/1433] Drop AsyncServiceListener (#754) --- examples/async_browser.py | 15 +++++++++------ tests/test_aio.py | 13 ++----------- zeroconf/aio.py | 31 ++++++++++--------------------- 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/examples/async_browser.py b/examples/async_browser.py index cba30223d..b835307c8 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -10,12 +10,12 @@ import logging from typing import Any, Optional, cast -from zeroconf import IPVersion, ServiceStateChange -from zeroconf.aio import AsyncServiceBrowser, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf import IPVersion, ServiceStateChange, Zeroconf +from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes def async_on_service_state_change( - zeroconf: AsyncZeroconf, service_type: str, name: str, state_change: ServiceStateChange + zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) if state_change is not ServiceStateChange.Added: @@ -23,8 +23,9 @@ def async_on_service_state_change( asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) -async def async_display_service_info(zeroconf: AsyncZeroconf, service_type: str, name: str) -> None: - info = await zeroconf.async_get_service_info(service_type, name) +async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: + info = AsyncServiceInfo(service_type, name) + await info.async_request(zeroconf, 3000) print("Info from zeroconf.get_service_info: %r" % (info)) if info: addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] @@ -59,7 +60,9 @@ async def async_run(self) -> None: ) print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) - self.aiobrowser = AsyncServiceBrowser(self.aiozc, services, handlers=[async_on_service_state_change]) + self.aiobrowser = AsyncServiceBrowser( + self.aiozc.zeroconf, services, handlers=[async_on_service_state_change] + ) while True: await asyncio.sleep(1) diff --git a/tests/test_aio.py b/tests/test_aio.py index 6cfbcfb20..327ccc660 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -12,7 +12,7 @@ import pytest -from zeroconf.aio import AsyncServiceInfo, AsyncServiceListener, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered @@ -433,16 +433,7 @@ async def test_async_service_browser() -> None: calls = [] - with pytest.raises(NotImplementedError): - AsyncServiceListener().add_service(aiozc, "_type", "name._type") - - with pytest.raises(NotImplementedError): - AsyncServiceListener().remove_service(aiozc, "_type", "name._type") - - with pytest.raises(NotImplementedError): - AsyncServiceListener().update_service(aiozc, "_type", "name._type") - - class MyListener(AsyncServiceListener): + class MyListener(ServiceListener): def add_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: calls.append(("add", type, name)) diff --git a/zeroconf/aio.py b/zeroconf/aio.py index e5b6a96a2..1f3e43523 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -26,6 +26,7 @@ from ._core import Zeroconf from ._exceptions import NonUniqueNameException +from ._services import ServiceListener from ._services.browser import _ServiceBrowserBase from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.types import ZeroconfServiceTypes @@ -45,22 +46,10 @@ "AsyncZeroconf", "AsyncServiceInfo", "AsyncServiceBrowser", - "AsyncServiceListener", "AsyncZeroconfServiceTypes", ] -class AsyncServiceListener: - def add_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def remove_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - def update_service(self, aiozc: 'AsyncZeroconf', type_: str, name: str) -> None: - raise NotImplementedError() - - class AsyncServiceInfo(ServiceInfo): """An async version of ServiceInfo.""" @@ -74,15 +63,15 @@ class AsyncServiceBrowser(_ServiceBrowserBase): def __init__( self, - aiozc: 'AsyncZeroconf', + zeroconf: 'Zeroconf', type_: Union[str, list], - handlers: Optional[Union[AsyncServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[AsyncServiceListener] = None, + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, ) -> None: - super().__init__(aiozc.zeroconf, type_, handlers, listener, addr, port, delay) # type: ignore + super().__init__(zeroconf, type_, handlers, listener, addr, port, delay) self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) async def async_cancel(self) -> None: @@ -115,7 +104,7 @@ async def async_find( local_zc = aiozc or AsyncZeroconf(interfaces=interfaces, ip_version=ip_version) listener = cls() async_browser = AsyncServiceBrowser( - local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener # type: ignore + local_zc.zeroconf, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener ) # wait for responses @@ -168,7 +157,7 @@ def __init__( ip_version=ip_version, apple_p2p=apple_p2p, ) - self.async_browsers: Dict[AsyncServiceListener, AsyncServiceBrowser] = {} + self.async_browsers: Dict[ServiceListener, AsyncServiceBrowser] = {} async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: """Send a broadcasts to announce a service at intervals.""" @@ -269,14 +258,14 @@ async def async_get_service_info( return info return None - async def async_add_service_listener(self, type_: str, listener: AsyncServiceListener) -> None: + async def async_add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when services of that type become available and unavailable.""" await self.async_remove_service_listener(listener) - self.async_browsers[listener] = AsyncServiceBrowser(self, type_, listener) + self.async_browsers[listener] = AsyncServiceBrowser(self.zeroconf, type_, listener) - async def async_remove_service_listener(self, listener: AsyncServiceListener) -> None: + async def async_remove_service_listener(self, listener: ServiceListener) -> None: """Removes a listener from the set that is currently listening.""" if listener in self.async_browsers: await self.async_browsers[listener].async_cancel() From f53c88b52ed080c80e2e98d3da91a830f0c7ebca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 11:19:04 -1000 Subject: [PATCH 0448/1433] Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) - This guarding is no longer needed as the ServiceBrowser loop now runs in the event loop and the thread safety guard is no longer needed --- zeroconf/_services/browser.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 1e44679fd..853c4395b 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -296,15 +296,8 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - # Cannot use .update here since can fail with - # RuntimeError: dictionary changed size during iteration - # for threaded ServiceBrowsers - while self._pending_handlers: - try: - (name_type, state_change) = self._pending_handlers.popitem(False) - except KeyError: - return - self._handlers_to_call[name_type] = state_change + self._handlers_to_call.update(self._pending_handlers) + self._pending_handlers.clear() def cancel(self) -> None: """Cancel the browser.""" From f24ebba9ecc4d1626d570956a7cc735206d7ff6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 14:16:08 -1000 Subject: [PATCH 0449/1433] Simplify ServiceBrowser callsbacks (#756) --- zeroconf/_services/browser.py | 91 ++++++++++++++--------------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 853c4395b..b03003b17 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -38,9 +38,9 @@ Signal, SignalRegistrationInterface, ) -from .._utils.aio import get_best_available_queue, get_running_loop, wait_condition_or_timeout +from .._utils.aio import get_best_available_queue, get_running_loop from .._utils.name import service_type_name -from .._utils.time import current_time_millis, millis_to_seconds +from .._utils.time import current_time_millis from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -172,8 +172,8 @@ def __init__( self, zc: 'Zeroconf', type_: Union[str, list], - handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, - listener: Optional['ServiceListener'] = None, + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, @@ -193,7 +193,6 @@ def __init__( self._next_time = {check_type_: current_time for check_type_ in self.types} self._delay = {check_type_: delay for check_type_ in self.types} self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() - self._handlers_to_call: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.queue: Optional[queue.Queue] = None self.done = False @@ -296,8 +295,30 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - self._handlers_to_call.update(self._pending_handlers) - self._pending_handlers.clear() + while self._pending_handlers: + event = self._pending_handlers.popitem(False) + # If there is a queue running (ServiceBrowser) + # get fired in dedicated thread + if self.queue: + self.queue.put(event) + else: + self._fire_service_state_changed_event(event) + + def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: + """Fire a service state changed event. + + When running with ServiceBrowser, this will happen in the dedicated + thread. + + When running with AsyncServiceBrowser, this will happen in the event loop. + """ + name_type, state_change = event + self._service_state_changed.fire( + zeroconf=self.zc, + service_type=name_type[1], + name=name_type[0], + state_change=state_change, + ) def cancel(self) -> None: """Cancel the browser.""" @@ -307,8 +328,7 @@ def cancel(self) -> None: def generate_ready_queries(self) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" now = current_time_millis() - - if min(self._next_time.values()) > now: + if self._millis_to_wait(current_time_millis()): return [] ready_types = [] @@ -323,56 +343,25 @@ def generate_ready_queries(self) -> List[DNSOutgoing]: return generate_service_query(self.zc, now, ready_types, self.multicast) - def _seconds_to_wait(self) -> Optional[float]: - """Returns the number of seconds to wait for the next event.""" - # If there are handlers to call - # we want to process them right away - if self._handlers_to_call: - return None - + def _millis_to_wait(self, now: float) -> Optional[float]: + """Returns the number of milliseconds to wait for the next event.""" # Wait for the type has the smallest next time next_time = min(self._next_time.values()) - now = current_time_millis() - - if next_time <= now: - return None - - return millis_to_seconds(next_time - now) + return None if next_time <= now else next_time - now async def async_browser_task(self) -> None: """Run the browser task.""" await self.zc.async_wait_for_start() assert self.zc.async_condition is not None while True: - timeout = self._seconds_to_wait() + timeout = self._millis_to_wait(current_time_millis()) if timeout: - async with self.zc.async_condition: - # We must check again while holding the condition - # in case the other thread has added to _handlers_to_call - # between when we checked above when we were not - # holding the condition - if not self._handlers_to_call: - await wait_condition_or_timeout(self.zc.async_condition, timeout) + await self.zc.async_wait(timeout) outs = self.generate_ready_queries() for out in outs: self.zc.async_send(out, addr=self.addr, port=self.port) - if not self._handlers_to_call: - continue - - (name_type, state_change) = self._handlers_to_call.popitem(False) - if self.queue: - self.queue.put((name_type, state_change)) - continue - - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) - async def _async_cancel_browser(self) -> None: """Cancel the browser.""" assert self._browser_task is not None @@ -392,8 +381,8 @@ def __init__( self, zc: 'Zeroconf', type_: Union[str, list], - handlers: Optional[Union['ServiceListener', List[Callable[..., None]]]] = None, - listener: Optional['ServiceListener'] = None, + handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, + listener: Optional[ServiceListener] = None, addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, @@ -438,10 +427,4 @@ def run(self) -> None: event = self.queue.get() if event is None: return - name_type, state_change = event - self._service_state_changed.fire( - zeroconf=self.zc, - service_type=name_type[1], - name=name_type[0], - state_change=state_change, - ) + self._fire_service_state_changed_event(event) From 1c93baa486b1b0f44487891766e0a0c1de3eb252 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 14:19:18 -1000 Subject: [PATCH 0450/1433] Update changelog (#757) --- README.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.rst b/README.rst index 3b7dbf227..a26981b4f 100644 --- a/README.rst +++ b/README.rst @@ -254,6 +254,28 @@ you can likely not be concerned with the breaking changes below: * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Simplify ServiceBrowser callsbacks (#756) @bdraco + +* Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) @bdraco + +- This guarding is no longer needed as the ServiceBrowser loop + now runs in the event loop and the thread safety guard is no + longer needed + +* Drop AsyncServiceListener (#754) @bdraco (Never shipped) + +* Run ServiceBrowser queries in the event loop (#752) @bdraco + +* Remove unused argument from AsyncZeroconf (#751) @bdraco + +* Fix warning about Zeroconf._async_notify_all not being awaited in sync shutdown (#750) @bdraco + +* Update async_service_info_request example to ensure it runs in the right event loop (#749) @bdraco + +* Run ServiceInfo requests in the event loop (#748) @bdraco + +* Remove support for notify listeners (#733) @bdraco (Never shipped) + * Relocate service browser tests to tests/services/test_browser.py (#745) @bdraco * Relocate ServiceInfo to zeroconf._services.info (#741) @bdraco From 9f68fc8b1b834d0194e8ba1069d052aa853a8d38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 14:38:41 -1000 Subject: [PATCH 0451/1433] Add missing coverage for SignalRegistrationInterface (#758) --- tests/test_services.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_services.py b/tests/test_services.py index 1a3ada236..684266f27 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -275,3 +275,18 @@ class MyPartialListener(r.ServiceListener): ) zc.close() + + +def test_signal_registration_interface(): + """Test adding and removing from the SignalRegistrationInterface.""" + + interface = r.SignalRegistrationInterface([]) + + def dummy(): + pass + + interface.register_handler(dummy) + interface.unregister_handler(dummy) + + with pytest.raises(ValueError): + interface.unregister_handler(dummy) From 936500a47cc33d9daa86f9012b1791986361ff63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 16:40:08 -1000 Subject: [PATCH 0452/1433] Add 60s timeout for each test (#761) --- Makefile | 4 ++-- requirements-dev.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index de816c1c5..6602d808e 100644 --- a/Makefile +++ b/Makefile @@ -41,10 +41,10 @@ mypy: mypy --no-warn-redundant-casts --no-warn-unused-ignores examples/*.py zeroconf test: - pytest -v tests + pytest --timeout=60 -v tests test_coverage: - pytest -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests + pytest --timeout=60 -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf diff --git a/requirements-dev.txt b/requirements-dev.txt index 325ce32a5..eef932540 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,3 +13,4 @@ pylint pytest pytest-asyncio pytest-cov +pytest-timeout From fc0e599eec77477dd8f21ecd68b238e6a27f1bcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 18:21:38 -1000 Subject: [PATCH 0453/1433] Fix race condition in ServiceBrowser test_integration (#762) - The event was being cleared in the wrong thread which meant if the test was fast enough it would not be seen the second time and give a spurious failure --- tests/services/test_browser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 342ced691..2d47e3686 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -441,7 +441,6 @@ def current_time_millis(): return time.time() * 1000 + time_offset * 1000 expected_ttl = const._DNS_HOST_TTL - nbr_answers = 0 def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): @@ -454,6 +453,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): unexpected_ttl.set() got_query.set() + got_query.clear() + old_send(out, addr=addr, port=port) # patch the zeroconf send @@ -482,13 +483,13 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # is greater than half the original TTL sleep_count = 0 test_iterations = 50 + while nbr_answers < test_iterations: # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 zeroconf_browser.notify_all() sleep_count += 1 got_query.wait(0.1) - got_query.clear() # Prevent the test running indefinitely in an error condition assert sleep_count < test_iterations * 4 assert not unexpected_ttl.is_set() From 38b59a64592f41b2bb547b35c72a010a925a2941 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 18:28:24 -1000 Subject: [PATCH 0454/1433] Fix test_lots_of_names overflowing the incoming buffer (#763) --- tests/test_init.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index 30149b846..dd330d394 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -14,6 +14,8 @@ import zeroconf as r from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, const +from . import _inject_response + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -153,7 +155,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # force receive on oversized packet zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) - time.sleep(2.0) + time.sleep(0.3) zeroconf.log.debug( 'warn %d debug %d was %s', mocked_log_warn.call_count, @@ -162,8 +164,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): ) assert mocked_log_debug.call_count > call_counts[0] - # close our zeroconf which will close the sockets - zc.close() + # close our zeroconf which will close the sockets + zc.close() def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} @@ -201,17 +203,11 @@ def verify_name_change(self, zc, type_, name, number_hosts): assert info_service2.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) def generate_many_hosts(self, zc, type_, name, number_hosts): - records_per_server = 2 block_size = 25 number_hosts = int(((number_hosts - 1) / block_size + 1)) * block_size for i in range(1, number_hosts + 1): next_name = name if i == 1 else '%s-%d' % (name, i) self.generate_host(zc, next_name, type_) - if i % block_size == 0: - sleep_count = 0 - while sleep_count < 40 and i * records_per_server > len(zc.cache.entries_with_name(type_)): - sleep_count += 1 - time.sleep(0.05) @staticmethod def generate_host(zc, host_name, type_): @@ -233,4 +229,4 @@ def generate_host(zc, host_name, type_): ), 0, ) - zc.send(out) + _inject_response(zc, r.DNSIncoming(out.packets()[0])) From 85532e13e42447fcd6d4d4b0060f04d33c3ab780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 19:00:19 -1000 Subject: [PATCH 0455/1433] Break test_lots_of_names into two tests (#764) --- tests/test_init.py | 70 +++++++++------------------------------------- 1 file changed, 13 insertions(+), 57 deletions(-) diff --git a/tests/test_init.py b/tests/test_init.py index dd330d394..0cc3baf8a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -12,7 +12,7 @@ from typing import Optional # noqa # used in type hints import zeroconf as r -from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf, const +from zeroconf import DNSOutgoing, ServiceBrowser, ServiceInfo, Zeroconf, const from . import _inject_response @@ -69,8 +69,7 @@ def test_same_name(self): generated.add_question(question) r.DNSIncoming(generated.packets()[0]) - def test_lots_of_names(self): - + def test_verify_name_change_with_lots_of_names(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) @@ -83,64 +82,21 @@ def test_lots_of_names(self): # verify that name changing works self.verify_name_change(zc, type_, name, server_count) - # we are going to patch the zeroconf send to check packet sizes - old_send = zc.async_send - - longest_packet_len = 0 - longest_packet = None # type: Optional[r.DNSOutgoing] - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): - """Sends an outgoing packet.""" - for packet in out.packets(): - nonlocal longest_packet_len, longest_packet - if longest_packet_len < len(packet): - longest_packet_len = len(packet) - longest_packet = out - old_send(out, addr=addr, port=port) - - # patch the zeroconf send - with unittest.mock.patch.object(zc, "async_send", send): - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - # start a browser - browser = ServiceBrowser(zc, type_, [on_service_state_change]) - - # wait until the browse request packet has maxed out in size - sleep_count = 0 - # we will never get to this large of a packet given the application-layer - # splitting of packets, but we still want to track the longest_packet_len - # for the debug message below - while sleep_count < 100 and longest_packet_len < const._MAX_MSG_ABSOLUTE - 100: - sleep_count += 1 - time.sleep(0.1) - - browser.cancel() - time.sleep(0.5) - - import zeroconf - - zeroconf.log.debug('sleep_count %d, sized %d', sleep_count, longest_packet_len) - - # now the browser has sent at least one request, verify the size - assert longest_packet_len <= const._MAX_MSG_TYPICAL - assert longest_packet_len >= const._MAX_MSG_TYPICAL - 100 + zc.close() - # mock zeroconf's logger warning() and debug() - from unittest.mock import patch + def test_large_packet_exception_log_handling(self): + """Verify we downgrade debug after warning.""" - patch_warn = patch('zeroconf._logger.log.warning') - patch_debug = patch('zeroconf._logger.log.debug') - mocked_log_warn = patch_warn.start() - mocked_log_debug = patch_debug.start() + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + with unittest.mock.patch('zeroconf._logger.log.warning') as mocked_log_warn, unittest.mock.patch( + 'zeroconf._logger.log.debug' + ) as mocked_log_debug: # now that we have a long packet in our possession, let's verify the # exception handling. - out = longest_packet - assert out is not None - out.data.append(b'\0' * 1000) + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + out.data.append(b'\0' * 10000) # mock the zeroconf logger and check for the correct logging backoff call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count @@ -156,7 +112,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) time.sleep(0.3) - zeroconf.log.debug( + r.log.debug( 'warn %d debug %d was %s', mocked_log_warn.call_count, mocked_log_debug.call_count, From 6c82fa9efd0f434f0f7c83e3bd98bd7851ede4cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 19:10:58 -1000 Subject: [PATCH 0456/1433] Switch to using an asyncio.Event for async_wait (#759) - We no longer need to check for thread safety under a asyncio.Condition as the ServiceBrowser and ServiceInfo internals schedule coroutines in the eventloop. --- tests/services/test_browser.py | 87 +++++++++++++++++++++++++++++++++- tests/utils/test_aio.py | 18 +++---- zeroconf/_core.py | 23 ++++----- zeroconf/_services/browser.py | 35 ++++++++------ zeroconf/_services/info.py | 2 +- zeroconf/_utils/aio.py | 14 +++--- zeroconf/aio.py | 3 +- 7 files changed, 131 insertions(+), 51 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 2d47e3686..5f2cea211 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -39,6 +39,91 @@ def teardown_module(): log.setLevel(original_logging_level) +def test_service_browser_cancel_multiple_times(): + """Test we can cancel a ServiceBrowser multiple times before close.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + + class MyServiceListener(r.ServiceListener): + pass + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + browser.cancel() + browser.cancel() + browser.cancel() + + zc.close() + + +def test_service_browser_cancel_multiple_times_after_close(): + """Test we can cancel a ServiceBrowser multiple times after close.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + + class MyServiceListener(r.ServiceListener): + pass + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + zc.close() + + browser.cancel() + browser.cancel() + browser.cancel() + + +def test_service_browser_started_after_zeroconf_closed(): + """Test starting a ServiceBrowser after close raises RuntimeError.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + + class MyServiceListener(r.ServiceListener): + pass + + listener = MyServiceListener() + zc.close() + + with pytest.raises(RuntimeError): + browser = r.ServiceBrowser(zc, type_, None, listener) + + +def test_multiple_instances_running_close(): + """Test we can shutdown multiple instances.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + zc2 = Zeroconf(interfaces=['127.0.0.1']) + zc3 = Zeroconf(interfaces=['127.0.0.1']) + + assert zc.loop != zc2.loop + assert zc.loop != zc3.loop + + class MyServiceListener(r.ServiceListener): + pass + + listener = MyServiceListener() + + zc2.add_service_listener("zca._hap._tcp.local.", listener) + + zc.close() + zc2.remove_service_listener(listener) + zc2.close() + zc3.close() + + class TestServiceBrowser(unittest.TestCase): def test_update_record(self): enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') @@ -489,7 +574,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): time_offset += expected_ttl / 4 zeroconf_browser.notify_all() sleep_count += 1 - got_query.wait(0.1) + got_query.wait(0.5) # Prevent the test running indefinitely in an error condition assert sleep_count < test_iterations * 4 assert not unexpected_ttl.is_set() diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index 1f0a1d7ef..bd402d4bd 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -24,22 +24,16 @@ def test_get_running_loop_no_loop() -> None: @pytest.mark.asyncio -async def test_wait_condition_or_timeout_times_out() -> None: - """Test wait_condition_or_timeout will timeout.""" - test_cond = asyncio.Condition() - async with test_cond: - await aioutils.wait_condition_or_timeout(test_cond, 0.1) +async def test_wait_event_or_timeout_times_out() -> None: + """Test wait_event_or_timeout will timeout.""" + test_event = asyncio.Event() + await aioutils.wait_event_or_timeout(test_event, 0.1) - async def _hold_condition(): - async with test_cond: - await test_cond.wait() - - task = asyncio.ensure_future(_hold_condition()) + task = asyncio.ensure_future(test_event.wait()) await asyncio.sleep(0.1) async def _async_wait_or_timeout(): - async with test_cond: - await aioutils.wait_condition_or_timeout(test_cond, 0.1) + await aioutils.wait_event_or_timeout(test_event, 0.1) # Test high lock contention await asyncio.gather(*[_async_wait_or_timeout() for _ in range(100)]) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 9dab6a27e..1053103c0 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -41,7 +41,7 @@ from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry -from ._utils.aio import get_running_loop, shutdown_loop, wait_condition_or_timeout +from ._utils.aio import get_running_loop, shutdown_loop, wait_event_or_timeout from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -291,7 +291,7 @@ def __init__( self.query_handler = QueryHandler(self.registry, self.cache) self.record_manager = RecordManager(self) - self.async_condition: Optional[asyncio.Condition] = None + self.notify_event: Optional[asyncio.Event] = None self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None @@ -304,7 +304,7 @@ def start(self) -> None: """Start Zeroconf.""" self.loop = get_running_loop() if self.loop: - self.async_condition = asyncio.Condition() + self.notify_event = asyncio.Event() self.engine.setup(self.loop, None) return self._start_thread() @@ -316,7 +316,7 @@ def _start_thread(self) -> None: def _run_loop() -> None: self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) - self.async_condition = asyncio.Condition() + self.notify_event = asyncio.Event() self.engine.setup(self.loop, loop_thread_ready) self.loop.run_forever() @@ -343,9 +343,8 @@ def wait(self, timeout: float) -> None: async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - assert self.async_condition is not None - async with self.async_condition: - await wait_condition_or_timeout(self.async_condition, millis_to_seconds(timeout)) + assert self.notify_event is not None + await wait_event_or_timeout(self.notify_event, timeout=millis_to_seconds(timeout)) def notify_all(self) -> None: """Notifies all waiting threads and notify listeners.""" @@ -354,13 +353,9 @@ def notify_all(self) -> None: def async_notify_all(self) -> None: """Schedule an async_notify_all.""" - asyncio.ensure_future(self._async_notify_all()) - - async def _async_notify_all(self) -> None: - """Notify all async listeners.""" - assert self.async_condition is not None - async with self.async_condition: - self.async_condition.notify_all() + assert self.notify_event is not None + self.notify_event.set() + self.notify_event.clear() def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: """Returns network's service information for a particular diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index b03003b17..296662d62 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -21,6 +21,7 @@ """ import asyncio +import concurrent.futures import contextlib import queue import threading @@ -352,7 +353,6 @@ def _millis_to_wait(self, now: float) -> Optional[float]: async def async_browser_task(self) -> None: """Run the browser task.""" await self.zc.async_wait_for_start() - assert self.zc.async_condition is not None while True: timeout = self._millis_to_wait(current_time_millis()) if timeout: @@ -366,8 +366,9 @@ async def _async_cancel_browser(self) -> None: """Cancel the browser.""" assert self._browser_task is not None self._browser_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._browser_task + browser_task = self._browser_task + self._browser_task = None + await browser_task class ServiceBrowser(_ServiceBrowserBase, threading.Thread): @@ -391,19 +392,21 @@ def __init__( super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) self.queue = get_best_available_queue() self.daemon = True + assert self.zc.loop is not None + if get_running_loop() == self.zc.loop: + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + else: + if not self.zc.loop.is_running(): + raise RuntimeError("The event loop is not running") + self._browser_task = cast( + asyncio.Task, + asyncio.run_coroutine_threadsafe(self._async_browser_task(), self.zc.loop).result(), + ) self.start() self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) - assert self.zc.loop is not None - if get_running_loop() == self.zc.loop: - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) - return - self._browser_task = cast( - asyncio.Task, - asyncio.run_coroutine_threadsafe(self._async_browser_task(), self.zc.loop).result(), - ) async def _async_browser_task(self) -> asyncio.Task: return cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) @@ -413,10 +416,12 @@ def cancel(self) -> None: assert self.zc.loop is not None assert self.queue is not None self.queue.put(None) - if get_running_loop() == self.zc.loop: - asyncio.ensure_future(self._async_cancel_browser()) - else: - asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() + if self._browser_task: + if get_running_loop() == self.zc.loop: + asyncio.ensure_future(self._async_cancel_browser()) + elif self.zc.loop.is_running(): + with contextlib.suppress(asyncio.CancelledError, concurrent.futures.CancelledError): + asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() super().cancel() self.join() diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 5a3319270..aa457ffd0 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -397,7 +397,7 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ - assert zc.loop is not None + assert zc.loop is not None and zc.loop.is_running() return asyncio.run_coroutine_threadsafe(self.async_request(zc, timeout), zc.loop).result() async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index d4adffa8e..726acea48 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -34,8 +34,8 @@ def get_best_available_queue() -> queue.Queue: # Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed -async def wait_condition_or_timeout(condition: asyncio.Condition, timeout: float) -> None: - """Wait for a condition or timeout.""" +async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: + """Wait for an event or timeout.""" loop = asyncio.get_event_loop() future = loop.create_future() @@ -44,22 +44,22 @@ def _handle_timeout() -> None: future.set_result(None) timer_handle = loop.call_later(timeout, _handle_timeout) - condition_wait = loop.create_task(condition.wait()) + event_wait = loop.create_task(event.wait()) def _handle_wait_complete(_: asyncio.Task) -> None: if not future.done(): future.set_result(None) - condition_wait.add_done_callback(_handle_wait_complete) + event_wait.add_done_callback(_handle_wait_complete) try: await future finally: timer_handle.cancel() - if not condition_wait.done(): - condition_wait.cancel() + if not event_wait.done(): + event_wait.cancel() with contextlib.suppress(asyncio.CancelledError): - await condition_wait + await event_wait async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 1f3e43523..7a107fcc4 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -76,7 +76,8 @@ def __init__( async def async_cancel(self) -> None: """Cancel the browser.""" - await self._async_cancel_browser() + with contextlib.suppress(asyncio.CancelledError): + await self._async_cancel_browser() super().cancel() From e70431e1fdc92c155309a1d40c89fed48737970c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jun 2021 22:10:28 -1000 Subject: [PATCH 0457/1433] Add test coverage to ensure RecordManager.add_listener callsback known question answers (#767) --- tests/test_handlers.py | 45 ++++++++++++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index b86d253d0..36f252969 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -11,6 +11,7 @@ import time import unittest import unittest.mock +from typing import List import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, current_time_millis @@ -915,3 +916,47 @@ async def test_cache_flush_bit(): assert loaded_info.addresses == info.addresses await aiozc.async_close() + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_record_update_manager_add_listener_callsback_existing_records(): + """Test that the RecordUpdateManager will callback existing records.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc: Zeroconf = aiozc.zeroconf + updated = [] + + class MyListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRecord]) -> None: + """Update multiple records in one shot.""" + updated.extend(records) + + type_ = "_cacheflush._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "server-uu1.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + a_record = info.dns_addresses()[0] + ptr_record = info.dns_pointer() + zc.cache.async_add_records([ptr_record, a_record, info.dns_text(), info.dns_service()]) + + listener = MyListener() + + zc.add_listener( + listener, + [ + r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN), + r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN), + ], + ) + await asyncio.sleep(0) # flush out the call_soon_threadsafe + + assert set(updated) == set([ptr_record, a_record]) + await aiozc.async_close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 2a58850d2..08c25e17e 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -386,7 +386,6 @@ def add_listener( self.listeners.append(listener) if question is None: - self.zc.notify_all() return questions = [question] if isinstance(question, DNSQuestion) else question @@ -406,6 +405,7 @@ def _async_update_matching_records( for record in self.cache.async_entries_with_name(question.name): if not record.is_expired(now) and question.answered_by(record): records.append(record) + if not records: return listener.async_update_records(self.zc, now, records) From 5d44a36a59c21ef7869ba9e6dde9f658d3502793 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:26:55 -1000 Subject: [PATCH 0458/1433] Improve performance of parsing DNSIncoming by caching read_utf (#769) --- zeroconf/_protocol.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 80ca7b886..b0b87a060 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -22,6 +22,7 @@ import enum import struct +from functools import lru_cache from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast @@ -207,6 +208,7 @@ def read_others(self) -> None: if rec is not None: self.answers.append(rec) + @lru_cache(maxsize=None) def read_utf(self, offset: int, length: int) -> str: """Reads a UTF-8 string of a given length from the packet""" return str(self.data[offset : offset + length], 'utf-8', 'replace') From b600547a47878775e1c6fb8df46682a670beccba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:13:33 -1000 Subject: [PATCH 0459/1433] Implement accidental synchronization protection (RFC2762 section 5.2) (#773) --- tests/services/test_browser.py | 46 ++++++++++++++++++++++++++++++++-- zeroconf/_services/browser.py | 26 ++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 5f2cea211..df405ac22 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -14,7 +14,7 @@ import pytest import zeroconf as r -from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis +from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis, millis_to_seconds import zeroconf._services.browser as _services_browser from zeroconf import Zeroconf from zeroconf._services import ServiceStateChange @@ -453,7 +453,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the backoff limit to prevent test running forever with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): + ), unittest.mock.patch.object( + _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 + ), unittest.mock.patch.object( + _services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0) + ): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -498,6 +502,44 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() +def test_first_query_delay(): + """Verify the first query is delayed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + """ + type_ = "_http._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.async_send + + first_query_time = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_query_time + if first_query_time is None: + first_query_time = current_time_millis() + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + start_time = current_time_millis() + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + try: + assert ( + current_time_millis() - start_time > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] + ) + finally: + browser.cancel() + zeroconf_browser.close() + + def test_integration(): service_added = Event() service_removed = Event() diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 296662d62..964998a2c 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -24,6 +24,7 @@ import concurrent.futures import contextlib import queue +import random import threading import warnings from collections import OrderedDict @@ -41,7 +42,7 @@ ) from .._utils.aio import get_best_available_queue, get_running_loop from .._utils.name import service_type_name -from .._utils.time import current_time_millis +from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -56,6 +57,8 @@ _TYPE_PTR, ) +# https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 +_FIRST_QUERY_DELAY_RANDOM_INTERVAL = (20, 120) # ms if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 @@ -190,14 +193,15 @@ def __init__( self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - current_time = current_time_millis() - self._next_time = {check_type_: current_time for check_type_ in self.types} - self._delay = {check_type_: delay for check_type_ in self.types} + self._next_time: Dict[str, float] = {} + self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self.types} self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.queue: Optional[queue.Queue] = None self.done = False + self._generate_first_next_time() + if hasattr(handlers, 'add_service'): listener = cast('ServiceListener', handlers) handlers = None @@ -212,6 +216,20 @@ def __init__( self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + def _generate_first_next_time(self) -> None: + """Generate the initial next query times. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + To avoid accidental synchronization when, for some reason, multiple + clients begin querying at exactly the same moment (e.g., because of + some common external trigger event), a Multicast DNS querier SHOULD + also delay the first query of the series by a randomly chosen amount + in the range 20-120 ms. + """ + delay = millis_to_seconds(random.randint(*_FIRST_QUERY_DELAY_RANDOM_INTERVAL)) + next_time = current_time_millis() + delay + self._next_time = {check_type_: next_time for check_type_ in self.types} + @property def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface From f23df4f5f05e3911cbf96234b198ea88691aadad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 12:13:45 -1000 Subject: [PATCH 0460/1433] Verify async callers can still use Zeroconf without migrating to AsyncZeroconf (#775) --- tests/test_core.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 13c6ec706..87979884a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -569,3 +569,22 @@ async def make_query(): # unregister zc.registry.remove(info) zc.close() + + +@pytest.mark.asyncio +async def test_open_close_twice_from_async() -> None: + """Test we can close twice from a coroutine when using Zeroconf. + + Ideally callers switch to using AsyncZeroconf, however there will + be a peroid where they still call the sync wrapper that we want + to ensure will not deadlock on shutdown. + + This test is expected to throw warnings about tasks being destroyed + since we force shutdown right away since we don't want to block + callers event loops and since they aren't using the AsyncZeroconf + version they won't yield with an await like async_close we don't + have much choice but to force things down. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + zc.close() + zc.close() From e8836b134c47080edaf47532d7cb844b307dfb08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 12:24:26 -1000 Subject: [PATCH 0461/1433] Add a guard against the task list changing when shutting down (#776) --- zeroconf/_utils/aio.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index 726acea48..fcf71c349 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -23,7 +23,7 @@ import asyncio import contextlib import queue -from typing import Optional, Set, cast +from typing import List, Optional, Set, cast def get_best_available_queue() -> queue.Queue: @@ -62,12 +62,13 @@ def _handle_wait_complete(_: asyncio.Task) -> None: await event_wait -async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: +async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio.Task]: """Return all tasks running.""" await asyncio.sleep(0) # flush out any call_soon_threadsafe + # Make a copy of the tasks in case they change during iteration if hasattr(asyncio, 'all_tasks'): - return cast(Set[asyncio.Task], asyncio.all_tasks(loop)) # type: ignore # pylint: disable=no-member - return cast(Set[asyncio.Task], asyncio.Task.all_tasks(loop)) # type: ignore # pylint: disable=no-member + return list(asyncio.all_tasks(loop)) # type: ignore # pylint: disable=no-member + return list(asyncio.Task.all_tasks(loop)) # type: ignore # pylint: disable=no-member async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: @@ -77,7 +78,7 @@ async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" - pending_tasks = asyncio.run_coroutine_threadsafe(_get_all_tasks(loop), loop).result() + pending_tasks = set(asyncio.run_coroutine_threadsafe(_get_all_tasks(loop), loop).result()) done_tasks = set(task for task in pending_tasks if not task.done()) pending_tasks -= done_tasks if pending_tasks: From b5d54e485d9dbcde1b7b472760a0b307198b8ec8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 12:35:53 -1000 Subject: [PATCH 0462/1433] Fix deadlock on ServiceBrowser shutdown with PyPy (#774) --- zeroconf/_services/browser.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 964998a2c..0bc926861 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -21,7 +21,6 @@ """ import asyncio -import concurrent.futures import contextlib import queue import random @@ -40,7 +39,7 @@ Signal, SignalRegistrationInterface, ) -from .._utils.aio import get_best_available_queue, get_running_loop +from .._utils.aio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( @@ -386,7 +385,8 @@ async def _async_cancel_browser(self) -> None: self._browser_task.cancel() browser_task = self._browser_task self._browser_task = None - await browser_task + with contextlib.suppress(asyncio.CancelledError): + await browser_task class ServiceBrowser(_ServiceBrowserBase, threading.Thread): @@ -411,35 +411,30 @@ def __init__( self.queue = get_best_available_queue() self.daemon = True assert self.zc.loop is not None - if get_running_loop() == self.zc.loop: - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) - else: - if not self.zc.loop.is_running(): - raise RuntimeError("The event loop is not running") - self._browser_task = cast( - asyncio.Task, - asyncio.run_coroutine_threadsafe(self._async_browser_task(), self.zc.loop).result(), - ) + if not self.zc.loop.is_running(): + raise RuntimeError("The event loop is not running") + self.zc.loop.call_soon_threadsafe(self._async_start_browser) self.start() self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) - async def _async_browser_task(self) -> asyncio.Task: - return cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + def _async_start_browser(self) -> None: + """Start the browser from the event loop.""" + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + + def _async_cancel_browser_soon(self) -> None: + """Cancel the browser from the event loop.""" + if self._browser_task: + asyncio.ensure_future(self._async_cancel_browser()) def cancel(self) -> None: """Cancel the browser.""" assert self.zc.loop is not None assert self.queue is not None self.queue.put(None) - if self._browser_task: - if get_running_loop() == self.zc.loop: - asyncio.ensure_future(self._async_cancel_browser()) - elif self.zc.loop.is_running(): - with contextlib.suppress(asyncio.CancelledError, concurrent.futures.CancelledError): - asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() + self.zc.loop.call_soon_threadsafe(self._async_cancel_browser_soon) super().cancel() self.join() From c0f4f48e2bb996ce18cb569aa5369356cbc919ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 12:41:00 -1000 Subject: [PATCH 0463/1433] Implement duplicate question supression (#770) https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 --- tests/__init__.py | 1 + tests/services/test_browser.py | 46 ++++++++++++++++++++- tests/test_core.py | 16 ++++++++ tests/test_handlers.py | 50 +++++++++++++++++++++++ tests/test_history.py | 75 ++++++++++++++++++++++++++++++++++ zeroconf/_core.py | 5 ++- zeroconf/_dns.py | 17 ++++---- zeroconf/_handlers.py | 10 +++-- zeroconf/_history.py | 70 +++++++++++++++++++++++++++++++ zeroconf/_services/browser.py | 8 +++- zeroconf/const.py | 1 + 11 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 tests/test_history.py create mode 100644 zeroconf/_history.py diff --git a/tests/__init__.py b/tests/__init__.py index 86d7e1994..0e7aa930b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -65,3 +65,4 @@ def has_working_ipv6(): def _clear_cache(zc): zc.cache.cache.clear() + zc.question_history._history.clear() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index df405ac22..69c23cb0b 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -20,6 +20,7 @@ from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo +from zeroconf.aio import AsyncZeroconf from .. import has_working_ipv6, _inject_response @@ -426,7 +427,8 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() -def test_backoff(): +@unittest.mock.patch("zeroconf._core.QuestionHistory.suppresses", return_value=False) +def test_backoff(suppresses_mock): got_query = Event() type_ = "_http._tcp.local." @@ -902,3 +904,45 @@ def test_group_ptr_queries_with_known_answers(): # If we generate multiple packets there must # only be one question assert len(packets) == 1 or len(out.questions) == 1 + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_generate_service_query_suppress_duplicate_questions(): + """Generate a service query for sending with zeroconf.send.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + now = current_time_millis() + name = "_hap._tcp.local." + question = r.DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN) + answer = r.DNSPointer( + name, + const._TYPE_PTR, + const._CLASS_IN, + 10000, + f'known-to-other.{name}', + ) + other_known_answers = set([answer]) + zc.question_history.add_question_at_time(question, now, other_known_answers) + assert zc.question_history.suppresses(question, now, other_known_answers) + + # The known answer list is different, do not suppress + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert outs + + zc.cache.async_add_records([answer]) + # The known answer list contains all the asked questions in the history + # we should suppress + + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert not outs + + # We do not suppress once the question history expires + outs = _services_browser.generate_service_query(zc, now + 1000, [name], multicast=True) + assert outs + + # We do not suppress QU queries ever + outs = _services_browser.generate_service_query(zc, now, [name], multicast=False) + assert outs + await aiozc.async_close() diff --git a/tests/test_core.py b/tests/test_core.py index 87979884a..6447bbf3c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -50,10 +50,26 @@ async def test_reaper(): record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + 'known-to-other._hap._tcp.local.', + ) + ] + ) + zeroconf.question_history.add_question_at_time(question, now, other_known_answers) + assert zeroconf.question_history.suppresses(question, now, other_known_answers) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) await asyncio.sleep(1.2) entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) await aiozc.async_close() + assert not zeroconf.question_history.suppresses(question, now, other_known_answers) assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 36f252969..d77db3f03 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -960,3 +960,53 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRe assert set(updated) == set([ptr_record, a_record]) await aiozc.async_close() + + +def test_questions_query_handler_populates_the_question_history_from_qm_questions(): + zc = Zeroconf(interfaces=['127.0.0.1']) + now = current_time_millis() + _clear_cache(zc) + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question.unicast = False + known_answer = r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + generated.add_question(question) + generated.add_answer_at_time(known_answer, 0) + now = r.current_time_millis() + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + assert zc.question_history.suppresses(question, now, set([known_answer])) + + zc.close() + + +def test_questions_query_handler_does_not_put_qu_questions_in_history(): + zc = Zeroconf(interfaces=['127.0.0.1']) + now = current_time_millis() + _clear_cache(zc) + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question.unicast = True + known_answer = r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + generated.add_question(question) + generated.add_answer_at_time(known_answer, 0) + now = r.current_time_millis() + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + assert not zc.question_history.suppresses(question, now, set([known_answer])) + + zc.close() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 000000000..89159dff6 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for _history.py.""" + +from zeroconf._history import QuestionHistory +import zeroconf as r +import zeroconf.const as const + + +def test_question_suppression(): + history = QuestionHistory() + + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + ] + ) + our_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' + ) + ] + ) + + history.add_question_at_time(question, now, other_known_answers) + + # Verify the question is suppressed if the known answers are the same + assert history.suppresses(question, now, other_known_answers) + + # Verify the question is suppressed if we know the answer to all the known answers + assert history.suppresses(question, now, other_known_answers | our_known_answers) + + # Verify the question is not suppressed if our known answers do no include the ones in the last question + assert not history.suppresses(question, now, set()) + + # Verify the question is not suppressed if our known answers do no include the ones in the last question + assert not history.suppresses(question, now, our_known_answers) + + # Verify the question is no longer suppressed after 1s + assert not history.suppresses(question, now + 1000, other_known_answers) + + +def test_question_expire(): + history = QuestionHistory() + + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + ] + ) + history.add_question_at_time(question, now, other_known_answers) + + # Verify the question is suppressed if the known answers are the same + assert history.suppresses(question, now, other_known_answers) + + history.async_expire(now) + + # Verify the question is suppressed if the known answers are the same since the cache hasn't expired + assert history.suppresses(question, now, other_known_answers) + + history.async_expire(now + 1000) + + # Verify the question not longer suppressed since the cache has expired + assert not history.suppresses(question, now, other_known_answers) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1053103c0..83395f05e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -35,6 +35,7 @@ from ._dns import DNSQuestion from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager +from ._history import QuestionHistory from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener, ServiceListener @@ -134,6 +135,7 @@ async def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" while not self.zc.done: now = current_time_millis() + self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates(now, self.zc.cache.async_expire(now)) self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) @@ -288,7 +290,8 @@ def __init__( self.browsers: Dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() self.cache = DNSCache() - self.query_handler = QueryHandler(self.registry, self.cache) + self.question_history = QuestionHistory() + self.query_handler = QueryHandler(self.registry, self.cache, self.question_history) self.record_manager = RecordManager(self) self.notify_event: Optional[asyncio.Event] = None diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index e656bc519..0fad150dd 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -414,18 +414,19 @@ def __init__(self, records: Iterable[DNSRecord]) -> None: self._records = records self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None - def suppresses(self, record: DNSRecord) -> bool: - """Returns true if any answer in the rrset can suffice for the - information held in this record.""" + @property + def lookup(self) -> Dict[DNSRecord, DNSRecord]: if self._lookup is None: # Build the hash table so we can lookup the record independent of the ttl self._lookup = {record: record for record in self._records} - other = self._lookup.get(record) + return self._lookup + + def suppresses(self, record: DNSRecord) -> bool: + """Returns true if any answer in the rrset can suffice for the + information held in this record.""" + other = self.lookup.get(record) return bool(other and other.ttl > (record.ttl / 2)) def __contains__(self, record: DNSRecord) -> bool: """Returns true if the rrset contains the record.""" - if self._lookup is None: - # Build the hash table so we can lookup the record independent of the ttl - self._lookup = {record: record for record in self._records} - return record in self._lookup + return record in self.lookup diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 08c25e17e..79bb6f903 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -21,10 +21,11 @@ """ import itertools -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from ._cache import DNSCache, _UniqueRecordsType from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord +from ._history import QuestionHistory from ._logger import log from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener @@ -156,10 +157,11 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" - def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: + def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: """Init the query handler.""" self.registry = registry self.cache = cache + self.question_history = question_history def _add_service_type_enumeration_query_answers( self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float @@ -253,6 +255,8 @@ def async_response( # pylint: disable=unused-argument for msg in msgs: for question in msg.questions: + if not question.unicast: + self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) answer_set: _AnswerWithAdditionalsType = {} self._answer_question(question, answer_set, known_answers, msg.now) if not ucast_source and question.unicast: @@ -364,7 +368,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: self.async_updates_complete() def _async_mark_unique_cached_records_older_than_1s_to_expire( - self, unique_types: Set[Tuple[str, int, int]], answers: List[DNSRecord], now: float + self, unique_types: Set[Tuple[str, int, int]], answers: Iterable[DNSRecord], now: float ) -> None: # rfc6762#section-10.2 para 2 # Since unique is set, all old records with that name, rrtype, diff --git a/zeroconf/_history.py b/zeroconf/_history.py new file mode 100644 index 000000000..cbb36144a --- /dev/null +++ b/zeroconf/_history.py @@ -0,0 +1,70 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from typing import Dict, Set, Tuple + +from ._dns import DNSQuestion, DNSRecord +from .const import _DUPLICATE_QUESTION_INTERVAL + +# The QuestionHistory is used to implement Duplicate Question Suppression +# https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + + +class QuestionHistory: + def __init__(self) -> None: + self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} + + def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: + """Remember a question with known answers.""" + self._history[question] = (now, known_answers) + + def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + """Check to see if a question should be suppressed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + When multiple queriers on the network are querying + for the same resource records, there is no need for them to all be + repeatedly asking the same question. + """ + previous_question = self._history.get(question) + # There was not previous question in the history + if not previous_question: + return False + than, previous_known_answers = previous_question + # The last question was older than 999ms + if now - than > _DUPLICATE_QUESTION_INTERVAL: + return False + # The last question has more known answers than + # we knew so we have to ask + if previous_known_answers - known_answers: + return False + return True + + def async_expire(self, now: float) -> None: + """Expire the history of old questions.""" + removes = [ + question + for question, now_known_answers in self._history.items() + if now - now_known_answers[0] > _DUPLICATE_QUESTION_INTERVAL + ] + for question in removes: + del self._history[question] diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 0bc926861..567ef2b68 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -31,6 +31,7 @@ from .._cache import _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord +from .._logger import log from .._protocol import DNSOutgoing from .._services import ( RecordUpdateListener, @@ -135,11 +136,16 @@ def generate_service_query( questions_with_known_answers: _QuestionWithKnownAnswers = {} for type_ in types_: question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) - questions_with_known_answers[question] = set( + known_answers = set( cast(DNSPointer, record) for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) ) + if multicast and zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): + log.debug("Asking %s was suppressed by the question history", question) + continue + questions_with_known_answers[question] = known_answers + return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) diff --git a/zeroconf/const.py b/zeroconf/const.py index ba9d5309b..df1ba8be5 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -30,6 +30,7 @@ _REGISTER_TIME = 225 # ms _LISTENER_TIME = 200 # ms _BROWSER_TIME = 1000 # ms +_DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10000 # ms From ac9f72a986ae314af0043cae6fb6219baabea7e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 14:25:29 -1000 Subject: [PATCH 0464/1433] Fix Responding to Address Queries (RFC6762 section 6.2) (#777) --- tests/test_handlers.py | 53 ++++++++++++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 12 +++++++--- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index d77db3f03..ccb2fcf57 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -16,8 +16,10 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const +from zeroconf._dns import DNSRRSet from zeroconf.aio import AsyncZeroconf + from . import _clear_cache, _inject_response log = logging.getLogger('zeroconf') @@ -325,6 +327,57 @@ def test_aaaa_query(): zc.close() +def test_a_and_aaaa_record_fate_sharing(): + """Test that queries for AAAA always return A records in the additionals.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_a-and-aaaa-service._tcp.local." + name = "knownname" + registration_name = "%s.%s" % (name, type_) + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + ipv4_address = socket.inet_aton("10.0.1.2") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address, ipv4_address]) + aaaa_record = info.dns_addresses(version=r.IPVersion.V6Only)[0] + a_record = info.dns_addresses(version=r.IPVersion.V4Only)[0] + + zc.registry.add(info) + + # Test AAAA query + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + _, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + answers = DNSRRSet([answer[0] for answer in multicast_out.answers]) + additionals = DNSRRSet(multicast_out.additionals) + assert aaaa_record in answers + assert a_record in additionals + assert len(multicast_out.answers) == 1 + assert len(multicast_out.additionals) == 1 + + # Test A query + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + _, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + answers = DNSRRSet([answer[0] for answer in multicast_out.answers]) + additionals = DNSRRSet(multicast_out.additionals) + + assert a_record in answers + assert aaaa_record in additionals + assert len(multicast_out.answers) == 1 + assert len(multicast_out.additionals) == 1 + # unregister + zc.registry.remove(info) + zc.close() + + def test_unicast_response(): """Ensure we send a unicast response when the source port is not the MDNS port.""" # instantiate a zeroconf instance diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 79bb6f903..9caae3a24 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -204,9 +204,15 @@ def _add_address_answers( ) -> None: """Answer A/AAAA/ANY question.""" for service in self.registry.get_infos_server(name): - for dns_address in service.dns_addresses(version=_TYPE_TO_IP_VERSION[type_], created=now): - if not known_answers.suppresses(dns_address): - answer_set[dns_address] = set() + answers: List[DNSAddress] = [] + additionals: Set[DNSRecord] = set() + for dns_address in service.dns_addresses(created=now): + if dns_address.type != type_: + additionals.add(dns_address) + elif not known_answers.suppresses(dns_address): + answers.append(dns_address) + for answer in answers: + answer_set[answer] = additionals def _answer_question( self, From 767ae8f6cd92493f8f43d66edc70c8fd856ed11e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 14:43:07 -1000 Subject: [PATCH 0465/1433] Reformat test_handlers (#780) --- tests/test_handlers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ccb2fcf57..522c71d65 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -337,7 +337,9 @@ def test_a_and_aaaa_record_fate_sharing(): server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") ipv4_address = socket.inet_aton("10.0.1.2") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address, ipv4_address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address, ipv4_address] + ) aaaa_record = info.dns_addresses(version=r.IPVersion.V6Only)[0] a_record = info.dns_addresses(version=r.IPVersion.V4Only)[0] @@ -373,7 +375,7 @@ def test_a_and_aaaa_record_fate_sharing(): assert aaaa_record in additionals assert len(multicast_out.answers) == 1 assert len(multicast_out.additionals) == 1 - # unregister + # unregister zc.registry.remove(info) zc.close() From 7aeafbf3b990ab671ff691b6c20cd410f69808bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 14:43:16 -1000 Subject: [PATCH 0466/1433] Switch to using a simple cache instead of lru_cache (#779) --- zeroconf/_protocol.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index b0b87a060..f31750047 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -22,7 +22,6 @@ import enum import struct -from functools import lru_cache from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast @@ -94,6 +93,7 @@ def __init__(self, data: bytes) -> None: self.num_additionals = 0 self.valid = False self.now = current_time_millis() + self._utf_cache: Dict[Tuple[int, int], str] = {} try: self.read_header() @@ -208,10 +208,15 @@ def read_others(self) -> None: if rec is not None: self.answers.append(rec) - @lru_cache(maxsize=None) def read_utf(self, offset: int, length: int) -> str: """Reads a UTF-8 string of a given length from the packet""" - return str(self.data[offset : offset + length], 'utf-8', 'replace') + key = (offset, length) + cached = self._utf_cache.get(key) + if cached is not None: + return cached + decoded = str(self.data[offset : offset + length], 'utf-8', 'replace') + self._utf_cache[key] = decoded + return decoded def read_name(self) -> str: """Reads a domain name from the packet""" From 1b873436e2d9ff36876a71c48fa697d277fd3ffa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 14:51:19 -1000 Subject: [PATCH 0467/1433] Drop utf cache from _dns (#781) - The cache did not make enough difference to justify the additional complexity after additional testing was done --- zeroconf/_protocol.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index f31750047..80ca7b886 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -93,7 +93,6 @@ def __init__(self, data: bytes) -> None: self.num_additionals = 0 self.valid = False self.now = current_time_millis() - self._utf_cache: Dict[Tuple[int, int], str] = {} try: self.read_header() @@ -210,13 +209,7 @@ def read_others(self) -> None: def read_utf(self, offset: int, length: int) -> str: """Reads a UTF-8 string of a given length from the packet""" - key = (offset, length) - cached = self._utf_cache.get(key) - if cached is not None: - return cached - decoded = str(self.data[offset : offset + length], 'utf-8', 'replace') - self._utf_cache[key] = decoded - return decoded + return str(self.data[offset : offset + length], 'utf-8', 'replace') def read_name(self) -> str: """Reads a domain name from the packet""" From 3be1bc84bff5ee2840040ddff41185b257a1055c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 15:26:25 -1000 Subject: [PATCH 0468/1433] Inline utf8 decoding when processing incoming packets (#782) --- zeroconf/_protocol.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 80ca7b886..53a7b710c 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -207,10 +207,6 @@ def read_others(self) -> None: if rec is not None: self.answers.append(rec) - def read_utf(self, offset: int, length: int) -> str: - """Reads a UTF-8 string of a given length from the packet""" - return str(self.data[offset : offset + length], 'utf-8', 'replace') - def read_name(self) -> str: """Reads a domain name from the packet""" result = '' @@ -218,6 +214,7 @@ def read_name(self) -> str: next_ = -1 first = off + # This is a tight loop that is called frequently, small optimizations can make a difference. while True: length = self.data[off] off += 1 @@ -225,7 +222,8 @@ def read_name(self) -> str: break t = length & 0xC0 if t == 0x00: - result += self.read_utf(off, length) + '.' + # Convert to utf-8 + result += str(self.data[off : off + length], 'utf-8', 'replace') + '.' off += length elif t == 0xC0: if next_ < 0: From dd85ae7defd3f195ed0511a2fdb6512326ca0562 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 15:28:30 -1000 Subject: [PATCH 0469/1433] Add a guard to prevent running ServiceInfo.request in async context (#784) * Add a guard to prevent running ServiceInfo.request in async context * test --- tests/test_aio.py | 10 ++++++++++ zeroconf/_services/info.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/tests/test_aio.py b/tests/test_aio.py index 327ccc660..4cb7af2e6 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -597,3 +597,13 @@ async def test_async_zeroconf_service_types(): finally: await zeroconf_registrar.async_close() + + +@pytest.mark.asyncio +async def test_guard_against_running_serviceinfo_request_event_loop() -> None: + """Test that running ServiceInfo.request from the event loop throws.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + + service_info = AsyncServiceInfo("_hap._tcp.local.", "doesnotmatter._hap._tcp.local.") + with pytest.raises(RuntimeError): + service_info.request(aiozc.zeroconf, 3000) diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index aa457ffd0..6fccb6b70 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -28,6 +28,7 @@ from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing from .._services import RecordUpdateListener +from .._utils.aio import get_running_loop from .._utils.name import service_type_name from .._utils.net import ( IPVersion, @@ -398,6 +399,8 @@ def request(self, zc: 'Zeroconf', timeout: float) -> bool: network, and updates this object with details discovered. """ assert zc.loop is not None and zc.loop.is_running() + if zc.loop == get_running_loop(): + raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") return asyncio.run_coroutine_threadsafe(self.async_request(zc, timeout), zc.loop).result() async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: From 97f5b502815075f2ff29bee3ace7cde6ad725dfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 15:57:55 -1000 Subject: [PATCH 0470/1433] Ensure the queue is created before adding listeners to ServiceBrowser (#785) * Ensure the queue is created before adding listeners to ServiceBrowser - The callback from the listener could generate an event that would fire in async context that should have gone to the queue which could result in the consumer running a sync call in the event loop and blocking it. * add comments * add comments * add comments * add comments * black --- zeroconf/_services/browser.py | 22 ++++++++++++++++------ zeroconf/aio.py | 2 ++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 567ef2b68..655d046a3 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -205,8 +205,6 @@ def __init__( self.queue: Optional[queue.Queue] = None self.done = False - self._generate_first_next_time() - if hasattr(handlers, 'add_service'): listener = cast('ServiceListener', handlers) handlers = None @@ -219,6 +217,13 @@ def __init__( for h in handlers: self.service_state_changed.register_handler(h) + def _setup(self) -> None: + """Generate the next time and setup listeners. + + Must be called by uses of this base class after they + have finished setting their properties. + """ + self._generate_first_next_time() self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) def _generate_first_next_time(self) -> None: @@ -412,15 +417,20 @@ def __init__( port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, ) -> None: + assert zc.loop is not None + if not zc.loop.is_running(): + raise RuntimeError("The event loop is not running") threading.Thread.__init__(self) super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + # Add the queue before the listener is installed in _setup + # to ensure that events run in the dedicated thread and do + # not block the event loop self.queue = get_best_available_queue() self.daemon = True - assert self.zc.loop is not None - if not self.zc.loop.is_running(): - raise RuntimeError("The event loop is not running") - self.zc.loop.call_soon_threadsafe(self._async_start_browser) self.start() + self._setup() + # Start queries after the listener is installed in _setup + zc.loop.call_soon_threadsafe(self._async_start_browser) self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 7a107fcc4..94fa82bb8 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -72,6 +72,8 @@ def __init__( delay: int = _BROWSER_TIME, ) -> None: super().__init__(zeroconf, type_, handlers, listener, addr, port, delay) + self._setup() + # Start queries after the listener is installed in _setup self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) async def async_cancel(self) -> None: From 3b3ecf09d2f30ee39c6c29b4d85e000577b2c4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 16:03:24 -1000 Subject: [PATCH 0471/1433] Update changelog (#786) --- README.rst | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.rst b/README.rst index a26981b4f..c9e23afb2 100644 --- a/README.rst +++ b/README.rst @@ -236,6 +236,10 @@ you can likely not be concerned with the breaking changes below: * TRAFFIC REDUCTION: Efficiently bucket queries with known answers (#698) @bdraco +* TRAFFIC REDUCTION: Implement duplicate question supression (#770) @bdraco + + http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + * MAJOR BUG: Ensure matching PTR queries are returned with the ANY query (#618) @bdraco * MAJOR BUG: Fix lookup of uppercase names in registry (#597) @bdraco @@ -254,6 +258,35 @@ you can likely not be concerned with the breaking changes below: * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco +* Switch to using an asyncio.Event for async_wait (#759) @bdraco + + We no longer need to check for thread safety under a asyncio.Condition + as the ServiceBrowser and ServiceInfo internals schedule coroutines + in the eventloop. + +* Ensure the queue is created before adding listeners to ServiceBrowser (#785) @bdraco + + The callback from the listener could generate an event that would + fire in async context that should have gone to the queue which + could result in the consumer running a sync call in the event loop + and blocking it. + +* Add a guard to prevent running ServiceInfo.request in async context (#784) @bdraco + +* Inline utf8 decoding when processing incoming packets (#782) @bdraco + +* Drop utf cache from _dns (#781) (later reverted) @bdraco + +* Switch to using a simple cache instead of lru_cache (#779) (later reverted) @bdraco + +* Fix Responding to Address Queries (RFC6762 section 6.2) (#777) @bdraco + +* Fix deadlock on ServiceBrowser shutdown with PyPy (#774) @bdraco + +* Add a guard against the task list changing when shutting down (#776) @bdraco + +* Improve performance of parsing DNSIncoming by caching read_utf (#769) (later reverted) @bdraco + * Simplify ServiceBrowser callsbacks (#756) @bdraco * Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) @bdraco From 135983cb96a27e3ad3750234286d1d9bfa6ff44f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 18:26:13 -1000 Subject: [PATCH 0472/1433] Add support for requesting QU questions to ServiceBrowser and ServiceInfo (#787) --- tests/services/test_browser.py | 100 +++++++++++++++++++++++++++++++++ tests/services/test_info.py | 48 ++++++++++++++++ zeroconf/__init__.py | 3 +- zeroconf/_core.py | 8 ++- zeroconf/_dns.py | 14 +++++ zeroconf/_services/browser.py | 35 ++++++++++-- zeroconf/_services/info.py | 26 ++++++--- zeroconf/aio.py | 24 ++++++-- 8 files changed, 236 insertions(+), 22 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 69c23cb0b..2198a33bb 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -542,6 +542,106 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() +def test_asking_default_is_asking_qm_questions(): + """Verify the service browser can ask QU questions.""" + type_ = "_quservice._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.async_send + + first_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + try: + assert first_outgoing.questions[0].unicast == False + finally: + browser.cancel() + zeroconf_browser.close() + + +def test_asking_qm_questions(): + """Verify explictly asking QM questions.""" + type_ = "_quservice._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.async_send + + first_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser( + zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QM + ) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + try: + assert first_outgoing.questions[0].unicast == False + finally: + browser.cancel() + zeroconf_browser.close() + + +def test_asking_qu_questions(): + """Verify the service browser can ask QU questions.""" + type_ = "_quservice._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.async_send + + first_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + browser = ServiceBrowser( + zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QU + ) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + try: + assert first_outgoing.questions[0].unicast == True + finally: + browser.cancel() + zeroconf_browser.close() + + def test_integration(): service_added = Event() service_removed = Event() diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 8fb45f22d..438ad8194 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -625,3 +625,51 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): addresses=addresses, ) assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + + +def test_asking_qu_questions(): + """Verify explictly asking QU questions.""" + type_ = "_quservice._tcp.local." + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf.async_send + + first_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf, "async_send", send): + zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) + assert first_outgoing.questions[0].unicast == True + zeroconf.close() + + +def test_asking_qm_questions_are_default(): + """Verify default is QM questions.""" + type_ = "_quservice._tcp.local." + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf.async_send + + first_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf, "async_send", send): + zeroconf.get_service_info(f"name.{type_}", type_, 500) + assert first_outgoing.questions[0].unicast == False + zeroconf.close() diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e3d7ddfb4..e836195e4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -33,6 +33,7 @@ DNSRecord, DNSService, DNSText, + DNSQuestionType, ) from ._logger import QuietLogger, log # noqa # import needed for backwards compat from ._exceptions import ( # noqa # import needed for backwards compat @@ -84,7 +85,7 @@ __all__ = [ "__version__", - "DNSOutgoing", + "DNSQuestionType", "Zeroconf", "ServiceInfo", "ServiceBrowser", diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 83395f05e..a22ede3d0 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -32,7 +32,7 @@ from typing import Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache -from ._dns import DNSQuestion +from ._dns import DNSQuestion, DNSQuestionType from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._history import QuestionHistory @@ -360,12 +360,14 @@ def async_notify_all(self) -> None: self.notify_event.set() self.notify_event.clear() - def get_service_info(self, type_: str, name: str, timeout: int = 3000) -> Optional[ServiceInfo]: + def get_service_info( + self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None + ) -> Optional[ServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds.""" info = ServiceInfo(type_, name) - if info.request(self, timeout): + if info.request(self, timeout, question_type): return info return None diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 0fad150dd..2c0e3338e 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -20,6 +20,7 @@ USA """ +import enum import socket from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast @@ -49,6 +50,19 @@ from ._protocol import DNSIncoming, DNSOutgoing # pylint: disable=cyclic-import +@enum.unique +class DNSQuestionType(enum.Enum): + """An MDNS question type. + + "QU" - questions requesting unicast responses + "QM" - questions requesting multicast responses + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 + """ + + QU = 1 + QM = 2 + + def dns_entry_matches(record: 'DNSEntry', key: str, type_: int, class_: int) -> bool: return key == record.key and type_ == record.type and class_ == record.class_ diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 655d046a3..30f4cf905 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -30,7 +30,7 @@ from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from .._cache import _UniqueRecordsType -from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord +from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log from .._protocol import DNSOutgoing from .._services import ( @@ -130,12 +130,18 @@ def _group_ptr_queries_with_known_answers( def generate_service_query( - zc: 'Zeroconf', now: float, types_: List[str], multicast: bool = True + zc: 'Zeroconf', + now: float, + types_: List[str], + multicast: bool = True, + question_type: Optional[DNSQuestionType] = None, ) -> List[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} + qu_question = not multicast if question_type is None else question_type == DNSQuestionType.QU for type_ in types_: question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) + question.unicast = qu_question known_answers = set( cast(DNSPointer, record) for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) @@ -186,8 +192,25 @@ def __init__( addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, + question_type: Optional[DNSQuestionType] = None, ) -> None: - """Creates a browser for a specific type""" + """Used to browse for a service for specific type(s). + + Constructor parameters are as follows: + + * `zc`: A Zeroconf instance + * `type_`: fully qualified service type name + * `handler`: ServiceListener or Callable that knows how to process ServiceStateChange events + * `listener`: ServiceListener + * `addr`: address to send queries (will default to multicast) + * `port`: port to send queries (will default to mdns 5353) + * `delay`: The initial delay between answering questions + * `question_type`: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) + + The listener object will have its add_service() and + remove_service() methods called when this browser + discovers changes in the services availability. + """ assert handlers or listener, 'You need to specify at least one handler' self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) for check_type_ in self.types: @@ -198,6 +221,7 @@ def __init__( self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) + self.question_type = question_type self._next_time: Dict[str, float] = {} self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self.types} self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() @@ -370,7 +394,7 @@ def generate_ready_queries(self) -> List[DNSOutgoing]: self._next_time[type_] = now + self._delay[type_] self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - return generate_service_query(self.zc, now, ready_types, self.multicast) + return generate_service_query(self.zc, now, ready_types, self.multicast, self.question_type) def _millis_to_wait(self, now: float) -> Optional[float]: """Returns the number of milliseconds to wait for the next event.""" @@ -416,12 +440,13 @@ def __init__( addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, + question_type: Optional[DNSQuestionType] = None, ) -> None: assert zc.loop is not None if not zc.loop.is_running(): raise RuntimeError("The event loop is not running") threading.Thread.__init__(self) - super().__init__(zc, type_, handlers=handlers, listener=listener, addr=addr, port=port, delay=delay) + super().__init__(zc, type_, handlers, listener, addr, port, delay, question_type) # Add the queue before the listener is installed in _setup # to ensure that events run in the dedicated thread and do # not block the event loop diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 6fccb6b70..82e8b9832 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -24,7 +24,7 @@ import socket from typing import Dict, List, Optional, TYPE_CHECKING, Union, cast -from .._dns import DNSAddress, DNSPointer, DNSRecord, DNSService, DNSText +from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing from .._services import RecordUpdateListener @@ -85,7 +85,6 @@ class ServiceInfo(RecordUpdateListener): * `other_ttl`: ttl used for PTR/TXT records * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, or in parsed form as text; at most one of those parameters can be provided) - """ text = b'' @@ -103,7 +102,7 @@ def __init__( other_ttl: int = _DNS_OTHER_TTL, *, addresses: Optional[List[bytes]] = None, - parsed_addresses: Optional[List[str]] = None + parsed_addresses: Optional[List[str]] = None, ) -> None: # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: @@ -394,16 +393,22 @@ def _is_complete(self) -> bool: """The ServiceInfo has all expected properties.""" return not (self.text is None or not self._addresses) - def request(self, zc: 'Zeroconf', timeout: float) -> bool: + def request( + self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None + ) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ assert zc.loop is not None and zc.loop.is_running() if zc.loop == get_running_loop(): raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") - return asyncio.run_coroutine_threadsafe(self.async_request(zc, timeout), zc.loop).result() + return asyncio.run_coroutine_threadsafe( + self.async_request(zc, timeout, question_type), zc.loop + ).result() - async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: + async def async_request( + self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None + ) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ @@ -421,7 +426,7 @@ async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: if last <= now: return False if next_ <= now: - out = self.generate_request_query(zc, now) + out = self.generate_request_query(zc, now, question_type) if not out.questions: return self.load_from_cache(zc) zc.async_send(out) @@ -435,13 +440,18 @@ async def async_request(self, zc: 'Zeroconf', timeout: float) -> bool: return True - def generate_request_query(self, zc: 'Zeroconf', now: float) -> DNSOutgoing: + def generate_request_query( + self, zc: 'Zeroconf', now: float, question_type: Optional[DNSQuestionType] = None + ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) + if question_type == DNSQuestionType.QU: + for question in out.questions: + question.unicast = True return out def __eq__(self, other: object) -> bool: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 94fa82bb8..dc4f90cdf 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -25,6 +25,7 @@ from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union, cast from ._core import Zeroconf +from ._dns import DNSQuestionType from ._exceptions import NonUniqueNameException from ._services import ServiceListener from ._services.browser import _ServiceBrowserBase @@ -55,11 +56,23 @@ class AsyncServiceInfo(ServiceInfo): class AsyncServiceBrowser(_ServiceBrowserBase): - """Used to browse for a service of a specific type. + """Used to browse for a service for specific type(s). + + Constructor parameters are as follows: + + * `zc`: A Zeroconf instance + * `type_`: fully qualified service type name + * `handler`: ServiceListener or Callable that knows how to process ServiceStateChange events + * `listener`: ServiceListener + * `addr`: address to send queries (will default to multicast) + * `port`: port to send queries (will default to mdns 5353) + * `delay`: The initial delay between answering questions + * `question_type`: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) The listener object will have its add_service() and remove_service() methods called when this browser - discovers changes in the services availability.""" + discovers changes in the services availability. + """ def __init__( self, @@ -70,8 +83,9 @@ def __init__( addr: Optional[str] = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, + question_type: Optional[DNSQuestionType] = None, ) -> None: - super().__init__(zeroconf, type_, handlers, listener, addr, port, delay) + super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type) self._setup() # Start queries after the listener is installed in _setup self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) @@ -251,13 +265,13 @@ async def async_close(self) -> None: await self.zeroconf._async_close() # pylint: disable=protected-access async def async_get_service_info( - self, type_: str, name: str, timeout: int = 3000 + self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None ) -> Optional[AsyncServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds.""" info = AsyncServiceInfo(type_, name) - if await info.async_request(self.zeroconf, timeout): + if await info.async_request(self.zeroconf, timeout, question_type): return info return None From 62dc9c91c277bc4755f81597adca030a43d0ce5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 19:40:33 -1000 Subject: [PATCH 0473/1433] Add async_apple_scanner example (#719) --- examples/async_apple_scanner.py | 119 ++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 examples/async_apple_scanner.py diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py new file mode 100644 index 000000000..573640b0a --- /dev/null +++ b/examples/async_apple_scanner.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +""" Scan for apple devices. """ + +import argparse +import asyncio +import logging +from typing import Any, Optional, cast + +from zeroconf import DNSQuestionType, IPVersion, ServiceStateChange, Zeroconf +from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + +HOMESHARING_SERVICE: str = "_appletv-v2._tcp.local." +DEVICE_SERVICE: str = "_touch-able._tcp.local." +MEDIAREMOTE_SERVICE: str = "_mediaremotetv._tcp.local." +AIRPLAY_SERVICE: str = "_airplay._tcp.local." +COMPANION_SERVICE: str = "_companion-link._tcp.local." +RAOP_SERVICE: str = "_raop._tcp.local." +AIRPORT_ADMIN_SERVICE: str = "_airport._tcp.local." +DEVICE_INFO_SERVICE: str = "_device-info._tcp.local." + +ALL_SERVICES = [ + HOMESHARING_SERVICE, + DEVICE_SERVICE, + MEDIAREMOTE_SERVICE, + AIRPLAY_SERVICE, + COMPANION_SERVICE, + RAOP_SERVICE, + AIRPORT_ADMIN_SERVICE, + DEVICE_INFO_SERVICE, +] + +log = logging.getLogger(__name__) + + +def async_on_service_state_change( + zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange +) -> None: + print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) + if state_change is not ServiceStateChange.Added: + return + base_name = name[: -len(service_type) - 1] + device_name = f"{base_name}.{DEVICE_INFO_SERVICE}" + asyncio.ensure_future(_async_show_service_info(zeroconf, service_type, name)) + # Also probe for device info + asyncio.ensure_future(_async_show_service_info(zeroconf, DEVICE_INFO_SERVICE, device_name)) + + +async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: + info = AsyncServiceInfo(service_type, name) + await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU) + print("Info from zeroconf.get_service_info: %r" % (info)) + if info: + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + print(" Name: %s" % name) + print(" Addresses: %s" % ", ".join(addresses)) + print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + print(" Server: %s" % (info.server,)) + if info.properties: + print(" Properties are:") + for key, value in info.properties.items(): + print(" %s: %s" % (key, value)) + else: + print(" No properties") + else: + print(" No info") + print('\n') + + +class AsyncAppleScanner: + def __init__(self, args: Any) -> None: + self.args = args + self.aiobrowser: Optional[AsyncServiceBrowser] = None + self.aiozc: Optional[AsyncZeroconf] = None + + async def async_run(self) -> None: + self.aiozc = AsyncZeroconf(ip_version=ip_version) + await self.aiozc.zeroconf.async_wait_for_start() + print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % ALL_SERVICES) + kwargs = {'handlers': [async_on_service_state_change], 'question_type': DNSQuestionType.QU} + if self.args.target: + kwargs["addr"] = self.args.target + self.aiobrowser = AsyncServiceBrowser(self.aiozc.zeroconf, ALL_SERVICES, **kwargs) # type: ignore + while True: + await asyncio.sleep(1) + + async def async_close(self) -> None: + assert self.aiozc is not None + assert self.aiobrowser is not None + await self.aiobrowser.async_cancel() + await self.aiozc.async_close() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG) + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + version_group = parser.add_mutually_exclusive_group() + version_group.add_argument('--target', help='Unicast target') + version_group.add_argument('--v6', action='store_true') + version_group.add_argument('--v6-only', action='store_true') + args = parser.parse_args() + + if args.debug: + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + if args.v6: + ip_version = IPVersion.All + elif args.v6_only: + ip_version = IPVersion.V6Only + else: + ip_version = IPVersion.V4Only + + loop = asyncio.get_event_loop() + runner = AsyncAppleScanner(args) + try: + loop.run_until_complete(runner.async_run()) + except KeyboardInterrupt: + loop.run_until_complete(runner.async_close()) From 5d2362825110e9f7a9c9259218a664e2e927e821 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 19:43:32 -1000 Subject: [PATCH 0474/1433] Update changelog (#788) --- README.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index c9e23afb2..c0b12b151 100644 --- a/README.rst +++ b/README.rst @@ -258,11 +258,9 @@ you can likely not be concerned with the breaking changes below: * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco -* Switch to using an asyncio.Event for async_wait (#759) @bdraco +* Add async_apple_scanner example (#719) @bdraco - We no longer need to check for thread safety under a asyncio.Condition - as the ServiceBrowser and ServiceInfo internals schedule coroutines - in the eventloop. +* Add support for requesting QU questions to ServiceBrowser and ServiceInfo (#787) @bdraco * Ensure the queue is created before adding listeners to ServiceBrowser (#785) @bdraco @@ -287,6 +285,12 @@ you can likely not be concerned with the breaking changes below: * Improve performance of parsing DNSIncoming by caching read_utf (#769) (later reverted) @bdraco +* Switch to using an asyncio.Event for async_wait (#759) @bdraco + + We no longer need to check for thread safety under a asyncio.Condition + as the ServiceBrowser and ServiceInfo internals schedule coroutines + in the eventloop. + * Simplify ServiceBrowser callsbacks (#756) @bdraco * Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) @bdraco From ecad4e84c44ffd21dbf15e969c08f7b3376b131c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 22:53:32 -1000 Subject: [PATCH 0475/1433] Ensure outgoing ServiceBrowser questions are seen by the question history (#790) --- tests/services/test_browser.py | 11 +++++++++++ zeroconf/_services/browser.py | 1 + 2 files changed, 12 insertions(+) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 2198a33bb..2dba61588 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1045,4 +1045,15 @@ async def test_generate_service_query_suppress_duplicate_questions(): # We do not suppress QU queries ever outs = _services_browser.generate_service_query(zc, now, [name], multicast=False) assert outs + + zc.question_history.async_expire(now + 1000) + # No suppression after clearing the history + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert outs + + # The previous query we just sent is still remembered and + # the next one is suppressed + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert not outs + await aiozc.async_close() diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 30f4cf905..6a679c20b 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -151,6 +151,7 @@ def generate_service_query( log.debug("Asking %s was suppressed by the question history", question) continue questions_with_known_answers[question] = known_answers + zc.question_history.add_question_at_time(question, now, cast(Set[DNSRecord], known_answers)) return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) From 6aac0eb0c1e394ec7ee21ddd6e98e446417d0e07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 07:01:23 -1000 Subject: [PATCH 0476/1433] Fix test_tc_bit_defers_last_response_missing failures due to thread safety (#795) --- tests/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index 6447bbf3c..27cfb9e75 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -576,7 +576,7 @@ async def make_query(): for _ in range(8): time.sleep(0.1) - if source_ip not in zc._timers: + if source_ip not in zc._timers and source_ip not in zc._deferred: break assert source_ip not in zc._deferred From 2bfbcbe9e05b9df98bba66a73deb0041c0e7c13b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 07:09:58 -1000 Subject: [PATCH 0477/1433] Make add_listener and remove_listener threadsafe (#794) --- tests/services/test_browser.py | 2 ++ zeroconf/_core.py | 34 ++++++++++++++++++++++++++++++---- zeroconf/_handlers.py | 18 ++++++++++++------ zeroconf/_services/browser.py | 24 ++++++++++-------------- zeroconf/_services/info.py | 4 ++-- zeroconf/aio.py | 8 +++----- 6 files changed, 59 insertions(+), 31 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 2dba61588..331e1f0d5 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -315,6 +315,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi finally: assert len(zeroconf.listeners) == 1 service_browser.cancel() + time.sleep(0.2) assert len(zeroconf.listeners) == 0 zeroconf.remove_all_service_listeners() zeroconf.close() @@ -422,6 +423,7 @@ def _mock_get_expiration_time(self, percent): finally: assert len(zeroconf.listeners) == 1 service_browser.cancel() + time.sleep(0.2) assert len(zeroconf.listeners) == 0 zeroconf.remove_all_service_listeners() zeroconf.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a22ede3d0..8e9f3dff6 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -548,12 +548,38 @@ def add_listener( ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to - answer the question(s).""" - self.record_manager.add_listener(listener, question) + answer the question(s). + + This function is threadsafe + """ + assert self.loop is not None + self.loop.call_soon_threadsafe(self.record_manager.async_add_listener, listener, question) def remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener.""" - self.record_manager.remove_listener(listener) + """Removes a listener. + + This function is threadsafe + """ + assert self.loop is not None + self.loop.call_soon_threadsafe(self.record_manager.async_remove_listener, listener) + + def async_add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s). + + This function is not threadsafe and must be called in the eventloop. + """ + self.record_manager.async_add_listener(listener, question) + + def async_remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener. + + This function is not threadsafe and must be called in the eventloop. + """ + self.record_manager.async_remove_listener(listener) def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 9caae3a24..711c84c9f 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -387,12 +387,15 @@ def _async_mark_unique_cached_records_older_than_1s_to_expire( # Expire in 1s entry.set_created_ttl(now, 1) - def add_listener( + def async_add_listener( self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to - answer the question(s).""" + answer the question(s). + + This function is not threadsafe and must be called in the eventloop. + """ self.listeners.append(listener) if question is None: @@ -400,7 +403,7 @@ def add_listener( questions = [question] if isinstance(question, DNSQuestion) else question assert self.zc.loop is not None - self.zc.loop.call_soon_threadsafe(self._async_update_matching_records, listener, questions) + self._async_update_matching_records(listener, questions) def _async_update_matching_records( self, listener: RecordUpdateListener, questions: List[DNSQuestion] @@ -422,10 +425,13 @@ def _async_update_matching_records( listener.async_update_records_complete() self.zc.async_notify_all() - def remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener.""" + def async_remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener. + + This function is not threadsafe and must be called in the eventloop. + """ try: self.listeners.remove(listener) - self.zc.notify_all() + self.zc.async_notify_all() except ValueError as e: log.exception('Failed to remove listener: %r', e) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 6a679c20b..698a162a9 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -242,14 +242,16 @@ def __init__( for h in handlers: self.service_state_changed.register_handler(h) - def _setup(self) -> None: + def _async_start(self) -> None: """Generate the next time and setup listeners. Must be called by uses of this base class after they have finished setting their properties. """ self._generate_first_next_time() - self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + # Only start queries after the listener is installed + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) def _generate_first_next_time(self) -> None: """Generate the initial next query times. @@ -374,10 +376,10 @@ def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], Servic state_change=state_change, ) - def cancel(self) -> None: + def _async_cancel(self) -> None: """Cancel the browser.""" self.done = True - self.zc.remove_listener(self) + self.zc.async_remove_listener(self) def generate_ready_queries(self) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" @@ -454,20 +456,15 @@ def __init__( self.queue = get_best_available_queue() self.daemon = True self.start() - self._setup() - # Start queries after the listener is installed in _setup - zc.loop.call_soon_threadsafe(self._async_start_browser) + zc.loop.call_soon_threadsafe(self._async_start) self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) - def _async_start_browser(self) -> None: - """Start the browser from the event loop.""" - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) - - def _async_cancel_browser_soon(self) -> None: + def _async_cancel_soon(self) -> None: """Cancel the browser from the event loop.""" + self._async_cancel() if self._browser_task: asyncio.ensure_future(self._async_cancel_browser()) @@ -476,8 +473,7 @@ def cancel(self) -> None: assert self.zc.loop is not None assert self.queue is not None self.queue.put(None) - self.zc.loop.call_soon_threadsafe(self._async_cancel_browser_soon) - super().cancel() + self.zc.loop.call_soon_threadsafe(self._async_cancel_soon) self.join() def run(self) -> None: diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 82e8b9832..d8268d3e1 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -421,7 +421,7 @@ async def async_request( last = now + timeout await zc.async_wait_for_start() try: - zc.add_listener(self, None) + zc.async_add_listener(self, None) while not self._is_complete: if last <= now: return False @@ -436,7 +436,7 @@ async def async_request( await zc.async_wait(min(next_, last) - now) now = current_time_millis() finally: - zc.remove_listener(self) + zc.async_remove_listener(self) return True diff --git a/zeroconf/aio.py b/zeroconf/aio.py index dc4f90cdf..985a440b9 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -22,7 +22,7 @@ import asyncio import contextlib from types import TracebackType # noqa # used in type hints -from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union, cast +from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union from ._core import Zeroconf from ._dns import DNSQuestionType @@ -86,15 +86,13 @@ def __init__( question_type: Optional[DNSQuestionType] = None, ) -> None: super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type) - self._setup() - # Start queries after the listener is installed in _setup - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + self._async_start() async def async_cancel(self) -> None: """Cancel the browser.""" + self._async_cancel() with contextlib.suppress(asyncio.CancelledError): await self._async_cancel_browser() - super().cancel() class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): From cb91484670ba76c8c453dc49502e89195561b31e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 08:42:16 -1000 Subject: [PATCH 0478/1433] Remove unused constant from zeroconf._handlers (#796) --- zeroconf/_handlers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 711c84c9f..d8ec0a89c 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -30,7 +30,6 @@ from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener from ._services.registry import ServiceRegistry -from ._utils.net import IPVersion from ._utils.time import current_time_millis from .const import ( _CLASS_IN, @@ -47,8 +46,6 @@ _TYPE_TXT, ) -_TYPE_TO_IP_VERSION = {_TYPE_A: IPVersion.V4Only, _TYPE_AAAA: IPVersion.V6Only, _TYPE_ANY: IPVersion.All} - if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 from ._core import Zeroconf # pylint: disable=cyclic-import From d637d67378698e0a505be90afbce4e2264b49444 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 08:56:41 -1000 Subject: [PATCH 0479/1433] Pass both the new and old records to async_update_records (#792) * Pass the old_record (cached) as the value and the new_record (wire) to async_update_records instead of forcing each consumer to check the cache since we will always have the old_record when generating the async_update_records call. This avoids the overhead of multiple cache lookups for each listener. --- tests/test_handlers.py | 10 +++- tests/test_services.py | 62 ----------------------- tests/test_updates.py | 92 ++++++++++++++++++++++++++++++++++ zeroconf/__init__.py | 2 +- zeroconf/_core.py | 7 ++- zeroconf/_handlers.py | 16 +++--- zeroconf/_services/__init__.py | 39 -------------- zeroconf/_services/browser.py | 16 +++--- zeroconf/_services/info.py | 23 +++++---- zeroconf/_updates.py | 77 ++++++++++++++++++++++++++++ 10 files changed, 210 insertions(+), 134 deletions(-) create mode 100644 tests/test_updates.py create mode 100644 zeroconf/_updates.py diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 522c71d65..53cd6de20 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -986,7 +986,7 @@ async def test_record_update_manager_add_listener_callsback_existing_records(): class MyListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRecord]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -1013,7 +1013,13 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRe ) await asyncio.sleep(0) # flush out the call_soon_threadsafe - assert set(updated) == set([ptr_record, a_record]) + assert set([record.new for record in updated]) == set([ptr_record, a_record]) + + # This behavior is probably wrong but should not be + # changed in this commit because the goal is to refactor + # only and not change how it functions + assert set([record.old for record in updated]) == set([ptr_record, a_record]) + await aiozc.async_close() diff --git a/tests/test_services.py b/tests/test_services.py index 684266f27..12ad95ba8 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -14,9 +14,7 @@ import pytest import zeroconf as r -from zeroconf import const from zeroconf import Zeroconf -from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo from . import has_working_ipv6, _clear_cache @@ -193,66 +191,6 @@ def update_service(self, zeroconf, type, name): zeroconf_browser.close() -def test_legacy_record_update_listener(): - """Test a RecordUpdateListener that does not implement update_records.""" - - # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - - with pytest.raises(RuntimeError): - r.RecordUpdateListener().update_record( - zc, 0, r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) - ) - - updates = [] - - class LegacyRecordUpdateListener(r.RecordUpdateListener): - """A RecordUpdateListener that does not implement update_records.""" - - def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: - nonlocal updates - updates.append(record) - - listener = LegacyRecordUpdateListener() - - zc.add_listener(listener, None) - - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - # start a browser - type_ = "_homeassistant._tcp.local." - name = "MyTestHome" - browser = ServiceBrowser(zc, type_, [on_service_state_change]) - - info_service = ServiceInfo( - type_, - '%s.%s' % (name, type_), - 80, - 0, - 0, - {'path': '/~paulsm/'}, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - - zc.register_service(info_service) - - zc.wait(1) - - browser.cancel() - - assert len(updates) - assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 - - zc.remove_listener(listener) - # Removing a second time should not throw - zc.remove_listener(listener) - - zc.close() - - def test_servicelisteners_raise_not_implemented(): """Verify service listeners raise when one of the methods is not implemented.""" diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 000000000..1f6f8ad4b --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +""" Unit tests for zeroconf._services. """ + +import logging +import socket +from threading import Event + +import pytest + +import zeroconf as r +from zeroconf import const +from zeroconf import Zeroconf +from zeroconf._services.browser import ServiceBrowser +from zeroconf._services.info import ServiceInfo + + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +def test_legacy_record_update_listener(): + """Test a RecordUpdateListener that does not implement update_records.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + with pytest.raises(RuntimeError): + r.RecordUpdateListener().update_record( + zc, 0, r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) + ) + + updates = [] + + class LegacyRecordUpdateListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: + nonlocal updates + updates.append(record) + + listener = LegacyRecordUpdateListener() + + zc.add_listener(listener, None) + + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + browser = ServiceBrowser(zc, type_, [on_service_state_change]) + + info_service = ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + zc.register_service(info_service) + + zc.wait(1) + + browser.cancel() + + assert len(updates) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + + zc.remove_listener(listener) + # Removing a second time should not throw + zc.remove_listener(listener) + + zc.close() diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e836195e4..263043aa8 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -49,7 +49,6 @@ from ._services import ( # noqa # import needed for backwards compat Signal, SignalRegistrationInterface, - RecordUpdateListener, ServiceListener, ServiceStateChange, ) @@ -62,6 +61,7 @@ ) from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat from ._services.types import ZeroconfServiceTypes +from ._updates import RecordUpdate, RecordUpdateListener # noqa # import needed for backwards compat from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 8e9f3dff6..2fa093b7a 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -38,10 +38,11 @@ from ._history import QuestionHistory from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing -from ._services import RecordUpdateListener, ServiceListener +from ._services import ServiceListener from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry +from ._updates import RecordUpdate, RecordUpdateListener from ._utils.aio import get_running_loop, shutdown_loop, wait_event_or_timeout from ._utils.name import service_type_name from ._utils.net import ( @@ -136,7 +137,9 @@ async def _async_cache_cleanup(self) -> None: while not self.zc.done: now = current_time_millis() self.zc.question_history.async_expire(now) - self.zc.record_manager.async_updates(now, self.zc.cache.async_expire(now)) + self.zc.record_manager.async_updates( + now, [RecordUpdate(record, None) for record in self.zc.cache.async_expire(now)] + ) self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index d8ec0a89c..d7900fe95 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -28,8 +28,8 @@ from ._history import QuestionHistory from ._logger import log from ._protocol import DNSIncoming, DNSOutgoing -from ._services import RecordUpdateListener from ._services.registry import ServiceRegistry +from ._updates import RecordUpdate, RecordUpdateListener from ._utils.time import current_time_millis from .const import ( _CLASS_IN, @@ -283,7 +283,7 @@ def __init__(self, zeroconf: 'Zeroconf') -> None: self.cache = zeroconf.cache self.listeners: List[RecordUpdateListener] = [] - def async_updates(self, now: float, rec: List[DNSRecord]) -> None: + def async_updates(self, now: float, records: List[RecordUpdate]) -> None: """Used to notify listeners of new information that has updated a record. @@ -292,7 +292,7 @@ def async_updates(self, now: float, rec: List[DNSRecord]) -> None: This method will be run in the event loop. """ for listener in self.listeners: - listener.async_update_records(self.zc, now, rec) + listener.async_update_records(self.zc, now, records) def async_updates_complete(self) -> None: """Used to notify listeners of new information that has updated @@ -313,7 +313,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: This function must be run in the event loop as it is not threadsafe. """ - updates: List[DNSRecord] = [] + updates: List[RecordUpdate] = [] address_adds: List[DNSAddress] = [] other_adds: List[DNSRecord] = [] removes: List[DNSRecord] = [] @@ -333,11 +333,11 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: address_adds.append(record) else: other_adds.append(record) - updates.append(record) + updates.append(RecordUpdate(record, maybe_entry)) # This is likely a goodbye since the record is # expired and exists in the cache elif maybe_entry is not None: - updates.append(record) + updates.append(RecordUpdate(record, maybe_entry)) removes.append(record) if unique_types: @@ -410,11 +410,11 @@ def _async_update_matching_records( This function must be run from the event loop. """ now = current_time_millis() - records = [] + records: List[RecordUpdate] = [] for question in questions: for record in self.cache.async_entries_with_name(question.name): if not record.is_expired(now) and question.answered_by(record): - records.append(record) + records.append(RecordUpdate(record, record)) if not records: return diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 776d43a7c..3759f1ec9 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -23,7 +23,6 @@ import enum from typing import Any, Callable, List, TYPE_CHECKING -from .._dns import DNSRecord if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 @@ -72,41 +71,3 @@ def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationI def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': self._handlers.remove(handler) return self - - -class RecordUpdateListener: - def update_record( # pylint: disable=no-self-use - self, zc: 'Zeroconf', now: float, record: DNSRecord - ) -> None: - """Update a single record. - - This method is deprecated and will be removed in a future version. - update_records should be implemented instead. - """ - raise RuntimeError("update_record is deprecated and will be removed in a future version.") - - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: - """Update multiple records in one shot. - - All records that are received in a single packet are passed - to update_records. - - This implementation is a compatiblity shim to ensure older code - that uses RecordUpdateListener as a base class will continue to - get calls to update_record. This method will raise - NotImplementedError in a future version. - - At this point the cache will not have the new records - - This method will be run in the event loop. - """ - for record in records: - self.update_record(zc, now, record) - - def async_update_records_complete(self) -> None: - """Called when a record update has completed for all handlers. - - At this point the cache will have the new records. - - This method will be run in the event loop. - """ diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 698a162a9..1a7caca8c 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -29,17 +29,16 @@ from collections import OrderedDict from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast -from .._cache import _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log from .._protocol import DNSOutgoing from .._services import ( - RecordUpdateListener, ServiceListener, ServiceStateChange, Signal, SignalRegistrationInterface, ) +from .._updates import RecordUpdate, RecordUpdateListener from .._utils.aio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds @@ -294,16 +293,15 @@ def _enqueue_callback( ): self._pending_handlers[key] = state_change - def _async_process_record_update(self, now: float, record: DNSRecord) -> None: + def _async_process_record_update( + self, now: float, record: DNSRecord, old_record: Optional[DNSRecord] + ) -> None: """Process a single record update from a batch of updates.""" expired = record.is_expired(now) if isinstance(record, DNSPointer): if record.name not in self.types: return - old_record = self.zc.cache.async_get_unique( - DNSPointer(record.name, _TYPE_PTR, _CLASS_IN, 0, record.alias) - ) if old_record is None: self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) elif expired: @@ -315,7 +313,7 @@ def _async_process_record_update(self, now: float, record: DNSRecord) -> None: return # If its expired or already exists in the cache it cannot be updated. - if expired or self.zc.cache.async_get_unique(cast(_UniqueRecordsType, record)): + if expired or old_record: return if isinstance(record, DNSAddress): @@ -332,7 +330,7 @@ def _async_process_record_update(self, now: float, record: DNSRecord) -> None: if type_: self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. @@ -342,7 +340,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSReco This method will be run in the event loop. """ for record in records: - self._async_process_record_update(now, record) + self._async_process_record_update(now, record[0], record[1]) def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index d8268d3e1..f7ab9e55d 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -27,7 +27,7 @@ from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing -from .._services import RecordUpdateListener +from .._updates import RecordUpdate, RecordUpdateListener from .._utils.aio import get_running_loop from .._utils.name import service_type_name from .._utils.net import ( @@ -258,22 +258,22 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) This method will be run in the event loop. """ if record is not None: - self._process_records_threadsafe(zc, now, [record]) + self._process_records_threadsafe(zc, now, [RecordUpdate(record, None)]) - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Updates service information from a DNS record. This method will be run in the event loop. """ self._process_records_threadsafe(zc, now, records) - def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[DNSRecord]) -> None: + def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Thread safe record updating.""" update_addresses = False - for record in records: - if isinstance(record, DNSService): + for record_update in records: + if isinstance(record_update[0], DNSService): update_addresses = True - self._process_record_threadsafe(record, now) + self._process_record_threadsafe(record_update[0], now) # Only update addresses if the DNSService (.server) has changed if not update_addresses: @@ -374,17 +374,18 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: This method is designed to be threadsafe. """ now = current_time_millis() - record_updates = [] + record_updates: List[RecordUpdate] = [] cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: # If there is a srv record, A and AAAA will already # be called and we do not want to do it twice - record_updates.append(cached_srv_record) + record_updates.append(RecordUpdate(cached_srv_record, None)) else: - record_updates.extend(self._get_address_records_from_cache(zc)) + for record in self._get_address_records_from_cache(zc): + record_updates.append(RecordUpdate(record, None)) cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: - record_updates.append(cached_txt_record) + record_updates.append(RecordUpdate(cached_txt_record, None)) self._process_records_threadsafe(zc, now, record_updates) return self._is_complete diff --git a/zeroconf/_updates.py b/zeroconf/_updates.py new file mode 100644 index 000000000..d7ad56c1a --- /dev/null +++ b/zeroconf/_updates.py @@ -0,0 +1,77 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from typing import List, NamedTuple, Optional, TYPE_CHECKING + + +from ._dns import DNSRecord + + +if TYPE_CHECKING: + # https://github.com/PyCQA/pylint/issues/3525 + from ._core import Zeroconf # pylint: disable=cyclic-import + + +class RecordUpdate(NamedTuple): + new: DNSRecord + old: Optional[DNSRecord] + + +class RecordUpdateListener: + def update_record( # pylint: disable=no-self-use + self, zc: 'Zeroconf', now: float, record: DNSRecord + ) -> None: + """Update a single record. + + This method is deprecated and will be removed in a future version. + update_records should be implemented instead. + """ + raise RuntimeError("update_record is deprecated and will be removed in a future version.") + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: + """Update multiple records in one shot. + + All records that are received in a single packet are passed + to update_records. + + This implementation is a compatiblity shim to ensure older code + that uses RecordUpdateListener as a base class will continue to + get calls to update_record. This method will raise + NotImplementedError in a future version. + + At this point the cache will not have the new records + + Records are passed as a list of RecordUpdate. This + allows consumers of async_update_records to avoid cache lookups. + + This method will be run in the event loop. + """ + for record in records: + self.update_record(zc, now, record[0]) + + def async_update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + + This method will be run in the event loop. + """ From c36099a41a71298d58e7afa42ecdc7a54d3b010a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 09:07:46 -1000 Subject: [PATCH 0480/1433] Update changelog (#797) --- README.rst | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c0b12b151..5c3753995 100644 --- a/README.rst +++ b/README.rst @@ -140,8 +140,23 @@ See examples directory for more. Changelog ========= -0.32.0 (Unreleased) -=================== +0.32.0 Beta 2 (Unreleased) +========================== + +* Pass both the new and old records to async_update_records (#792) @bdraco + + Pass the old_record (cached) as the value and the new_record (wire) + to async_update_records instead of forcing each consumer to + check the cache since we will always have the old_record + when generating the async_update_records call. This avoids + the overhead of multiple cache lookups for each listener. + +* Make add_listener and remove_listener threadsafe (#794) @bdraco + +* Ensure outgoing ServiceBrowser questions are seen by the question history (#790) @bdraco + +0.32.0 Beta 1 +============= Documentation for breaking changes era on the side of the caution and likely overstates the risk on many of these. If you are not accessing zeroconf internals, From 38e66ec5ba5fcb96cef17b8949385075807a2fb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 09:20:14 -1000 Subject: [PATCH 0481/1433] Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) --- tests/test_aio.py | 52 +++++++++++++++++++++++++++++++++++++++++- tests/test_handlers.py | 7 +++--- zeroconf/_handlers.py | 2 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 4cb7af2e6..4523ca1e5 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -12,7 +12,7 @@ import pytest -from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes from zeroconf import Zeroconf from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered @@ -607,3 +607,53 @@ async def test_guard_against_running_serviceinfo_request_event_loop() -> None: service_info = AsyncServiceInfo("_hap._tcp.local.", "doesnotmatter._hap._tcp.local.") with pytest.raises(RuntimeError): service_info.request(aiozc.zeroconf, 3000) + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_browser_instantiation_generates_add_events_from_cache(): + """Test that the ServiceBrowser will generate Add events with the existing cache when starting.""" + + # instantiate a zeroconf instance + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + type_ = "_hap._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener(ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + zc.cache.async_add_records( + [info.dns_pointer(), info.dns_service(), *info.dns_addresses(), info.dns_text()] + ) + + browser = AsyncServiceBrowser(zc, type_, None, listener) + + await asyncio.sleep(0) + + assert callbacks == [ + ('add', type_, registration_name), + ] + await browser.async_cancel() + + await aiozc.async_close() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 53cd6de20..64e444951 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1015,10 +1015,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor assert set([record.new for record in updated]) == set([ptr_record, a_record]) - # This behavior is probably wrong but should not be - # changed in this commit because the goal is to refactor - # only and not change how it functions - assert set([record.old for record in updated]) == set([ptr_record, a_record]) + # The old records should be None so we trigger Add events + # in service browsers instead of Update events + assert set([record.old for record in updated]) == set([None]) await aiozc.async_close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index d7900fe95..128d8711d 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -414,7 +414,7 @@ def _async_update_matching_records( for question in questions: for record in self.cache.async_entries_with_name(question.name): if not record.is_expired(now) and question.answered_by(record): - records.append(RecordUpdate(record, record)) + records.append(RecordUpdate(record, None)) if not records: return From 9961dce598d3c6eeda68a2f874a7a50ec33f819c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 09:21:37 -1000 Subject: [PATCH 0482/1433] Update changelog (#798) --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index 5c3753995..5751e276e 100644 --- a/README.rst +++ b/README.rst @@ -143,6 +143,10 @@ Changelog 0.32.0 Beta 2 (Unreleased) ========================== +* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) + + This is fixing ServiceBrowser missing an add when the record is already in the cache. + * Pass both the new and old records to async_update_records (#792) @bdraco Pass the old_record (cached) as the value and the new_record (wire) From bbc91241a86f3339aa27cae7b4ea2ab9d7c1f37d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 09:50:05 -1000 Subject: [PATCH 0483/1433] Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) --- tests/test_core.py | 25 +++++++++++++++++++++++++ tests/utils/test_aio.py | 15 +++++++++++++++ zeroconf/_utils/aio.py | 17 +++++++++++------ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 27cfb9e75..ae397d16d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -604,3 +604,28 @@ async def test_open_close_twice_from_async() -> None: zc = Zeroconf(interfaces=['127.0.0.1']) zc.close() zc.close() + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_multiple_sync_instances_stared_from_async_close(): + """Test we can shutdown multiple sync instances from async.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + zc2 = Zeroconf(interfaces=['127.0.0.1']) + + assert zc.loop == zc2.loop + + zc.close() + assert zc.loop.is_running() + zc2.close() + assert zc2.loop.is_running() + + zc3 = Zeroconf(interfaces=['127.0.0.1']) + assert zc3.loop == zc2.loop + + zc3.close() + assert zc3.loop.is_running() + + await asyncio.sleep(0) diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index bd402d4bd..b0fa8dbc6 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -6,12 +6,27 @@ import asyncio import contextlib +import unittest.mock import pytest from zeroconf._utils import aio as aioutils +@pytest.mark.asyncio +async def test_async_get_all_tasks() -> None: + """Test we can get all tasks in the event loop. + + We make sure we handle RuntimeError here as + this is not thread safe under PyPy + """ + await aioutils._async_get_all_tasks(aioutils.get_running_loop()) + if not hasattr(asyncio, 'all_tasks'): + return + with unittest.mock.patch("zeroconf._utils.aio.asyncio.all_tasks", side_effect=RuntimeError): + await aioutils._async_get_all_tasks(aioutils.get_running_loop()) + + @pytest.mark.asyncio async def test_get_running_loop_from_async() -> None: """Test we can get the event loop.""" diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index fcf71c349..0b6d8dba2 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -62,13 +62,18 @@ def _handle_wait_complete(_: asyncio.Task) -> None: await event_wait -async def _get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio.Task]: +async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio.Task]: """Return all tasks running.""" await asyncio.sleep(0) # flush out any call_soon_threadsafe - # Make a copy of the tasks in case they change during iteration - if hasattr(asyncio, 'all_tasks'): - return list(asyncio.all_tasks(loop)) # type: ignore # pylint: disable=no-member - return list(asyncio.Task.all_tasks(loop)) # type: ignore # pylint: disable=no-member + # If there are multiple event loops running, all_tasks is not + # safe EVEN WHEN CALLED FROM THE EVENTLOOP + # under PyPy so we have to try a few times. + for _ in range(3): + with contextlib.suppress(RuntimeError): + if hasattr(asyncio, 'all_tasks'): + return asyncio.all_tasks(loop) # type: ignore # pylint: disable=no-member + return asyncio.Task.all_tasks(loop) # type: ignore # pylint: disable=no-member + return [] async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: @@ -78,7 +83,7 @@ async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" - pending_tasks = set(asyncio.run_coroutine_threadsafe(_get_all_tasks(loop), loop).result()) + pending_tasks = set(asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result()) done_tasks = set(task for task in pending_tasks if not task.done()) pending_tasks -= done_tasks if pending_tasks: From 662ed6166282b9b5b6e83a596c0576a57f8962d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 09:51:12 -1000 Subject: [PATCH 0484/1433] Update changelog (#801) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 5751e276e..bf8f565bd 100644 --- a/README.rst +++ b/README.rst @@ -143,6 +143,8 @@ Changelog 0.32.0 Beta 2 (Unreleased) ========================== +* Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) @bdraco + * Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) This is fixing ServiceBrowser missing an add when the record is already in the cache. From 58ae3cf553cd925ac90f3db551f4085ea5bc8b79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 10:04:33 -1000 Subject: [PATCH 0485/1433] Update changelog (#802) --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index bf8f565bd..a4ece3b1a 100644 --- a/README.rst +++ b/README.rst @@ -140,12 +140,12 @@ See examples directory for more. Changelog ========= -0.32.0 Beta 2 (Unreleased) -========================== +0.32.0 Beta 2 +============= * Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) @bdraco -* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) +* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) @bdraco This is fixing ServiceBrowser missing an add when the record is already in the cache. From 18fe341300e28ed93d7b5d7ca8e07edb119bd597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 11:48:53 -1000 Subject: [PATCH 0486/1433] Add slots to DNS classes (#803) - On a busy network that receives many mDNS packets per second, we will not know the answer to most of the questions being asked. In this case the creating the DNS* objects are usually garbage collected within 1s as they are not needed. We now set __slots__ to speed up the creation and destruction of these objects --- zeroconf/_dns.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 2c0e3338e..af3057bf6 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -71,6 +71,8 @@ class DNSEntry: """A DNS entry""" + __slots__ = ('key', 'name', 'type', 'class_', 'unique') + def __init__(self, name: str, type_: int, class_: int) -> None: self.key = name.lower() self.name = name @@ -156,6 +158,8 @@ class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" + __slots__ = ('ttl', 'created', '_expiration_time', '_stale_time', '_recent_time') + # TODO: Switch to just int ttl def __init__( self, name: str, type_: int, class_: int, ttl: Union[float, int], created: Optional[float] = None @@ -238,6 +242,8 @@ class DNSAddress(DNSRecord): """A DNS address record""" + __slots__ = ('address',) + def __init__( self, name: str, type_: int, class_: int, ttl: int, address: bytes, created: Optional[float] = None ) -> None: @@ -274,6 +280,8 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" + __slots__ = ('cpu', 'os') + def __init__( self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str, created: Optional[float] = None ) -> None: @@ -308,6 +316,8 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" + __slots__ = ('alias',) + def __init__( self, name: str, type_: int, class_: int, ttl: int, alias: str, created: Optional[float] = None ) -> None: @@ -345,6 +355,8 @@ class DNSText(DNSRecord): """A DNS text record""" + __slots__ = ('text',) + def __init__( self, name: str, type_: int, class_: int, ttl: int, text: bytes, created: Optional[float] = None ) -> None: @@ -375,6 +387,8 @@ class DNSService(DNSRecord): """A DNS service record""" + __slots__ = ('priority', 'weight', 'port', 'server') + def __init__( self, name: str, @@ -423,6 +437,8 @@ def __repr__(self) -> str: class DNSRRSet: """A set of dns records independent of the ttl.""" + __slots__ = ('_records', '_lookup') + def __init__(self, records: Iterable[DNSRecord]) -> None: """Create an RRset from records.""" self._records = records From df66da2a943b9ff978602680b746f1edeba048dc Mon Sep 17 00:00:00 2001 From: ZLJasonG <36852337+ZLJasonG@users.noreply.github.com> Date: Tue, 22 Jun 2021 01:16:06 +0100 Subject: [PATCH 0487/1433] Skip network adapters that are disconnected (#327) Co-authored-by: J. Nick Koston --- zeroconf/_utils/net.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 19500e0f7..80a4377bf 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -268,6 +268,13 @@ def add_multicast_member( if _errno in err_einval: log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) return False + if _errno == errno.ENOPROTOOPT: + log.info( + 'Failed to set socket option on %s, this can happen if ' + 'the network adapter is in a disconnected state', + interface, + ) + return False raise return True From 59e4bd25347aac254700dc3a1518676042982b3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 14:17:49 -1000 Subject: [PATCH 0488/1433] Update changelog (#804) --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index a4ece3b1a..ef44bcecb 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,20 @@ See examples directory for more. Changelog ========= + +0.32.0 Beta 3 (Unreleased) +========================== + +* Skip network adapters that are disconnected (#327) @ZLJasonG + +* Add slots to DNS classes (#803) @bdraco + + On a busy network that receives many mDNS packets per second, we + will not know the answer to most of the questions being asked. + In this case the creating the DNS* objects are usually garbage + collected within 1s as they are not needed. We now set __slots__ + to speed up the creation and destruction of these objects + 0.32.0 Beta 2 ============= From 5dccf3496a9bd4c268da4c39aab545ddcd50ac57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Jun 2021 14:21:44 -1000 Subject: [PATCH 0489/1433] Tag 0.32.0b3 (#805) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ef44bcecb..020515785 100644 --- a/README.rst +++ b/README.rst @@ -141,8 +141,8 @@ Changelog ========= -0.32.0 Beta 3 (Unreleased) -========================== +0.32.0 Beta 3 +============= * Skip network adapters that are disconnected (#327) @ZLJasonG From 05bb21b9b43f171e30b48fad6a756df49162b557 Mon Sep 17 00:00:00 2001 From: ibygrave Date: Tue, 22 Jun 2021 18:01:10 +0100 Subject: [PATCH 0490/1433] Qualify IPv6 link-local addresses with scope_id (#343) Co-authored-by: Lokesh Prajapati Co-authored-by: de Angelis, Antonio When a service is advertised on an IPv6 address where the scope is link local, i.e. fe80::/64 (see RFC 4007) the resolved IPv6 address must be extended with the scope_id that identifies through the "%" symbol the local interface to be used when routing to that address. A new API `parsed_scoped_addresses()` is provided to return qualified addresses to avoid breaking compatibility on the existing parsed_addresses(). --- examples/async_browser.py | 2 +- examples/browser.py | 3 ++- tests/services/test_browser.py | 8 +++--- tests/services/test_info.py | 41 +++++++++++++++++++++++------- zeroconf/_core.py | 46 ++++++++++++++++++++++++++-------- zeroconf/_dns.py | 20 ++++++++++++--- zeroconf/_protocol.py | 9 ++++--- zeroconf/_services/info.py | 27 ++++++++++++++++++-- 8 files changed, 121 insertions(+), 35 deletions(-) diff --git a/examples/async_browser.py b/examples/async_browser.py index b835307c8..85192e140 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -28,7 +28,7 @@ async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name await info.async_request(zeroconf, 3000) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) diff --git a/examples/browser.py b/examples/browser.py index 2f2644399..8525e9b9b 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -21,8 +21,9 @@ def on_service_state_change( if state_change is ServiceStateChange.Added: info = zeroconf.get_service_info(service_type, name) print("Info from zeroconf.get_service_info: %r" % (info)) + if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(" Server: %s" % (info.server,)) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 331e1f0d5..e95a7b5fc 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -447,10 +447,10 @@ def current_time_millis(): """Current system time in milliseconds""" return start_time + time_offset * 1000 - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): """Sends an outgoing packet.""" got_query.set() - old_send(out, addr=addr, port=port) + old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) # patch the zeroconf send # patch the zeroconf current_time_millis @@ -674,7 +674,7 @@ def current_time_millis(): expected_ttl = const._DNS_HOST_TTL nbr_answers = 0 - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): """Sends an outgoing packet.""" pout = r.DNSIncoming(out.packets()[0]) nonlocal nbr_answers @@ -686,7 +686,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): got_query.set() got_query.clear() - old_send(out, addr=addr, port=port) + old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) # patch the zeroconf send # patch the zeroconf current_time_millis diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 438ad8194..d9ca43a70 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -201,6 +201,8 @@ def test_get_info_partial(self): service_server = 'ash-1.local.' service_text = b'path=/~matt1/' service_address = '10.0.1.2' + service_address_v6_ll = 'fe80::52e:c2f2:bc5f:e9c6' + service_scope_id = 12 service_info = None send_event = Event() @@ -208,7 +210,7 @@ def test_get_info_partial(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): """Sends an outgoing packet.""" nonlocal last_sent @@ -316,7 +318,15 @@ def get_service_info_helper(zc, type, name): const._CLASS_IN | const._CLASS_UNIQUE, ttl, socket.inet_pton(socket.AF_INET, service_address), - ) + ), + r.DNSAddress( + service_server, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET6, service_address_v6_ll), + scope_id=service_scope_id, + ), ] ), ) @@ -345,7 +355,7 @@ def test_get_info_single(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): """Sends an outgoing packet.""" nonlocal last_sent @@ -442,6 +452,8 @@ def test_multiple_addresses(): info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) assert info.addresses == [address, address] + assert info.parsed_addresses() == [address_parsed, address_parsed] + assert info.parsed_scoped_addresses() == [address_parsed, address_parsed] info = ServiceInfo( type_, @@ -454,10 +466,16 @@ def test_multiple_addresses(): parsed_addresses=[address_parsed, address_parsed], ) assert info.addresses == [address, address] + assert info.parsed_addresses() == [address_parsed, address_parsed] + assert info.parsed_scoped_addresses() == [address_parsed, address_parsed] if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) + address_v6_ll_parsed = "fe80::52e:c2f2:bc5f:e9c6" + address_v6_ll_scoped_parsed = "fe80::52e:c2f2:bc5f:e9c6%12" + address_v6_ll = socket.inet_pton(socket.AF_INET6, address_v6_ll_parsed) + interface_index = 12 infos = [ ServiceInfo( type_, @@ -467,7 +485,8 @@ def test_multiple_addresses(): 0, desc, "ash-2.local.", - addresses=[address, address_v6], + addresses=[address, address_v6, address_v6_ll], + interface_index=interface_index, ), ServiceInfo( type_, @@ -477,17 +496,21 @@ def test_multiple_addresses(): 0, desc, "ash-2.local.", - parsed_addresses=[address_parsed, address_v6_parsed], + parsed_addresses=[address_parsed, address_v6_parsed, address_v6_ll_parsed], + interface_index=interface_index, ), ] for info in infos: assert info.addresses == [address] - assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6] + assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6, address_v6_ll] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6] - assert info.parsed_addresses() == [address_parsed, address_v6_parsed] + assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6, address_v6_ll] + assert info.parsed_addresses() == [address_parsed, address_v6_parsed, address_v6_ll_parsed] assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed, address_v6_ll_parsed] + assert info.parsed_scoped_addresses() == [address_v6_ll_scoped_parsed, address_parsed, address_v6_parsed] + assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] + assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [address_v6_ll_scoped_parsed, address_v6_parsed] # This test uses asyncio because it needs to access the cache directly diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 2fa093b7a..a760f404f 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -191,12 +191,16 @@ def datagram_received( self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: assert self.transport is not None + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () if len(addrs) == 2: # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore + scope = None elif len(addrs) == 4: # https://github.com/python/mypy/issues/1178 - addr, port, _flow, _scope = addrs # type: ignore + addr, port, flow, scope = addrs # type: ignore + log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + v6_flow_scope = (flow, scope) else: return @@ -212,7 +216,7 @@ def datagram_received( return self.data = data - msg = DNSIncoming(data) + msg = DNSIncoming(data, scope) if msg.valid: log.debug( 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', @@ -238,7 +242,7 @@ def datagram_received( self.zc.handle_response(msg) return - self.zc.handle_query(msg, addr, port) + self.zc.handle_query(msg, addr, port, v6_flow_scope) def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" @@ -589,7 +593,9 @@ def handle_response(self, msg: DNSIncoming) -> None: are held in the cache, and listeners are notified.""" self.record_manager.async_updates_from_response(msg) - def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: + def handle_query( + self, msg: DNSIncoming, addr: str, port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + ) -> None: """Deal with incoming query packets. Provides a response if possible.""" if not msg.truncated: @@ -606,9 +612,15 @@ def handle_query(self, msg: DNSIncoming, addr: str, port: int) -> None: assert self.loop is not None if addr in self._timers: self._timers.pop(addr).cancel() - self._timers[addr] = self.loop.call_later(delay, self._respond_query, None, addr, port) + self._timers[addr] = self.loop.call_later(delay, self._respond_query, None, addr, port, v6_flow_scope) - def _respond_query(self, msg: Optional[DNSIncoming], addr: str, port: int) -> None: + def _respond_query( + self, + msg: Optional[DNSIncoming], + addr: str, + port: int, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" if addr in self._timers: self._timers.pop(addr).cancel() @@ -618,16 +630,28 @@ def _respond_query(self, msg: Optional[DNSIncoming], addr: str, port: int) -> No unicast_out, multicast_out = self.query_handler.async_response(packets, addr, port) if unicast_out: - self.async_send(unicast_out, addr, port) + self.async_send(unicast_out, addr, port, v6_flow_scope) if multicast_out: self.async_send(multicast_out, None, _MDNS_PORT) - def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + def send( + self, + out: DNSOutgoing, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None - self.loop.call_soon_threadsafe(self.async_send, out, addr, port) + self.loop.call_soon_threadsafe(self.async_send, out, addr, port, v6_flow_scope) - def async_send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None: + def async_send( + self, + out: DNSOutgoing, + addr: Optional[str] = None, + port: int = _MDNS_PORT, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: """Sends an outgoing packet.""" for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: @@ -653,7 +677,7 @@ def async_send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _ continue else: real_addr = addr - transport.sendto(packet, (real_addr, port or _MDNS_PORT)) + transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) except OSError as exc: if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: # with IPv6 we don't have a reliable way to determine if an interface actually has diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index af3057bf6..dbe009d2b 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -242,13 +242,22 @@ class DNSAddress(DNSRecord): """A DNS address record""" - __slots__ = ('address',) + __slots__ = ('address', 'scope_id') def __init__( - self, name: str, type_: int, class_: int, ttl: int, address: bytes, created: Optional[float] = None + self, + name: str, + type_: int, + class_: int, + ttl: int, + address: bytes, + *, + scope_id: Optional[int] = None, + created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_, ttl, created) self.address = address + self.scope_id = scope_id def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -257,12 +266,15 @@ def write(self, out: 'DNSOutgoing') -> None: def __eq__(self, other: Any) -> bool: """Tests equality on address""" return ( - isinstance(other, DNSAddress) and DNSEntry.__eq__(self, other) and self.address == other.address + isinstance(other, DNSAddress) + and DNSEntry.__eq__(self, other) + and self.address == other.address + and self.scope_id == other.scope_id ) def __hash__(self) -> int: """Hash to compare like DNSAddresses.""" - return hash((*self._entry_tuple(), self.address)) + return hash((*self._entry_tuple(), self.address, self.scope_id)) def __repr__(self) -> str: """String representation""" diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 53a7b710c..50bbca28d 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -79,7 +79,7 @@ class DNSIncoming(DNSMessage, QuietLogger): """Object representation of an incoming DNS packet""" - def __init__(self, data: bytes) -> None: + def __init__(self, data: bytes, scope_id: Optional[int] = None) -> None: """Constructor from string holding bytes of packet""" super().__init__(0) self.offset = 0 @@ -93,6 +93,7 @@ def __init__(self, data: bytes) -> None: self.num_additionals = 0 self.valid = False self.now = current_time_millis() + self.scope_id = scope_id try: self.read_header() @@ -169,7 +170,7 @@ def read_others(self) -> None: type_, class_, ttl, length = self.unpack(b'!HHiH') rec: Optional[DNSRecord] = None if type_ == _TYPE_A: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4), self.now) + rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) elif type_ in (_TYPE_CNAME, _TYPE_PTR): rec = DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) elif type_ == _TYPE_TXT: @@ -197,7 +198,9 @@ def read_others(self) -> None: self.now, ) elif type_ == _TYPE_AAAA: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16), self.now) + rec = DNSAddress( + domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id + ) else: # Try to ignore types we don't know about # Skip the payload for the resource record so the next diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index f7ab9e55d..4365d6ef3 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -21,8 +21,9 @@ """ import asyncio +import ipaddress import socket -from typing import Dict, List, Optional, TYPE_CHECKING, Union, cast +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, cast from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException @@ -85,6 +86,8 @@ class ServiceInfo(RecordUpdateListener): * `other_ttl`: ttl used for PTR/TXT records * `addresses` and `parsed_addresses`: List of IP addresses (either as bytes, network byte order, or in parsed form as text; at most one of those parameters can be provided) + * interface_index: scope_id or zone_id for IPv6 link-local addresses i.e. an identifier of the interface + where the peer is connected to """ text = b'' @@ -103,6 +106,7 @@ def __init__( *, addresses: Optional[List[bytes]] = None, parsed_addresses: Optional[List[str]] = None, + interface_index: Optional[int] = None, ) -> None: # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: @@ -137,6 +141,7 @@ def __init__( self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl + self.interface_index = interface_index @property def name(self) -> str: @@ -194,6 +199,21 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: for addr in result ] + def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local + addresses are qualified with % when available + """ + if self.interface_index is None: + return self.parsed_addresses(version) + + def is_link_local(addr_str: str) -> Any: + addr = ipaddress.ip_address(addr_str) + return addr.version == 6 and addr.is_link_local + + ll_addrs = list(filter(is_link_local, self.parsed_addresses(version))) + other_addrs = list(filter(lambda addr: not is_link_local(addr), self.parsed_addresses(version))) + return ["{}%{}".format(addr, self.interface_index) for addr in ll_addrs] + other_addrs + def _set_properties(self, properties: Dict) -> None: """Sets properties and text of this info from a dictionary""" self._properties = properties @@ -289,6 +309,8 @@ def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: if isinstance(record, DNSAddress): if record.key == self.server_key and record.address not in self._addresses: self._addresses.append(record.address) + if record.type is _TYPE_AAAA and ipaddress.IPv6Address(record.address).is_link_local: + self.interface_index = record.scope_id return if isinstance(record, DNSService): @@ -320,7 +342,7 @@ def dns_addresses( _CLASS_IN | _CLASS_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, address, - created, + created=created, ) for address in self.addresses_by_version(version) ] @@ -474,6 +496,7 @@ def __repr__(self) -> str: 'priority', 'server', 'properties', + 'interface_index', ) ), ) From 0129ac061db4a950f7bddf1084309e44aaabdbdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 07:28:40 -1000 Subject: [PATCH 0491/1433] Format tests/services/test_info.py with newer black (#809) --- tests/services/test_info.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index d9ca43a70..e55f03ce6 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -508,9 +508,16 @@ def test_multiple_addresses(): assert info.parsed_addresses() == [address_parsed, address_v6_parsed, address_v6_ll_parsed] assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed, address_v6_ll_parsed] - assert info.parsed_scoped_addresses() == [address_v6_ll_scoped_parsed, address_parsed, address_v6_parsed] + assert info.parsed_scoped_addresses() == [ + address_v6_ll_scoped_parsed, + address_parsed, + address_v6_parsed, + ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [address_v6_ll_scoped_parsed, address_v6_parsed] + assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ + address_v6_ll_scoped_parsed, + address_v6_parsed, + ] # This test uses asyncio because it needs to access the cache directly From f9bbbce388f2c6c24109c15ef843c10eeccf008f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 07:46:10 -1000 Subject: [PATCH 0492/1433] Make DNSHinfo and DNSAddress use the same match order as DNSPointer and DNSText (#808) We want to check the data that is most likely to be unique first so we can reject the __eq__ as soon as possible. --- zeroconf/_dns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index dbe009d2b..93db9859c 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -267,9 +267,9 @@ def __eq__(self, other: Any) -> bool: """Tests equality on address""" return ( isinstance(other, DNSAddress) - and DNSEntry.__eq__(self, other) and self.address == other.address and self.scope_id == other.scope_id + and DNSEntry.__eq__(self, other) ) def __hash__(self) -> int: @@ -310,9 +310,9 @@ def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" return ( isinstance(other, DNSHinfo) - and DNSEntry.__eq__(self, other) and self.cpu == other.cpu and self.os == other.os + and DNSEntry.__eq__(self, other) ) def __hash__(self) -> int: From d4c8f0d3ffdcdc609810aca383492a57f9e1a723 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 07:53:40 -1000 Subject: [PATCH 0493/1433] Simplify wait_event_or_timeout (#810) - This function always did the same thing on timeout and wait complete so we can use the same callback. This solves the CI failing due to the test coverage flapping back and forth as the timeout would rarely happen. --- zeroconf/_utils/aio.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index 0b6d8dba2..7cc3b7fa4 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -23,7 +23,7 @@ import asyncio import contextlib import queue -from typing import List, Optional, Set, cast +from typing import Any, List, Optional, Set, cast def get_best_available_queue() -> queue.Queue: @@ -39,18 +39,13 @@ async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: loop = asyncio.get_event_loop() future = loop.create_future() - def _handle_timeout() -> None: + def _handle_timeout_or_wait_complete(*_: Any) -> None: if not future.done(): future.set_result(None) - timer_handle = loop.call_later(timeout, _handle_timeout) + timer_handle = loop.call_later(timeout, _handle_timeout_or_wait_complete) event_wait = loop.create_task(event.wait()) - - def _handle_wait_complete(_: asyncio.Task) -> None: - if not future.done(): - future.set_result(None) - - event_wait.add_done_callback(_handle_wait_complete) + event_wait.add_done_callback(_handle_timeout_or_wait_complete) try: await future From 13c558cf3f40e52a13347a39b050e49a9241c269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 07:56:36 -1000 Subject: [PATCH 0494/1433] Update changelog (#811) --- README.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.rst b/README.rst index 020515785..1fd39c7f0 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,31 @@ See examples directory for more. Changelog ========= +0.32.0 Beta 4 +============= + +* Simplify wait_event_or_timeout (#810) @bdraco + + This function always did the same thing on timeout and + wait complete so we can use the same callback. This + solves the CI failing due to the test coverage flapping + back and forth as the timeout would rarely happen. + +* Make DNSHinfo and DNSAddress use the same match order as DNSPointer and DNSText (#808) @bdraco + + We want to check the data that is most likely to be unique first + so we can reject the __eq__ as soon as possible. + +* Qualify IPv6 link-local addresses with scope_id (#343) @ibygrave + + When a service is advertised on an IPv6 address where + the scope is link local, i.e. fe80::/64 (see RFC 4007) + the resolved IPv6 address must be extended with the + scope_id that identifies through the "%" symbol the + local interface to be used when routing to that address. + A new API `parsed_scoped_addresses()` is provided to + return qualified addresses to avoid breaking compatibility + on the existing parsed_addresses(). 0.32.0 Beta 3 ============= From e32bb5d98be0dc7ed130224206a4de699bcd68e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 13:24:56 -1000 Subject: [PATCH 0495/1433] New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) --- tests/services/test_browser.py | 19 ++++++++++++------- zeroconf/_services/browser.py | 26 +++++++++++++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index e95a7b5fc..b78e0c625 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -544,8 +544,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() -def test_asking_default_is_asking_qm_questions(): - """Verify the service browser can ask QU questions.""" +def test_asking_default_is_asking_qm_questions_after_the_first_qu(): + """Verify the service browser's first question is QU and subsequent ones are QM questions.""" type_ = "_quservice._tcp.local." zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) @@ -553,10 +553,14 @@ def test_asking_default_is_asking_qm_questions(): old_send = zeroconf_browser.async_send first_outgoing = None + second_outgoing = None def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" nonlocal first_outgoing + nonlocal second_outgoing + if first_outgoing is not None and second_outgoing is None: + second_outgoing = out if first_outgoing is None: first_outgoing = out old_send(out, addr=addr, port=port) @@ -567,10 +571,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): def on_service_state_change(zeroconf, service_type, state_change, name): pass - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change], delay=5) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 120 + 5)) try: - assert first_outgoing.questions[0].unicast == False + assert first_outgoing.questions[0].unicast == True + assert second_outgoing.questions[0].unicast == False finally: browser.cancel() zeroconf_browser.close() @@ -1016,7 +1021,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zc = aiozc.zeroconf now = current_time_millis() - name = "_hap._tcp.local." + name = "_suppresstest._tcp.local." question = r.DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN) answer = r.DNSPointer( name, @@ -1048,7 +1053,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): outs = _services_browser.generate_service_query(zc, now, [name], multicast=False) assert outs - zc.question_history.async_expire(now + 1000) + zc.question_history.async_expire(now + 2000) # No suppression after clearing the history outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) assert outs diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 1a7caca8c..40a80df3d 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -146,11 +146,14 @@ def generate_service_query( for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) ) - if multicast and zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): + if not qu_question and zc.question_history.suppresses( + question, now, cast(Set[DNSRecord], known_answers) + ): log.debug("Asking %s was suppressed by the question history", question) continue questions_with_known_answers[question] = known_answers - zc.question_history.add_question_at_time(question, now, cast(Set[DNSRecord], known_answers)) + if not qu_question: + zc.question_history.add_question_at_time(question, now, cast(Set[DNSRecord], known_answers)) return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) @@ -379,7 +382,7 @@ def _async_cancel(self) -> None: self.done = True self.zc.async_remove_listener(self) - def generate_ready_queries(self) -> List[DNSOutgoing]: + def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" now = current_time_millis() if self._millis_to_wait(current_time_millis()): @@ -395,7 +398,13 @@ def generate_ready_queries(self) -> List[DNSOutgoing]: self._next_time[type_] = now + self._delay[type_] self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - return generate_service_query(self.zc, now, ready_types, self.multicast, self.question_type) + # If they did not specify and this is the first request, ask QU questions + # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are + # just starting up and we know our cache is likely empty. This ensures + # the next outgoing will be sent with the known answers list. + question_type = DNSQuestionType.QU if not self.question_type and first_request else self.question_type + + return generate_service_query(self.zc, now, ready_types, self.multicast, question_type) def _millis_to_wait(self, now: float) -> Optional[float]: """Returns the number of milliseconds to wait for the next event.""" @@ -406,14 +415,17 @@ def _millis_to_wait(self, now: float) -> Optional[float]: async def async_browser_task(self) -> None: """Run the browser task.""" await self.zc.async_wait_for_start() + first_request = True while True: timeout = self._millis_to_wait(current_time_millis()) if timeout: await self.zc.async_wait(timeout) - outs = self.generate_ready_queries() - for out in outs: - self.zc.async_send(out, addr=self.addr, port=self.port) + outs = self._generate_ready_queries(first_request) + if outs: + first_request = False + for out in outs: + self.zc.async_send(out, addr=self.addr, port=self.port) async def _async_cancel_browser(self) -> None: """Cancel the browser.""" From ffd2532f72a59ede86732b310512774b8fa344e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 13:43:20 -1000 Subject: [PATCH 0496/1433] Turn on logging in the types test (#816) - Will be needed to track down #813 --- tests/services/test_types.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index ba355bae5..7a07085f2 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -4,17 +4,31 @@ """Unit tests for zeroconf._services.types.""" +import logging import os import unittest import socket import sys -import time import zeroconf as r from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes from .. import _clear_cache, has_working_ipv6 +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + class ServiceTypesQuery(unittest.TestCase): def test_integration_with_listener(self): From f9d35299a39fee0b1632a3b2ac00170f761d53b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 13:58:10 -1000 Subject: [PATCH 0497/1433] Fix default v6_flow_scope argument with tests that mock send (#819) --- tests/services/test_browser.py | 4 ++-- tests/services/test_info.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index b78e0c625..688ad18b5 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -447,7 +447,7 @@ def current_time_millis(): """Current system time in milliseconds""" return start_time + time_offset * 1000 - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" got_query.set() old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) @@ -679,7 +679,7 @@ def current_time_millis(): expected_ttl = const._DNS_HOST_TTL nbr_answers = 0 - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" pout = r.DNSIncoming(out.packets()[0]) nonlocal nbr_answers diff --git a/tests/services/test_info.py b/tests/services/test_info.py index e55f03ce6..348d623dc 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -210,7 +210,7 @@ def test_get_info_partial(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" nonlocal last_sent @@ -355,7 +355,7 @@ def test_get_info_single(self): last_sent = None # type: Optional[r.DNSOutgoing] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=None): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" nonlocal last_sent From a7b4f8e070de69db1ed872e2ff7a953ec624394c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 16:48:02 -1000 Subject: [PATCH 0498/1433] Fix reliablity of tests that patch sending (#820) --- tests/__init__.py | 6 ++++++ tests/services/test_browser.py | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 0e7aa930b..d77140fdb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -41,6 +41,12 @@ async def _wait_for_response(): asyncio.run_coroutine_threadsafe(_wait_for_response(), zc.loop).result() +def _wait_for_start(zc: Zeroconf) -> None: + """Wait for all sockets to be up and running.""" + assert zc.loop is not None + asyncio.run_coroutine_threadsafe(zc.async_wait_for_start(), zc.loop).result() + + @lru_cache(maxsize=None) def has_working_ipv6(): """Return True if if the system can bind an IPv6 address.""" diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 688ad18b5..35fe487fb 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -22,7 +22,7 @@ from zeroconf._services.info import ServiceInfo from zeroconf.aio import AsyncZeroconf -from .. import has_working_ipv6, _inject_response +from .. import has_working_ipv6, _inject_response, _wait_for_start log = logging.getLogger('zeroconf') @@ -435,6 +435,7 @@ def test_backoff(suppresses_mock): type_ = "_http._tcp.local." zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + _wait_for_start(zeroconf_browser) # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.async_send @@ -513,6 +514,7 @@ def test_first_query_delay(): """ type_ = "_http._tcp.local." zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + _wait_for_start(zeroconf_browser) # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.async_send @@ -666,6 +668,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): service_removed.set() zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + _wait_for_start(zeroconf_browser) # we are going to patch the zeroconf send to check packet sizes old_send = zeroconf_browser.async_send From 4062fe21d8baaad36960f8cae0f59ac7083a6b55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 17:06:49 -1000 Subject: [PATCH 0499/1433] Only wake up the query loop when there is a change in the next query time (#818) The ServiceBrowser query loop (async_browser_task) was being awoken on every packet because it was using `zeroconf.async_wait` which wakes up on every new packet. We only need to awaken the loop when the next time we are going to send a query has changed. fixes #814 fixes #768 --- tests/services/test_browser.py | 97 ++++++++++++++++++++-- zeroconf/_services/browser.py | 145 ++++++++++++++++++++++----------- 2 files changed, 187 insertions(+), 55 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 35fe487fb..eac26a4cb 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -4,6 +4,7 @@ """ Unit tests for zeroconf._services.browser. """ +import asyncio import logging import socket import time @@ -476,13 +477,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): expected_query_time = 0.0 while True: sleep_count += 1 - for _ in range(2): - # If the browser thread is starting up - # its possible we notify before the initial sleep - # which means the test will fail so we need to d - # this twice to eliminate the race condition - zeroconf_browser.notify_all() - got_query.wait(0.05) + got_query.wait(0.1) if time_offset == expected_query_time: assert got_query.is_set() got_query.clear() @@ -501,6 +496,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): else: assert not got_query.is_set() time_offset += initial_query_interval + zeroconf_browser.loop.call_soon_threadsafe(browser.query_scheduler.set_schedule_changed) finally: browser.cancel() @@ -726,7 +722,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): while nbr_answers < test_iterations: # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 - zeroconf_browser.notify_all() + zeroconf_browser.loop.call_soon_threadsafe(browser.query_scheduler.set_schedule_changed) sleep_count += 1 got_query.wait(0.5) # Prevent the test running indefinitely in an error condition @@ -1067,3 +1063,88 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert not outs await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_query_scheduler(): + delay = const._BROWSER_TIME + types_ = set(["_hap._tcp.local.", "_http._tcp.local."]) + query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0)) + + now = current_time_millis() + query_scheduler.start(now) + + # Test query interval is increasing + assert query_scheduler.millis_to_wait(now - 1) == 1 + assert query_scheduler.millis_to_wait(now) is None + assert query_scheduler.millis_to_wait(now + 1) is None + + assert set(query_scheduler.process_ready_types(now)) == types_ + assert set(query_scheduler.process_ready_types(now)) == set() + assert query_scheduler.millis_to_wait(now) == delay + + assert set(query_scheduler.process_ready_types(now + delay)) == types_ + assert set(query_scheduler.process_ready_types(now + delay)) == set() + assert query_scheduler.millis_to_wait(now) == delay * 3 + + assert set(query_scheduler.process_ready_types(now + delay * 3)) == types_ + assert set(query_scheduler.process_ready_types(now + delay * 3)) == set() + assert query_scheduler.millis_to_wait(now) == delay * 7 + + assert set(query_scheduler.process_ready_types(now + delay * 7)) == types_ + assert set(query_scheduler.process_ready_types(now + delay * 7)) == set() + assert query_scheduler.millis_to_wait(now) == delay * 15 + + assert set(query_scheduler.process_ready_types(now + delay * 15)) == types_ + assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() + + # Test if we reschedule 1 second later, the millis_to_wait goes up by 1 + query_scheduler.reschedule_type("_hap._tcp.local.", now + delay * 16) + assert query_scheduler.millis_to_wait(now) == delay * 16 + + assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() + + # Test if we reschedule 1 second later... and its ready for processing + assert set(query_scheduler.process_ready_types(now + delay * 16)) == set(["_hap._tcp.local."]) + assert query_scheduler.millis_to_wait(now) == delay * 31 + assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() + + assert set(query_scheduler.process_ready_types(now + delay * 31)) == set(["_http._tcp.local."]) + + +@pytest.mark.asyncio +async def test_query_scheduler_triggers_async_wait_ready_on_reschedule(): + """Test that a reschedule wakes up the async_wait_ready.""" + delay = const._BROWSER_TIME + types_ = set(["_hap._tcp.local.", "_http._tcp.local."]) + query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0)) + + now = current_time_millis() + query_scheduler.start(now) + assert set(query_scheduler.process_ready_types(now)) == types_ + assert query_scheduler.millis_to_wait(now) == delay + + task = asyncio.ensure_future(query_scheduler.async_wait_ready(now)) + await asyncio.sleep(0) # Start the task + await asyncio.sleep(0) # Make sure its waiting + assert not task.done() + assert query_scheduler.millis_to_wait(now + 1) == delay - 1 + query_scheduler.reschedule_type("_hap._tcp.local.", now + 1) + assert query_scheduler.millis_to_wait(now + 1) is None + await asyncio.wait_for(task, timeout=0.1) + assert task.done() + + task2 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000)) + assert set(query_scheduler.process_ready_types(now + 1)) == set(["_hap._tcp.local."]) + assert not task2.done() + assert query_scheduler.millis_to_wait(now + 2) == delay - 2 + query_scheduler.reschedule_type("_hap._tcp.local.", now + 2) + assert query_scheduler.millis_to_wait(now + 2) is None + await asyncio.wait_for(task2, timeout=0.1) + assert task2.done() + assert set(query_scheduler.process_ready_types(now + 10000)) == types_ + assert query_scheduler.millis_to_wait(now + 10000) == delay * 2 + + task3 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000)) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(task3, timeout=0.1) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 40a80df3d..a7abca4f0 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -39,7 +39,7 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.aio import get_best_available_queue +from .._utils.aio import get_best_available_queue, wait_event_or_timeout from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( @@ -183,6 +183,89 @@ def on_change( return on_change +class QueryScheduler: + """Schedule outgoing PTR queries for Continuous Multicast DNS Querying + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + + """ + + def __init__( + self, + types: Set[str], + delay: int, + first_random_delay_interval: Tuple[int, int], + ): + self._schedule_changed_event: Optional[asyncio.Event] = None + self._types = types + self._next_time: Dict[str, float] = {} + self._first_random_delay_interval = first_random_delay_interval + self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self._types} + + def start(self, now: float) -> None: + """Start the scheduler.""" + self._schedule_changed_event = asyncio.Event() + self._generate_first_next_time(now) + + def _generate_first_next_time(self, now: float) -> None: + """Generate the initial next query times. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + To avoid accidental synchronization when, for some reason, multiple + clients begin querying at exactly the same moment (e.g., because of + some common external trigger event), a Multicast DNS querier SHOULD + also delay the first query of the series by a randomly chosen amount + in the range 20-120 ms. + """ + delay = millis_to_seconds(random.randint(*self._first_random_delay_interval)) + next_time = now + delay + self._next_time = {check_type_: next_time for check_type_ in self._types} + + def millis_to_wait(self, now: float) -> Optional[float]: + """Returns the number of milliseconds to wait for the next event.""" + # Wait for the type has the smallest next time + next_time = min(self._next_time.values()) + return None if next_time <= now else next_time - now + + def reschedule_type(self, type_: str, next_time: float) -> None: + """Reschedule the query for a type to happen sooner.""" + if next_time >= self._next_time[type_]: + return + + self._next_time[type_] = next_time + self.set_schedule_changed() + + def set_schedule_changed(self) -> None: + """Set the event to unblock async_wait_ready to make sure the adjusted next time is seen.""" + assert self._schedule_changed_event is not None + self._schedule_changed_event.set() + self._schedule_changed_event.clear() + + def process_ready_types(self, now: float) -> List[str]: + """Generate a list of ready types that is due and schedule the next time.""" + if self.millis_to_wait(now): + return [] + + ready_types: List[str] = [] + + for type_, due in self._next_time.items(): + if due > now: + continue + + ready_types.append(type_) + self._next_time[type_] = now + self._delay[type_] + self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + + return ready_types + + async def async_wait_ready(self, now: float) -> None: + """Wait for at least one query to be ready.""" + timeout = self.millis_to_wait(now) + if timeout: + assert self._schedule_changed_event is not None + await wait_event_or_timeout(self._schedule_changed_event, timeout=millis_to_seconds(timeout)) + + class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" @@ -225,10 +308,9 @@ def __init__( self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) self.question_type = question_type - self._next_time: Dict[str, float] = {} - self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self.types} self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() + self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) self.queue: Optional[queue.Queue] = None self.done = False @@ -250,25 +332,11 @@ def _async_start(self) -> None: Must be called by uses of this base class after they have finished setting their properties. """ - self._generate_first_next_time() + self.query_scheduler.start(current_time_millis()) self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) # Only start queries after the listener is installed self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) - def _generate_first_next_time(self) -> None: - """Generate the initial next query times. - - https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 - To avoid accidental synchronization when, for some reason, multiple - clients begin querying at exactly the same moment (e.g., because of - some common external trigger event), a Multicast DNS querier SHOULD - also delay the first query of the series by a randomly chosen amount - in the range 20-120 ms. - """ - delay = millis_to_seconds(random.randint(*_FIRST_QUERY_DELAY_RANDOM_INTERVAL)) - next_time = current_time_millis() + delay - self._next_time = {check_type_: next_time for check_type_ in self.types} - @property def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface @@ -310,9 +378,9 @@ def _async_process_record_update( elif expired: self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: - expires = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - if expires < self._next_time[record.name]: - self._next_time[record.name] = expires + self.query_scheduler.reschedule_type( + record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + ) return # If its expired or already exists in the cache it cannot be updated. @@ -385,47 +453,30 @@ def _async_cancel(self) -> None: def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" now = current_time_millis() - if self._millis_to_wait(current_time_millis()): + ready_types = self.query_scheduler.process_ready_types(now) + if not ready_types: return [] - ready_types = [] - - for type_, due in self._next_time.items(): - if due > now: - continue - - ready_types.append(type_) - self._next_time[type_] = now + self._delay[type_] - self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) - # If they did not specify and this is the first request, ask QU questions # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are # just starting up and we know our cache is likely empty. This ensures # the next outgoing will be sent with the known answers list. question_type = DNSQuestionType.QU if not self.question_type and first_request else self.question_type - return generate_service_query(self.zc, now, ready_types, self.multicast, question_type) - def _millis_to_wait(self, now: float) -> Optional[float]: - """Returns the number of milliseconds to wait for the next event.""" - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - return None if next_time <= now else next_time - now - async def async_browser_task(self) -> None: """Run the browser task.""" await self.zc.async_wait_for_start() first_request = True while True: - timeout = self._millis_to_wait(current_time_millis()) - if timeout: - await self.zc.async_wait(timeout) - + await self.query_scheduler.async_wait_ready(current_time_millis()) outs = self._generate_ready_queries(first_request) - if outs: - first_request = False - for out in outs: - self.zc.async_send(out, addr=self.addr, port=self.port) + if not outs: + continue + + first_request = False + for out in outs: + self.zc.async_send(out, addr=self.addr, port=self.port) async def _async_cancel_browser(self) -> None: """Cancel the browser.""" From 4a8276941a07188180ee31dc4ca578306c2df92b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jun 2021 18:04:35 -1000 Subject: [PATCH 0500/1433] Update changelog (#822) --- README.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.rst b/README.rst index 1fd39c7f0..9f45c35f8 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,28 @@ See examples directory for more. Changelog ========= +0.32.0 Beta 5 +============= + +* Only wake up the query loop when there is a change in the next query time (#818) @bdraco + + The ServiceBrowser query loop (async_browser_task) was being awoken on + every packet because it was using `zeroconf.async_wait` which wakes + up on every new packet. We only need to awaken the loop when the next time + we are going to send a query has changed. + +* New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) @bdraco + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 + When we start a ServiceBrowser and zeroconf has just started up, the known + answer list will be small. By asking a QU question first, it is likely + that we have a large known answer list by the time we ask the QM question + a second later (current default which is likely too low but would be + a breaking change to increase). This reduces the amount of traffic on + the network, and has the secondary advantage that most responders will + answer a QU question without the typical delay answering QM questions. + + 0.32.0 Beta 4 ============= From 7f6d003210244b6f7df133bd474d7ddf64098422 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jun 2021 09:16:51 -1000 Subject: [PATCH 0501/1433] Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) * We now enforce a minimum TTL for PTR records to avoid ServiceBrowsers generating excessive queries refresh queries. Apple uses a 15s minimum TTL, however we do not have the same level of rate limit and safe guards so we use 1/4 of the recommended value. --- tests/test_core.py | 4 ++-- tests/test_handlers.py | 47 ++++++++++++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 20 ++++++++++++++++++ zeroconf/const.py | 5 +++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index ae397d16d..0a07bd51d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -367,8 +367,8 @@ def test_register_service_with_custom_ttl(): addresses=[socket.inet_aton("10.0.1.2")], ) - zc.register_service(info_service, ttl=30) - assert zc.cache.get(info_service.dns_pointer()).ttl == 30 + zc.register_service(info_service, ttl=3000) + assert zc.cache.get(info_service.dns_pointer()).ttl == 3000 zc.close() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 64e444951..ea29c528e 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1070,3 +1070,50 @@ def test_questions_query_handler_does_not_put_qu_questions_in_history(): assert not zc.question_history.suppresses(question, now, set([known_answer])) zc.close() + + +def test_guard_against_low_ptr_ttl(): + """Ensure we enforce a minimum for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. + + Some poorly designed IoT devices can set excessively low PTR + TTLs would will cause ServiceBrowsers to flood the network + with excessive refresh queries. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + # Apple uses a 15s minimum TTL, however we do not have the same + # level of rate limit and safe guards so we use 1/4 of the recommended value + answer_with_low_ttl = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 2, + 'low.local.', + ) + answer_with_normal_ttl = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'normal.local.', + ) + good_bye_answer = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 0, + 'goodbye.local.', + ) + # TTL should be adjusted to a safe value + response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + response.add_answer_at_time(answer_with_low_ttl, 0) + response.add_answer_at_time(answer_with_normal_ttl, 0) + response.add_answer_at_time(good_bye_answer, 0) + incoming = r.DNSIncoming(response.packets()[0]) + zc.record_manager.async_updates_from_response(incoming) + + incoming_answer_low = zc.cache.async_get_unique(answer_with_low_ttl) + assert incoming_answer_low.ttl == const._DNS_PTR_MIN_TTL + incoming_answer_normal = zc.cache.async_get_unique(answer_with_normal_ttl) + assert incoming_answer_normal.ttl == const._DNS_OTHER_TTL + assert zc.cache.async_get_unique(good_bye_answer) is None + zc.close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 128d8711d..617be4083 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -34,6 +34,7 @@ from .const import ( _CLASS_IN, _DNS_OTHER_TTL, + _DNS_PTR_MIN_TTL, _FLAGS_AA, _FLAGS_QR_RESPONSE, _MDNS_PORT, @@ -54,6 +55,23 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] +def sanitize_incoming_record(record: DNSRecord) -> None: + """Protect zeroconf from records that can cause denial of service. + + We enforce a minimum TTL for PTR records to avoid + ServiceBrowsers generating excessive queries refresh queries. + Apple uses a 15s minimum TTL, however we do not have the same + level of rate limit and safe guards so we use 1/4 of the recommended value. + """ + if record.ttl and record.ttl < _DNS_PTR_MIN_TTL and isinstance(record, DNSPointer): + log.debug( + "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", + record, + _DNS_PTR_MIN_TTL, + ) + record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) + + class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" @@ -321,6 +339,8 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: unique_types: Set[Tuple[str, int, int]] = set() for record in msg.answers: + sanitize_incoming_record(record) + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record.type, record.class_)) diff --git a/zeroconf/const.py b/zeroconf/const.py index df1ba8be5..8107a2af4 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -47,6 +47,11 @@ _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 _DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per RFC6762 +# Currently we enforce a minimum TTL for PTR records to avoid +# ServiceBrowsers generating excessive queries refresh queries. +# Apple uses a 15s minimum TTL, however we do not have the same +# level of rate limit and safe guards so we use 1/4 of the recommended value +_DNS_PTR_MIN_TTL = _DNS_OTHER_TTL / 4 _DNS_PACKET_HEADER_LEN = 12 From 6298ef9078cf2408bc1e57660ee141e882d13469 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jun 2021 09:19:13 -1000 Subject: [PATCH 0502/1433] Drop oversize packets before processing them (#826) - Oversized packets can quickly overwhelm the system and deny service to legitimate queriers. In practice this is usually due to broken mDNS implementations rather than malicious actors. --- tests/test_core.py | 70 +++++++++++++++++++++++++++++++++++++++++++++- zeroconf/_core.py | 12 ++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index 0a07bd51d..9f2412f06 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -17,7 +17,7 @@ from typing import cast import zeroconf as r -from zeroconf import _core, const, ServiceBrowser, Zeroconf, current_time_millis +from zeroconf import _core, _protocol, const, ServiceBrowser, Zeroconf, current_time_millis from zeroconf.aio import AsyncZeroconf from . import has_working_ipv6, _clear_cache, _inject_response @@ -629,3 +629,71 @@ async def test_multiple_sync_instances_stared_from_async_close(): assert zc3.loop.is_running() await asyncio.sleep(0) + + +def test_guard_against_oversized_packets(): + """Ensure we do not process oversized packets. + + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for i in range(5000): + generated.add_answer_at_time( + r.DNSText( + "packet{i}.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ), + 0, + ) + + # We are patching to generate an oversized packet + with unittest.mock.patch.object(_protocol, "_MAX_MSG_ABSOLUTE", 100000), unittest.mock.patch.object( + _protocol, "_MAX_MSG_TYPICAL", 100000 + ): + over_sized_packet = generated.packets()[0] + assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + okpacket_record = r.DNSText( + "okpacket.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + + generated.add_answer_at_time( + okpacket_record, + 0, + ) + ok_packet = generated.packets()[0] + + # We cannot test though the network interface as some operating systems + # will guard against the oversized packet and we won't see it. + listener = _core.AsyncListener(zc) + listener.transport = unittest.mock.MagicMock() + + listener.datagram_received(ok_packet, ('127.0.0.1', 5353)) + assert zc.cache.async_get_unique(okpacket_record) is not None + + listener.datagram_received(over_sized_packet, ('127.0.0.1', 5353)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + + zc.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a760f404f..8237eb16e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -216,6 +216,18 @@ def datagram_received( return self.data = data + + if len(data) > _MAX_MSG_ABSOLUTE: + # Guard against oversized packets to ensure bad implementations cannot overwhelm + # the system. + log.debug( + "Discarding incoming packet with length %s, which is larger " + "than the absolute maximum size of %s", + len(data), + _MAX_MSG_ABSOLUTE, + ) + return + msg = DNSIncoming(data, scope) if msg.valid: log.debug( From 82f80c301a6324d2f1711ca751e81069e90030ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jun 2021 09:22:15 -1000 Subject: [PATCH 0503/1433] Update changelog (#827) --- README.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.rst b/README.rst index 9f45c35f8..61c05e98f 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,26 @@ See examples directory for more. Changelog ========= +0.32.0 Beta 6 +============= + +This beta addresses two potential areas where zeroconf can be overwhelmed and +deny service to legitimate queriers. + +* BREAKING CHANGE: Drop oversize packets before processing them (#826) @bdraco + + Oversized packets can quickly overwhelm the system and deny + service to legitimate queriers. In practice this is usually + due to broken mDNS implementations rather than malicious + actors. + +* BREAKING CHANGE: Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) @bdraco + + We now enforce a minimum TTL for PTR records to avoid + ServiceBrowsers generating excessive queries refresh queries. + Apple uses a 15s minimum TTL, however we do not have the same + level of rate limit and safe guards so we use 1/4 of the recommended value. + 0.32.0 Beta 5 ============= From 4c4b388ba125ad23a03722b30c71da86853fe05a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 17:52:46 -1000 Subject: [PATCH 0504/1433] Convert test_integration to asyncio to avoid testing threading races (#828) Fixes #768 --- tests/services/test_browser.py | 92 -------------------------------- tests/test_aio.py | 97 +++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 93 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index eac26a4cb..eb25c7e27 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -647,98 +647,6 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() -def test_integration(): - service_added = Event() - service_removed = Event() - unexpected_ttl = Event() - got_query = Event() - - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ - - def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() - elif state_change is ServiceStateChange.Removed: - service_removed.set() - - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - _wait_for_start(zeroconf_browser) - - # we are going to patch the zeroconf send to check packet sizes - old_send = zeroconf_browser.async_send - - time_offset = 0.0 - - def current_time_millis(): - """Current system time in milliseconds""" - return time.time() * 1000 + time_offset * 1000 - - expected_ttl = const._DNS_HOST_TTL - nbr_answers = 0 - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): - """Sends an outgoing packet.""" - pout = r.DNSIncoming(out.packets()[0]) - nonlocal nbr_answers - for answer in pout.answers: - nbr_answers += 1 - if not answer.ttl > expected_ttl / 2: - unexpected_ttl.set() - - got_query.set() - got_query.clear() - - old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) - - # patch the zeroconf send - # patch the zeroconf current_time_millis - # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( - _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): - service_added = Event() - service_removed = Event() - - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - zeroconf_registrar.register_service(info) - - try: - service_added.wait(1) - assert service_added.is_set() - - # Test that we receive queries containing answers only if the remaining TTL - # is greater than half the original TTL - sleep_count = 0 - test_iterations = 50 - - while nbr_answers < test_iterations: - # Increase simulated time shift by 1/4 of the TTL in seconds - time_offset += expected_ttl / 4 - zeroconf_browser.loop.call_soon_threadsafe(browser.query_scheduler.set_schedule_changed) - sleep_count += 1 - got_query.wait(0.5) - # Prevent the test running indefinitely in an error condition - assert sleep_count < test_iterations * 4 - assert not unexpected_ttl.is_set() - - # Don't remove service, allow close() to cleanup - - finally: - zeroconf_registrar.close() - service_removed.wait(1) - assert service_removed.is_set() - browser.cancel() - zeroconf_browser.close() - - def test_legacy_record_update_listener(): """Test a RecordUpdateListener that does not implement update_records.""" diff --git a/tests/test_aio.py b/tests/test_aio.py index 4523ca1e5..0d587709f 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -7,16 +7,18 @@ import asyncio import logging import socket +import time import threading import unittest.mock import pytest from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes -from zeroconf import Zeroconf +from zeroconf import DNSIncoming, ServiceStateChange, Zeroconf, const from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf._services import ServiceListener +import zeroconf._services.browser as _services_browser from zeroconf._services.info import ServiceInfo from zeroconf._utils.time import current_time_millis @@ -657,3 +659,96 @@ def update_service(self, zc, type_, name) -> None: await browser.async_cancel() await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_integration(): + service_added = asyncio.Event() + service_removed = asyncio.Event() + unexpected_ttl = asyncio.Event() + got_query = asyncio.Event() + + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + elif state_change is ServiceStateChange.Removed: + service_removed.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + await zeroconf_browser.async_wait_for_start() + + # we are going to patch the zeroconf send to check packet sizes + old_send = zeroconf_browser.async_send + + time_offset = 0.0 + + def current_time_millis(): + """Current system time in milliseconds""" + return (time.time() * 1000) + (time_offset * 1000) + + expected_ttl = const._DNS_HOST_TTL + nbr_answers = 0 + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = DNSIncoming(out.packets()[0]) + nonlocal nbr_answers + for answer in pout.answers: + nbr_answers += 1 + if not answer.ttl > expected_ttl / 2: + unexpected_ttl.set() + + got_query.set() + got_query.clear() + + old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) + + # patch the zeroconf send + # patch the zeroconf current_time_millis + # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL + with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch( + "zeroconf._services.browser.current_time_millis", current_time_millis + ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + service_added = asyncio.Event() + service_removed = asyncio.Event() + + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + + try: + await asyncio.wait_for(service_added.wait(), 1) + assert service_added.is_set() + + # Test that we receive queries containing answers only if the remaining TTL + # is greater than half the original TTL + sleep_count = 0 + test_iterations = 50 + + while nbr_answers < test_iterations: + # Increase simulated time shift by 1/4 of the TTL in seconds + time_offset += expected_ttl / 4 + browser.query_scheduler.set_schedule_changed() + sleep_count += 1 + await asyncio.wait_for(got_query.wait(), 0.5) + # Prevent the test running indefinitely in an error condition + assert sleep_count < test_iterations * 4 + assert not unexpected_ttl.is_set() + # Don't remove service, allow close() to cleanup + finally: + await aio_zeroconf_registrar.async_close() + await asyncio.wait_for(service_removed.wait(), 1) + assert service_removed.is_set() + await browser.async_cancel() + await aiozc.async_close() From 10f4a7f8d607d09673be56e5709912403503d86b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 20:30:22 -1000 Subject: [PATCH 0505/1433] Disable duplicate question suppression for test_integration (#830) - This test waits until we get 50 known answers. It would sometimes fail because it could not ask enough unsuppressed questions in the allowed time. --- tests/test_aio.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 0d587709f..0df5d2976 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -662,7 +662,10 @@ def update_service(self, zc, type_, name) -> None: @pytest.mark.asyncio -async def test_integration(): +# Disable duplicate question suppression for this test as it works +# by asking the same question over and over +@unittest.mock.patch("zeroconf._core.QuestionHistory.suppresses", return_value=False) +async def test_integration(suppresses_mock): service_added = asyncio.Event() service_removed = asyncio.Event() unexpected_ttl = asyncio.Event() From 8230e3f40da5d2d152942725d67d5f8c0b8c647b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 21:33:05 -1000 Subject: [PATCH 0506/1433] Show 20 slowest tests on each run (#832) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6602d808e..10e2960e6 100644 --- a/Makefile +++ b/Makefile @@ -41,10 +41,10 @@ mypy: mypy --no-warn-redundant-casts --no-warn-unused-ignores examples/*.py zeroconf test: - pytest --timeout=60 -v tests + pytest --durations=20 --timeout=60 -v tests test_coverage: - pytest --timeout=60 -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests + pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf From 4039b0b755a3d0fe15e4cb1a7cb1592c35e048e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 21:33:32 -1000 Subject: [PATCH 0507/1433] Annotate test failures on github (#831) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c6f98f1f..132482b5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: run: | pip install --upgrade -r requirements-dev.txt pip install . + - name: Install pytest-github-actions-annotate-failures plugin + run: pip install pytest-github-actions-annotate-failures - name: Run tests run: make ci - name: Report coverage to Codecov From 0bf4f7537a042a00d9d3f815afcdf7ebe29d9f53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 23:07:12 -1000 Subject: [PATCH 0508/1433] Cache dependency installs in CI (#833) --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++-------------- Makefile | 3 ++- requirements-dev.txt | 2 +- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 132482b5c..c19b66829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,19 +15,34 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + include: + - os: ubuntu-latest + venvcmd: . env/bin/activate + - os: macos-latest + venvcmd: . env/bin/activate + - os: windows-latest + venvcmd: env\Scripts\Activate.ps1 steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install --upgrade -r requirements-dev.txt - pip install . - - name: Install pytest-github-actions-annotate-failures plugin - run: pip install pytest-github-actions-annotate-failures - - name: Run tests - run: make ci - - name: Report coverage to Codecov - uses: codecov/codecov-action@v1 + - uses: actions/checkout@v2 + - uses: actions/cache@v2 + id: cache + with: + path: env + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements-dev.txt') }}-${{ hashFiles('**/Makefile') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m venv env + ${{ matrix.venvcmd }} + pip install --upgrade -r requirements-dev.txt pytest-github-actions-annotate-failures + - name: Run tests + run: | + ${{ matrix.venvcmd }} + make ci + - name: Report coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/Makefile b/Makefile index 10e2960e6..d0335d0ff 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ endif virtualenv: ./env/requirements.built env: - virtualenv env + python -m venv env ./env/requirements.built: env requirements-dev.txt ./env/bin/pip install -r requirements-dev.txt @@ -48,3 +48,4 @@ test_coverage: autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf + diff --git a/requirements-dev.txt b/requirements-dev.txt index eef932540..dc2f21de2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ autopep8 black;implementation_name=="cpython" coveralls coverage -# Version restricted because of https://github.com/PyCQA/pycodestyle/issues/741 +# Version restricted because of https://github.com/PyCQA/pycodestyle/issues/741 - is fixed flake8>=3.6.0 flake8-import-order ifaddr From 540c65218eb9d1aedc88a3d3724af97f39ccb88e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Jun 2021 23:20:25 -1000 Subject: [PATCH 0509/1433] Wait for startup in test_integration (#834) --- tests/test_aio.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_aio.py b/tests/test_aio.py index 0df5d2976..e1e6de2c4 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -723,6 +723,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] From 0b1abbc8f2b09235cfd44e5586024c7b82dc5289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jun 2021 09:58:07 -1000 Subject: [PATCH 0510/1433] Ensure coverage.xml is written for codecov (#837) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d0335d0ff..378970c4a 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ test: pytest --durations=20 --timeout=60 -v tests test_coverage: - pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report html --cov-report term-missing tests + pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests autopep8: autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf From 7297f3ef71c9984296c3e28539ce7a4b42f04a05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jun 2021 10:09:49 -1000 Subject: [PATCH 0511/1433] Make multipacket known answer suppression per interface (#836) - The suppression was happening per instance of Zeroconf instead of per interface. Since the same network can be seen on multiple interfaces (usually and wifi and ethernet), this would confuse the multi-packet known answer supression since it was not expecting to get the same data more than once Fixes #835 --- tests/test_core.py | 91 ++++++++++++++++++++++----------------------- zeroconf/_core.py | 93 +++++++++++++++++++++++++++++----------------- 2 files changed, 104 insertions(+), 80 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 9f2412f06..e9a42012d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -17,10 +17,10 @@ from typing import cast import zeroconf as r -from zeroconf import _core, _protocol, const, ServiceBrowser, Zeroconf, current_time_millis +from zeroconf import _core, _protocol, const, Zeroconf, current_time_millis from zeroconf.aio import AsyncZeroconf -from . import has_working_ipv6, _clear_cache, _inject_response +from . import has_working_ipv6, _clear_cache, _inject_response, _wait_for_start log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -37,6 +37,13 @@ def teardown_module(): log.setLevel(original_logging_level) +def threadsafe_query(zc, protocol, *args): + async def make_query(): + protocol.handle_query_or_defer(*args) + + asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() + + # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio @@ -408,6 +415,7 @@ def test_sending_unicast(): def test_tc_bit_defers(): zc = Zeroconf(interfaces=['127.0.0.1']) + _wait_for_start(zc) type_ = "_tcbitdefer._tcp.local." name = "knownname" name2 = "knownname2" @@ -435,12 +443,7 @@ def test_tc_bit_defers(): zc.registry.add(info2) zc.registry.add(info3) - def threadsafe_query(*args): - async def make_query(): - zc.handle_query(*args) - - asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() - + protocol = zc.engine.protocols[0] now = r.current_time_millis() _clear_cache(zc) @@ -459,30 +462,30 @@ async def make_query(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert source_ip not in zc._deferred - assert source_ip not in zc._timers + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert source_ip not in protocol._deferred + assert source_ip not in protocol._timers # unregister zc.unregister_service(info) @@ -491,6 +494,7 @@ async def make_query(): def test_tc_bit_defers_last_response_missing(): zc = Zeroconf(interfaces=['127.0.0.1']) + _wait_for_start(zc) type_ = "_knowndefer._tcp.local." name = "knownname" name2 = "knownname2" @@ -518,12 +522,7 @@ def test_tc_bit_defers_last_response_missing(): zc.registry.add(info2) zc.registry.add(info3) - def threadsafe_query(*args): - async def make_query(): - zc.handle_query(*args) - - asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() - + protocol = zc.engine.protocols[0] now = r.current_time_millis() _clear_cache(zc) source_ip = '203.0.113.12' @@ -542,45 +541,45 @@ async def make_query(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - timer1 = zc._timers[source_ip] + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + timer1 = protocol._timers[source_ip] next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - timer2 = zc._timers[source_ip] + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + timer2 = protocol._timers[source_ip] if sys.version_info >= (3, 7): assert timer1.cancelled() assert timer2 != timer1 # Send the same packet again to similar multi interfaces - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers - timer3 = zc._timers[source_ip] + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer3 = protocol._timers[source_ip] if sys.version_info >= (3, 7): assert not timer3.cancelled() assert timer3 == timer2 next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(next_packet, source_ip, const._MDNS_PORT) - assert zc._deferred[source_ip] == expected_deferred - assert source_ip in zc._timers - timer4 = zc._timers[source_ip] + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer4 = protocol._timers[source_ip] if sys.version_info >= (3, 7): assert timer3.cancelled() assert timer4 != timer3 for _ in range(8): time.sleep(0.1) - if source_ip not in zc._timers and source_ip not in zc._deferred: + if source_ip not in protocol._timers and source_ip not in protocol._deferred: break - assert source_ip not in zc._deferred - assert source_ip not in zc._timers + assert source_ip not in protocol._deferred + assert source_ip not in protocol._timers # unregister zc.registry.remove(info) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 8237eb16e..d5461e6d4 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -85,6 +85,7 @@ def __init__( ) -> None: self.loop: Optional[asyncio.AbstractEventLoop] = None self.zc = zeroconf + self.protocols: List[AsyncListener] = [] self.readers: List[asyncio.DatagramTransport] = [] self.senders: List[asyncio.DatagramTransport] = [] self._listen_socket = listen_socket @@ -127,7 +128,8 @@ async def _async_create_endpoints(self) -> None: sender_sockets.append(s) for s in reader_sockets: - transport, _ = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) + transport, protocol = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) + self.protocols.append(cast(AsyncListener, protocol)) self.readers.append(cast(asyncio.DatagramTransport, transport)) if s in sender_sockets: self.senders.append(cast(asyncio.DatagramTransport, transport)) @@ -185,6 +187,10 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data: Optional[bytes] = None self.transport: Optional[asyncio.DatagramTransport] = None + + self._deferred: Dict[str, List[DNSIncoming]] = {} + self._timers: Dict[str, asyncio.TimerHandle] = {} + super().__init__() def datagram_received( @@ -254,7 +260,49 @@ def datagram_received( self.zc.handle_response(msg) return - self.zc.handle_query(msg, addr, port, v6_flow_scope) + self.handle_query_or_defer(msg, addr, port, v6_flow_scope) + + def handle_query_or_defer( + self, msg: DNSIncoming, addr: str, port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + ) -> None: + """Deal with incoming query packets. Provides a response if + possible.""" + if not msg.truncated: + self._respond_query(msg, addr, port, v6_flow_scope) + return + + deferred = self._deferred.setdefault(addr, []) + # If we get the same packet we ignore it + for incoming in reversed(deferred): + if incoming.data == msg.data: + return + deferred.append(msg) + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) + assert self.zc.loop is not None + self._cancel_any_timers_for_addr(addr) + self._timers[addr] = self.zc.loop.call_later( + delay, self._respond_query, None, addr, port, v6_flow_scope + ) + + def _cancel_any_timers_for_addr(self, addr: str) -> None: + """Cancel any future truncated packet timers for the address.""" + if addr in self._timers: + self._timers.pop(addr).cancel() + + def _respond_query( + self, + msg: Optional[DNSIncoming], + addr: str, + port: int, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Respond to a query and reassemble any truncated deferred packets.""" + self._cancel_any_timers_for_addr(addr) + packets = self._deferred.pop(addr, []) + if msg: + packets.append(msg) + + self.zc.handle_assembled_query(packets, addr, port, v6_flow_scope) def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" @@ -317,9 +365,6 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None - self._deferred: Dict[str, List[DNSIncoming]] = {} - self._timers: Dict[str, asyncio.TimerHandle] = {} - self.start() def start(self) -> None: @@ -605,41 +650,21 @@ def handle_response(self, msg: DNSIncoming) -> None: are held in the cache, and listeners are notified.""" self.record_manager.async_updates_from_response(msg) - def handle_query( - self, msg: DNSIncoming, addr: str, port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () - ) -> None: - """Deal with incoming query packets. Provides a response if - possible.""" - if not msg.truncated: - self._respond_query(msg, addr, port) - return - - deferred = self._deferred.setdefault(addr, []) - # If we get the same packet on another iterface we ignore it - for incoming in reversed(deferred): - if incoming.data == msg.data: - return - deferred.append(msg) - delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) - assert self.loop is not None - if addr in self._timers: - self._timers.pop(addr).cancel() - self._timers[addr] = self.loop.call_later(delay, self._respond_query, None, addr, port, v6_flow_scope) - - def _respond_query( + def handle_assembled_query( self, - msg: Optional[DNSIncoming], + packets: List[DNSIncoming], addr: str, port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: - """Respond to a query and reassemble any truncated deferred packets.""" - if addr in self._timers: - self._timers.pop(addr).cancel() - packets = self._deferred.pop(addr, []) - if msg: - packets.append(msg) + """Respond to a (re)assembled query. + If the protocol recieved packets with the TC bit set, it will + wait a bit for the rest of the packets and only call + handle_assembled_query once it has a complete set of packets + or the timer expires. If the TC bit is not set, a single + packet will be in packets. + """ unicast_out, multicast_out = self.query_handler.async_response(packets, addr, port) if unicast_out: self.async_send(unicast_out, addr, port, v6_flow_scope) From 3fdd8349553c160586fb6831c9466410f19a3308 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jun 2021 10:11:40 -1000 Subject: [PATCH 0512/1433] Adjust restore key for CI cache (#838) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19b66829..548153817 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: path: env key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements-dev.txt') }}-${{ hashFiles('**/Makefile') }} restore-keys: | - ${{ runner.os }}-pip- + ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: From 937be522a42830b27326b5253d49003b57998bc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jun 2021 10:40:54 -1000 Subject: [PATCH 0513/1433] Skip dependencies install in CI on cache hit (#839) There is no need to reinstall dependencies in the CI when we have a cache hit. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 548153817..1e7484b3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies + if: steps.cache.outputs.cache-hit != 'true' run: | python -m venv env ${{ matrix.venvcmd }} From 7fb11bfc03c06cbe9ed5a4303b3e632d69665bb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Jun 2021 21:50:30 -1000 Subject: [PATCH 0514/1433] Limit duplicate packet suppression to 1s intervals (#841) - Only suppress duplicate packets that happen within the same second. Legitimate queriers will retry the question if they are suppressed. The limit was reduced to one second to be in line with rfc6762: To protect the network against excessive packet flooding due to software bugs or malicious attack, a Multicast DNS responder MUST NOT (except in the one special case of answering probe queries) multicast a record on a given interface until at least one second has elapsed since the last time that record was multicast on that particular --- tests/test_core.py | 18 ++++++++++++++++++ zeroconf/_core.py | 17 +++++++++++++---- zeroconf/_protocol.py | 4 ++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index e9a42012d..8e4a17cf3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -696,3 +696,21 @@ def test_guard_against_oversized_packets(): ) zc.close() + + +def test_guard_against_duplicate_packets(): + """Ensure we do not process duplicate packets. + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + listener = _core.AsyncListener(zc) + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is False + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis() + 1000) is False + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True + assert listener.suppress_duplicate_packet(b"other packet", current_time_millis()) is False + assert listener.suppress_duplicate_packet(b"other packet", current_time_millis()) is True + assert listener.suppress_duplicate_packet(b"other packet", current_time_millis() + 1000) is False + assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is False + zc.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index d5461e6d4..a70d43e77 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -186,6 +186,7 @@ class AsyncListener(asyncio.Protocol, QuietLogger): def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data: Optional[bytes] = None + self.last_time: float = 0 self.transport: Optional[asyncio.DatagramTransport] = None self._deferred: Dict[str, List[DNSIncoming]] = {} @@ -193,6 +194,14 @@ def __init__(self, zc: 'Zeroconf') -> None: super().__init__() + def suppress_duplicate_packet(self, data: bytes, now: float) -> bool: + """Suppress duplicate packet if the last one was the same in the last second.""" + if self.data == data and (now - 1000) < self.last_time: + return True + self.data = data + self.last_time = now + return False + def datagram_received( self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: @@ -210,7 +219,9 @@ def datagram_received( else: return - if self.data == data: + now = current_time_millis() + if self.suppress_duplicate_packet(data, now): + # Guard against duplicate packets log.debug( 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', addr, @@ -221,8 +232,6 @@ def datagram_received( ) return - self.data = data - if len(data) > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm # the system. @@ -234,7 +243,7 @@ def datagram_received( ) return - msg = DNSIncoming(data, scope) + msg = DNSIncoming(data, scope, now) if msg.valid: log.debug( 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 50bbca28d..79f483def 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -79,7 +79,7 @@ class DNSIncoming(DNSMessage, QuietLogger): """Object representation of an incoming DNS packet""" - def __init__(self, data: bytes, scope_id: Optional[int] = None) -> None: + def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[float] = None) -> None: """Constructor from string holding bytes of packet""" super().__init__(0) self.offset = 0 @@ -92,7 +92,7 @@ def __init__(self, data: bytes, scope_id: Optional[int] = None) -> None: self.num_authorities = 0 self.num_additionals = 0 self.valid = False - self.now = current_time_millis() + self.now = now or current_time_millis() self.scope_id = scope_id try: From ecd9c941810e4b413b20dc55929b3ae1a7e57b27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 07:27:22 -1000 Subject: [PATCH 0515/1433] Fix ineffective patching on PyPy (#842) - Use patch in all places so its easier to find where we need to clean up --- tests/services/test_browser.py | 22 +++++++++---------- tests/services/test_info.py | 9 ++++---- tests/test_aio.py | 40 ++++++++++++++++++++++++---------- tests/test_core.py | 5 +++-- tests/test_init.py | 3 ++- tests/utils/test_aio.py | 4 ++-- 6 files changed, 52 insertions(+), 31 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index eb25c7e27..7a5f5df46 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -11,6 +11,7 @@ import os import unittest from threading import Event +from unittest.mock import patch import pytest @@ -382,7 +383,7 @@ def _mock_get_expiration_time(self, percent): return self.created + (percent * self.ttl * 10) # Set an expire time that will force a refresh - with unittest.mock.patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + with patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): _inject_response( zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), @@ -430,8 +431,7 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() -@unittest.mock.patch("zeroconf._core.QuestionHistory.suppresses", return_value=False) -def test_backoff(suppresses_mock): +def test_backoff(): got_query = Event() type_ = "_http._tcp.local." @@ -457,11 +457,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send # patch the zeroconf current_time_millis # patch the backoff limit to prevent test running forever - with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( - _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object( + with patch.object(zeroconf_browser, "async_send", send), patch.object( + zeroconf_browser.question_history, "suppresses", return_value=False + ), patch.object(_services_browser, "current_time_millis", current_time_millis), patch.object( _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 - ), unittest.mock.patch.object( + ), patch.object( _services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0) ): # dummy service callback @@ -525,7 +525,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + with patch.object(zeroconf_browser, "async_send", send): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -564,7 +564,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + with patch.object(zeroconf_browser, "async_send", send): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -597,7 +597,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + with patch.object(zeroconf_browser, "async_send", send): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -631,7 +631,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + with patch.object(zeroconf_browser, "async_send", send): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 348d623dc..adca1a535 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -9,6 +9,7 @@ import threading import os import unittest +from unittest.mock import patch from threading import Event from typing import List @@ -218,7 +219,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): send_event.set() # patch the zeroconf send - with unittest.mock.patch.object(zc, "async_send", send): + with patch.object(zc, "async_send", send): def mock_incoming_msg(records) -> r.DNSIncoming: @@ -363,7 +364,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): send_event.set() # patch the zeroconf send - with unittest.mock.patch.object(zc, "async_send", send): + with patch.object(zc, "async_send", send): def mock_incoming_msg(records) -> r.DNSIncoming: @@ -675,7 +676,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf, "async_send", send): + with patch.object(zeroconf, "async_send", send): zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) assert first_outgoing.questions[0].unicast == True zeroconf.close() @@ -699,7 +700,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): old_send(out, addr=addr, port=port) # patch the zeroconf send - with unittest.mock.patch.object(zeroconf, "async_send", send): + with patch.object(zeroconf, "async_send", send): zeroconf.get_service_info(f"name.{type_}", type_, 500) assert first_outgoing.questions[0].unicast == False zeroconf.close() diff --git a/tests/test_aio.py b/tests/test_aio.py index e1e6de2c4..5fb41a7a1 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -9,7 +9,8 @@ import socket import time import threading -import unittest.mock +from unittest.mock import patch + import pytest @@ -411,7 +412,7 @@ async def test_service_info_async_request() -> None: _clear_cache(aiozc.zeroconf) # Generating the race condition is almost impossible # without patching since its a TOCTOU race - with unittest.mock.patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): + with patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): await aiosinfo.async_request(aiozc.zeroconf, 3000) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] @@ -662,10 +663,7 @@ def update_service(self, zc, type_, name) -> None: @pytest.mark.asyncio -# Disable duplicate question suppression for this test as it works -# by asking the same question over and over -@unittest.mock.patch("zeroconf._core.QuestionHistory.suppresses", return_value=False) -async def test_integration(suppresses_mock): +async def test_integration(): service_added = asyncio.Event() service_removed = asyncio.Event() unexpected_ttl = asyncio.Event() @@ -711,20 +709,40 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + + assert len(zeroconf_registrar.engine.protocols) == 2 # patch the zeroconf send # patch the zeroconf current_time_millis # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch( + # Disable duplicate question suppression and duplicate packet suppression for this test as it works + # by asking the same question over and over + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_browser.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_browser.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_browser.question_history, "suppresses", return_value=False + ), patch.object( + zeroconf_browser, "async_send", send + ), patch( "zeroconf._services.browser.current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + ), patch.object( + _services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4) + ): service_added = asyncio.Event() service_removed = asyncio.Event() browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) - await aio_zeroconf_registrar.zeroconf.async_wait_for_start() - desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] diff --git a/tests/test_core.py b/tests/test_core.py index 8e4a17cf3..85571ddd5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ import unittest import unittest.mock from typing import cast +from unittest.mock import patch import zeroconf as r from zeroconf import _core, _protocol, const, Zeroconf, current_time_millis @@ -48,7 +49,7 @@ async def make_query(): # which is not threadsafe @pytest.mark.asyncio async def test_reaper(): - with unittest.mock.patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): + with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): assert _core._CACHE_CLEANUP_INTERVAL == 10 aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zeroconf = aiozc.zeroconf @@ -652,7 +653,7 @@ def test_guard_against_oversized_packets(): ) # We are patching to generate an oversized packet - with unittest.mock.patch.object(_protocol, "_MAX_MSG_ABSOLUTE", 100000), unittest.mock.patch.object( + with patch.object(_protocol, "_MAX_MSG_ABSOLUTE", 100000), patch.object( _protocol, "_MAX_MSG_TYPICAL", 100000 ): over_sized_packet = generated.packets()[0] diff --git a/tests/test_init.py b/tests/test_init.py index 0cc3baf8a..0383af1a4 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,6 +10,7 @@ import unittest import unittest.mock from typing import Optional # noqa # used in type hints +from unittest.mock import patch import zeroconf as r from zeroconf import DNSOutgoing, ServiceBrowser, ServiceInfo, Zeroconf, const @@ -90,7 +91,7 @@ def test_large_packet_exception_log_handling(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) - with unittest.mock.patch('zeroconf._logger.log.warning') as mocked_log_warn, unittest.mock.patch( + with patch('zeroconf._logger.log.warning') as mocked_log_warn, patch( 'zeroconf._logger.log.debug' ) as mocked_log_debug: # now that we have a long packet in our possession, let's verify the diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index b0fa8dbc6..52a23dea6 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -6,7 +6,7 @@ import asyncio import contextlib -import unittest.mock +from unittest.mock import patch import pytest @@ -23,7 +23,7 @@ async def test_async_get_all_tasks() -> None: await aioutils._async_get_all_tasks(aioutils.get_running_loop()) if not hasattr(asyncio, 'all_tasks'): return - with unittest.mock.patch("zeroconf._utils.aio.asyncio.all_tasks", side_effect=RuntimeError): + with patch("zeroconf._utils.aio.asyncio.all_tasks", side_effect=RuntimeError): await aioutils._async_get_all_tasks(aioutils.get_running_loop()) From 688c5184dce67e5af857c138639ced4bdcec1e57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 07:31:20 -1000 Subject: [PATCH 0516/1433] Use AAAA records instead of A records in test_integration_with_listener_ipv6 (#843) --- tests/services/test_types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 7a07085f2..8b38317b3 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -99,6 +99,7 @@ def test_integration_with_listener_ipv6(self): type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" registration_name = "%s.%s" % (name, type_) + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) desc = {'path': '/~paulsm/'} @@ -110,7 +111,7 @@ def test_integration_with_listener_ipv6(self): 0, desc, "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], + addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) zeroconf_registrar.registry.add(info) try: From dd86f2f9fee4bbaebce956b330c1837a6e9c6c99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 07:35:12 -1000 Subject: [PATCH 0517/1433] Increase timeout in test_integration (#844) - The github macOS runners tend to be a bit loaded and these sometimes fail because of it --- tests/test_aio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 5fb41a7a1..a146cf969 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -764,7 +764,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): time_offset += expected_ttl / 4 browser.query_scheduler.set_schedule_changed() sleep_count += 1 - await asyncio.wait_for(got_query.wait(), 0.5) + await asyncio.wait_for(got_query.wait(), 1) # Prevent the test running indefinitely in an error condition assert sleep_count < test_iterations * 4 assert not unexpected_ttl.is_set() From 72502c303a1a889cf84906b8764fd941a840e6d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 07:52:14 -1000 Subject: [PATCH 0518/1433] Update changelog (#845) --- README.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.rst b/README.rst index 61c05e98f..00b4f35ed 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,30 @@ See examples directory for more. Changelog ========= +0.32.0 Release Candidate 2 +========================== + +* Limit duplicate packet suppression to 1s intervals (#841) @bdraco + + Only suppress duplicate packets that happen within the same + second. Legitimate queriers will retry the question if they + are suppressed. The limit was reduced to one second to be + in line with rfc6762 + +* Make multipacket known answer suppression per interface (#836) @bdraco + + The suppression was happening per instance of Zeroconf instead + of per interface. Since the same network can be seen on multiple + interfaces (usually and wifi and ethernet), this would confuse the + multi-packet known answer supression since it was not expecting + to get the same data more than once + + +0.32.0 Release Candidate 1 +========================== + +No changes + 0.32.0 Beta 6 ============= From 182c68ff11ba381444a708e17560e920ae1849ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 16:44:09 -1000 Subject: [PATCH 0519/1433] Fix thread safety in handlers test (#847) --- tests/test_handlers.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ea29c528e..8fe2c56dd 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1022,8 +1022,10 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor await aiozc.async_close() -def test_questions_query_handler_populates_the_question_history_from_qm_questions(): - zc = Zeroconf(interfaces=['127.0.0.1']) +@pytest.mark.asyncio +async def test_questions_query_handler_populates_the_question_history_from_qm_questions(): + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf now = current_time_millis() _clear_cache(zc) @@ -1044,11 +1046,13 @@ def test_questions_query_handler_populates_the_question_history_from_qm_question assert multicast_out is None assert zc.question_history.suppresses(question, now, set([known_answer])) - zc.close() + await aiozc.async_close() -def test_questions_query_handler_does_not_put_qu_questions_in_history(): - zc = Zeroconf(interfaces=['127.0.0.1']) +@pytest.mark.asyncio +async def test_questions_query_handler_does_not_put_qu_questions_in_history(): + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf now = current_time_millis() _clear_cache(zc) @@ -1069,17 +1073,19 @@ def test_questions_query_handler_does_not_put_qu_questions_in_history(): assert multicast_out is None assert not zc.question_history.suppresses(question, now, set([known_answer])) - zc.close() + await aiozc.async_close() -def test_guard_against_low_ptr_ttl(): +@pytest.mark.asyncio +async def test_guard_against_low_ptr_ttl(): """Ensure we enforce a minimum for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. Some poorly designed IoT devices can set excessively low PTR TTLs would will cause ServiceBrowsers to flood the network with excessive refresh queries. """ - zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf # Apple uses a 15s minimum TTL, however we do not have the same # level of rate limit and safe guards so we use 1/4 of the recommended value answer_with_low_ttl = r.DNSPointer( @@ -1116,4 +1122,4 @@ def test_guard_against_low_ptr_ttl(): incoming_answer_normal = zc.cache.async_get_unique(answer_with_normal_ttl) assert incoming_answer_normal.ttl == const._DNS_OTHER_TTL assert zc.cache.async_get_unique(good_bye_answer) is None - zc.close() + await aiozc.async_close() From 9f71e5b7364d4a23492cafe4f49a5c2acda4178d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jun 2021 17:03:33 -1000 Subject: [PATCH 0520/1433] Fix spurious failures in ZeroconfServiceTypes tests (#848) - These tests ran the same test twice in 0.5s and would trigger the duplicate packet suppression. Rather then making them run longer, we can disable the suppression for the test. --- tests/services/test_types.py | 61 ++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 8b38317b3..d14a8b250 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -9,6 +9,7 @@ import unittest import socket import sys +from unittest.mock import patch import zeroconf as r from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes @@ -51,11 +52,16 @@ def test_integration_with_listener(self): ) zeroconf_registrar.registry.add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ): + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types finally: zeroconf_registrar.close() @@ -83,11 +89,16 @@ def test_integration_with_listener_v6_records(self): ) zeroconf_registrar.registry.add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ): + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types finally: zeroconf_registrar.close() @@ -115,11 +126,16 @@ def test_integration_with_listener_ipv6(self): ) zeroconf_registrar.registry.add(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert type_ in service_types + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ): + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert type_ in service_types finally: zeroconf_registrar.close() @@ -146,11 +162,16 @@ def test_integration_with_subtype_and_listener(self): ) zeroconf_registrar.registry.add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) - assert discovery_type in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) - assert discovery_type in service_types + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ): + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + assert discovery_type in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + assert discovery_type in service_types finally: zeroconf_registrar.close() From a8c16231881de43adedbedbc3f1ea707c0b457f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 07:08:54 -1000 Subject: [PATCH 0521/1433] Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849) - Simplifies scheduling as there is no more need to sleep in a loop as we now schedule future callbacks with call_later - Simplifies cancelation as there is no more coroutine to cancel, only a timer handle We no longer have to handle the canceled error and cleaning up the awaitable - Solves the infrequent test failures in test_backoff and test_integration --- tests/services/test_browser.py | 44 ++--------------- tests/test_aio.py | 9 ++-- zeroconf/_services/browser.py | 86 +++++++++++++++------------------- zeroconf/aio.py | 2 - 4 files changed, 46 insertions(+), 95 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 7a5f5df46..36f459c7c 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -496,7 +496,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): else: assert not got_query.is_set() time_offset += initial_query_interval - zeroconf_browser.loop.call_soon_threadsafe(browser.query_scheduler.set_schedule_changed) + zeroconf_browser.loop.call_soon_threadsafe(browser.schedule_changed) finally: browser.cancel() @@ -984,8 +984,8 @@ async def test_query_scheduler(): # Test query interval is increasing assert query_scheduler.millis_to_wait(now - 1) == 1 - assert query_scheduler.millis_to_wait(now) is None - assert query_scheduler.millis_to_wait(now + 1) is None + assert query_scheduler.millis_to_wait(now) is 0 + assert query_scheduler.millis_to_wait(now + 1) is 0 assert set(query_scheduler.process_ready_types(now)) == types_ assert set(query_scheduler.process_ready_types(now)) == set() @@ -1018,41 +1018,3 @@ async def test_query_scheduler(): assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() assert set(query_scheduler.process_ready_types(now + delay * 31)) == set(["_http._tcp.local."]) - - -@pytest.mark.asyncio -async def test_query_scheduler_triggers_async_wait_ready_on_reschedule(): - """Test that a reschedule wakes up the async_wait_ready.""" - delay = const._BROWSER_TIME - types_ = set(["_hap._tcp.local.", "_http._tcp.local."]) - query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0)) - - now = current_time_millis() - query_scheduler.start(now) - assert set(query_scheduler.process_ready_types(now)) == types_ - assert query_scheduler.millis_to_wait(now) == delay - - task = asyncio.ensure_future(query_scheduler.async_wait_ready(now)) - await asyncio.sleep(0) # Start the task - await asyncio.sleep(0) # Make sure its waiting - assert not task.done() - assert query_scheduler.millis_to_wait(now + 1) == delay - 1 - query_scheduler.reschedule_type("_hap._tcp.local.", now + 1) - assert query_scheduler.millis_to_wait(now + 1) is None - await asyncio.wait_for(task, timeout=0.1) - assert task.done() - - task2 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000)) - assert set(query_scheduler.process_ready_types(now + 1)) == set(["_hap._tcp.local."]) - assert not task2.done() - assert query_scheduler.millis_to_wait(now + 2) == delay - 2 - query_scheduler.reschedule_type("_hap._tcp.local.", now + 2) - assert query_scheduler.millis_to_wait(now + 2) is None - await asyncio.wait_for(task2, timeout=0.1) - assert task2.done() - assert set(query_scheduler.process_ready_types(now + 10000)) == types_ - assert query_scheduler.millis_to_wait(now + 10000) == delay * 2 - - task3 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000)) - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(task3, timeout=0.1) diff --git a/tests/test_aio.py b/tests/test_aio.py index a146cf969..41e0e83af 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -688,7 +688,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): time_offset = 0.0 - def current_time_millis(): + def _new_current_time_millis(): """Current system time in milliseconds""" return (time.time() * 1000) + (time_offset * 1000) @@ -705,7 +705,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): unexpected_ttl.set() got_query.set() - got_query.clear() old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) @@ -734,7 +733,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ), patch.object( zeroconf_browser, "async_send", send ), patch( - "zeroconf._services.browser.current_time_millis", current_time_millis + "zeroconf._services.browser.current_time_millis", _new_current_time_millis ), patch.object( _services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4) ): @@ -762,9 +761,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): while nbr_answers < test_iterations: # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 - browser.query_scheduler.set_schedule_changed() + now = _new_current_time_millis() + browser.reschedule_type(type_, now) sleep_count += 1 await asyncio.wait_for(got_query.wait(), 1) + got_query.clear() # Prevent the test running indefinitely in an error condition assert sleep_count < test_iterations * 4 assert not unexpected_ttl.is_set() diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index a7abca4f0..bc368edb0 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -21,7 +21,6 @@ """ import asyncio -import contextlib import queue import random import threading @@ -39,7 +38,7 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.aio import get_best_available_queue, wait_event_or_timeout +from .._utils.aio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( @@ -221,25 +220,17 @@ def _generate_first_next_time(self, now: float) -> None: next_time = now + delay self._next_time = {check_type_: next_time for check_type_ in self._types} - def millis_to_wait(self, now: float) -> Optional[float]: + def millis_to_wait(self, now: float) -> float: """Returns the number of milliseconds to wait for the next event.""" # Wait for the type has the smallest next time next_time = min(self._next_time.values()) - return None if next_time <= now else next_time - now + return 0 if next_time <= now else next_time - now def reschedule_type(self, type_: str, next_time: float) -> None: """Reschedule the query for a type to happen sooner.""" if next_time >= self._next_time[type_]: return - self._next_time[type_] = next_time - self.set_schedule_changed() - - def set_schedule_changed(self) -> None: - """Set the event to unblock async_wait_ready to make sure the adjusted next time is seen.""" - assert self._schedule_changed_event is not None - self._schedule_changed_event.set() - self._schedule_changed_event.clear() def process_ready_types(self, now: float) -> List[str]: """Generate a list of ready types that is due and schedule the next time.""" @@ -258,13 +249,6 @@ def process_ready_types(self, now: float) -> List[str]: return ready_types - async def async_wait_ready(self, now: float) -> None: - """Wait for at least one query to be ready.""" - timeout = self.millis_to_wait(now) - if timeout: - assert self._schedule_changed_event is not None - await wait_event_or_timeout(self._schedule_changed_event, timeout=millis_to_seconds(timeout)) - class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" @@ -302,7 +286,6 @@ def __init__( for check_type_ in self.types: # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) - self._browser_task: Optional[asyncio.Task] = None self.zc = zc self.addr = addr self.port = port @@ -313,6 +296,8 @@ def __init__( self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) self.queue: Optional[queue.Queue] = None self.done = False + self._first_request: bool = True + self._next_send_timer: Optional[asyncio.TimerHandle] = None if hasattr(handlers, 'add_service'): listener = cast('ServiceListener', handlers) @@ -335,7 +320,7 @@ def _async_start(self) -> None: self.query_scheduler.start(current_time_millis()) self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) # Only start queries after the listener is installed - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + asyncio.ensure_future(self._async_start_query_sender()) @property def service_state_changed(self) -> SignalRegistrationInterface: @@ -378,9 +363,7 @@ def _async_process_record_update( elif expired: self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: - self.query_scheduler.reschedule_type( - record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - ) + self.reschedule_type(record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) return # If its expired or already exists in the cache it cannot be updated. @@ -448,6 +431,7 @@ def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], Servic def _async_cancel(self) -> None: """Cancel the browser.""" self.done = True + self._cancel_send_timer() self.zc.async_remove_listener(self) def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: @@ -464,28 +448,40 @@ def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: question_type = DNSQuestionType.QU if not self.question_type and first_request else self.question_type return generate_service_query(self.zc, now, ready_types, self.multicast, question_type) - async def async_browser_task(self) -> None: - """Run the browser task.""" + async def _async_start_query_sender(self) -> None: + """Start scheduling queries.""" await self.zc.async_wait_for_start() - first_request = True - while True: - await self.query_scheduler.async_wait_ready(current_time_millis()) - outs = self._generate_ready_queries(first_request) - if not outs: - continue + self._async_send_ready_queries_schedule_next() + + def _cancel_send_timer(self) -> None: + """Cancel the next send.""" + if self._next_send_timer: + self._next_send_timer.cancel() - first_request = False + def reschedule_type(self, type_: str, next_time: float) -> None: + """Reschedule a type to be refreshed in the future.""" + self.query_scheduler.reschedule_type(type_, next_time) + self.schedule_changed() + + def schedule_changed(self) -> None: + """Called when the schedule has changed.""" + self._cancel_send_timer() + self._async_send_ready_queries_schedule_next() + + def _async_send_ready_queries_schedule_next(self) -> None: + """Send any ready queries and scheule the next time.""" + if self.done or self.zc.done: + return + + outs = self._generate_ready_queries(self._first_request) + if outs: + self._first_request = False for out in outs: self.zc.async_send(out, addr=self.addr, port=self.port) - async def _async_cancel_browser(self) -> None: - """Cancel the browser.""" - assert self._browser_task is not None - self._browser_task.cancel() - browser_task = self._browser_task - self._browser_task = None - with contextlib.suppress(asyncio.CancelledError): - await browser_task + assert self.zc.loop is not None + delay = millis_to_seconds(self.query_scheduler.millis_to_wait(current_time_millis())) + self._next_send_timer = self.zc.loop.call_later(delay, self._async_send_ready_queries_schedule_next) class ServiceBrowser(_ServiceBrowserBase, threading.Thread): @@ -523,18 +519,12 @@ def __init__( getattr(self, 'native_id', self.ident), ) - def _async_cancel_soon(self) -> None: - """Cancel the browser from the event loop.""" - self._async_cancel() - if self._browser_task: - asyncio.ensure_future(self._async_cancel_browser()) - def cancel(self) -> None: """Cancel the browser.""" assert self.zc.loop is not None assert self.queue is not None self.queue.put(None) - self.zc.loop.call_soon_threadsafe(self._async_cancel_soon) + self.zc.loop.call_soon_threadsafe(self._async_cancel) self.join() def run(self) -> None: diff --git a/zeroconf/aio.py b/zeroconf/aio.py index 985a440b9..67ff1c120 100644 --- a/zeroconf/aio.py +++ b/zeroconf/aio.py @@ -91,8 +91,6 @@ def __init__( async def async_cancel(self) -> None: """Cancel the browser.""" self._async_cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._async_cancel_browser() class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): From 8c9d1d8964d9226d5d3ac38bec908e930954b369 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 07:11:18 -1000 Subject: [PATCH 0522/1433] Update changelog (#850) --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index 00b4f35ed..d69369b98 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,20 @@ See examples directory for more. Changelog ========= + +0.32.0 Release Candidate 3 +========================== + +* Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849) @bdraco + + Simplifies scheduling as there is no more need to sleep in a loop as + we now schedule future callbacks with call_later + + Simplifies cancelation as there is no more coroutine to cancel, only a timer handle + We no longer have to handle the canceled error and cleaning up the awaitable + + Solves the infrequent test failures in test_backoff and test_integration + 0.32.0 Release Candidate 2 ========================== From 76e0b05ca9c601bd638817bf68ca8d981f1d65f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 10:04:17 -1000 Subject: [PATCH 0523/1433] Make ServiceInfo first question QU (#852) - We want an immediate response when making a request with ServiceInfo by asking a QU question, most responders will not delay the response and respond right away to our question. This also improves compatibility with split networks as we may not have been able to see the response otherwise. If the responder has not multicast the record recently it may still choose to do so in addition to responding via unicast - Reduces traffic when there are multiple zeroconf instances running on the network running ServiceBrowsers - If we don't get an answer on the first try, we ask a QM question in the event we can't receive a unicast response for some reason - This change puts ServiceInfo inline with ServiceBrowser which also asks the first question as QU since ServiceInfo is commonly called from ServiceBrowser callbacks closes #851 --- tests/services/test_info.py | 6 ++--- tests/test_aio.py | 47 +++++++++++++++++++++++++++++++++++++ zeroconf/_services/info.py | 6 ++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index adca1a535..37f98aa18 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -682,8 +682,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): zeroconf.close() -def test_asking_qm_questions_are_default(): - """Verify default is QM questions.""" +def test_asking_qm_questions(): + """Verify explictly asking QM questions.""" type_ = "_quservice._tcp.local." zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) @@ -701,6 +701,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info(f"name.{type_}", type_, 500) + zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) assert first_outgoing.questions[0].unicast == False zeroconf.close() diff --git a/tests/test_aio.py b/tests/test_aio.py index 41e0e83af..f22bf9660 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -776,3 +776,50 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert service_removed.is_set() await browser.async_cancel() await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): + """Verify the service info first question is QU and subsequent ones are QM questions.""" + type_ = "_quservice._tcp.local." + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_info = aiozc.zeroconf + + name = "xxxyyy" + registration_name = "%s.%s" % (name, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + zeroconf_info.registry.add(info) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_info.async_send + + first_outgoing = None + second_outgoing = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_outgoing + nonlocal second_outgoing + if out.questions: + if first_outgoing is not None and second_outgoing is None: + second_outgoing = out + if first_outgoing is None: + first_outgoing = out + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with patch.object(zeroconf_info, "async_send", send): + aiosinfo = AsyncServiceInfo(type_, registration_name) + # Patch _is_complete so we send multiple times + with patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): + await aiosinfo.async_request(aiozc.zeroconf, 1200) + try: + assert first_outgoing.questions[0].unicast == True + assert second_outgoing.questions[0].unicast == False + finally: + await aiozc.async_close() diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 4365d6ef3..9d1c37f3e 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -438,6 +438,7 @@ async def async_request( if self.load_from_cache(zc): return True + first_request = True now = current_time_millis() delay = _LISTENER_TIME next_ = now @@ -449,7 +450,10 @@ async def async_request( if last <= now: return False if next_ <= now: - out = self.generate_request_query(zc, now, question_type) + out = self.generate_request_query( + zc, now, question_type or DNSQuestionType.QU if first_request else DNSQuestionType.QM + ) + first_request = False if not out.questions: return self.load_from_cache(zc) zc.async_send(out) From 0cd876f5a42699aeb0176380ba4cca4d8a536df3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 10:04:27 -1000 Subject: [PATCH 0524/1433] Speed up test_verify_name_change_with_lots_of_names under PyPy (#853) fixes #840 --- tests/__init__.py | 12 +++++++++--- tests/test_init.py | 14 +++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index d77140fdb..2671fe62d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,7 +23,7 @@ import asyncio import socket from functools import lru_cache - +from typing import List import ifaddr @@ -31,16 +31,22 @@ from zeroconf import DNSIncoming, Zeroconf -def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: +def _inject_responses(zc: Zeroconf, msgs: List[DNSIncoming]) -> None: """Inject a DNSIncoming response.""" assert zc.loop is not None async def _wait_for_response(): - zc.handle_response(msg) + for msg in msgs: + zc.handle_response(msg) asyncio.run_coroutine_threadsafe(_wait_for_response(), zc.loop).result() +def _inject_response(zc: Zeroconf, msg: DNSIncoming) -> None: + """Inject a DNSIncoming response.""" + _inject_responses(zc, [msg]) + + def _wait_for_start(zc: Zeroconf) -> None: """Wait for all sockets to be up and running.""" assert zc.loop is not None diff --git a/tests/test_init.py b/tests/test_init.py index 0383af1a4..5005a75d9 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,13 +9,12 @@ import time import unittest import unittest.mock -from typing import Optional # noqa # used in type hints from unittest.mock import patch import zeroconf as r -from zeroconf import DNSOutgoing, ServiceBrowser, ServiceInfo, Zeroconf, const +from zeroconf import ServiceInfo, Zeroconf, const -from . import _inject_response +from . import _inject_responses log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -162,14 +161,16 @@ def verify_name_change(self, zc, type_, name, number_hosts): def generate_many_hosts(self, zc, type_, name, number_hosts): block_size = 25 number_hosts = int(((number_hosts - 1) / block_size + 1)) * block_size + out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) for i in range(1, number_hosts + 1): next_name = name if i == 1 else '%s-%d' % (name, i) - self.generate_host(zc, next_name, type_) + self.generate_host(out, next_name, type_) + + _inject_responses(zc, [r.DNSIncoming(packet) for packet in out.packets()]) @staticmethod - def generate_host(zc, host_name, type_): + def generate_host(out, host_name, type_): name = '.'.join((host_name, type_)) - out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) out.add_answer_at_time( r.DNSPointer(type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name), 0 ) @@ -186,4 +187,3 @@ def generate_host(zc, host_name, type_): ), 0, ) - _inject_response(zc, r.DNSIncoming(out.packets()[0])) From 03411f35d82752d5d2633a67db132a011098d9e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 10:59:27 -1000 Subject: [PATCH 0525/1433] Only run linters on Linux in CI (#855) - The github MacOS and Windows runners are slower and will have the same results as the Linux runners so there is no need to wait for them. closes #854 --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e7484b3f..9d4f9a350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,26 +24,46 @@ jobs: venvcmd: env\Scripts\Activate.ps1 steps: - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} - uses: actions/cache@v2 id: cache with: path: env - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements-dev.txt') }}-${{ hashFiles('**/Makefile') }} + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Makefile') }}-${{ hashFiles('**/requirements-dev.txt') }} restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} + ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Makefile') }} - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' run: | python -m venv env ${{ matrix.venvcmd }} pip install --upgrade -r requirements-dev.txt pytest-github-actions-annotate-failures + - name: Run flake8 + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + run: | + ${{ matrix.venvcmd }} + make flake8 + - name: Run mypy + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + run: | + ${{ matrix.venvcmd }} + make mypy + - name: Run black_check + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + run: | + ${{ matrix.venvcmd }} + make black_check + - name: Run pylint + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + run: | + ${{ matrix.venvcmd }} + make pylint - name: Run tests run: | ${{ matrix.venvcmd }} - make ci + make test_coverage - name: Report coverage to Codecov uses: codecov/codecov-action@v1 From cb2e237b6f1af0a83bc7352464562cdb7bbcac14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 11:11:39 -1000 Subject: [PATCH 0526/1433] Update changelog (#856) --- README.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.rst b/README.rst index d69369b98..186e44306 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,27 @@ See examples directory for more. Changelog ========= +0.32.0 Release Candidate 4 +========================== + +Make ServiceInfo first question QU (#852) @bdraco + + We want an immediate response when making a request with ServiceInfo + by asking a QU question, most responders will not delay the response + and respond right away to our question. This also improves compatibility + with split networks as we may not have been able to see the response + otherwise. If the responder has not multicast the record recently + it may still choose to do so in addition to responding via unicast + + Reduces traffic when there are multiple zeroconf instances running + on the network running ServiceBrowsers + + If we don't get an answer on the first try, we ask a QM question + in the event we can't receive a unicast response for some reason + + This change puts ServiceInfo inline with ServiceBrowser which + also asks the first question as QU since ServiceInfo is commonly + called from ServiceBrowser callbacks 0.32.0 Release Candidate 3 ========================== From 59247f1c44b485bf51d4a8d3e3966b9faf40cf82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 11:13:10 -1000 Subject: [PATCH 0527/1433] Fix changelog formatting (#857) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 186e44306..47e3c5846 100644 --- a/README.rst +++ b/README.rst @@ -143,7 +143,7 @@ Changelog 0.32.0 Release Candidate 4 ========================== -Make ServiceInfo first question QU (#852) @bdraco +* Make ServiceInfo first question QU (#852) @bdraco We want an immediate response when making a request with ServiceInfo by asking a QU question, most responders will not delay the response From 3eb7be95fd6cd4960f96f29aa72fc45347c57b6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 16:04:45 -1000 Subject: [PATCH 0528/1433] Cleanup coverage data (#858) --- .coveragerc | 1 + zeroconf/__init__.py | 4 ++-- zeroconf/const.py | 5 ++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.coveragerc b/.coveragerc index 56ef8a32a..7648cf0d8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,4 @@ exclude_lines = pragma: no cover if TYPE_CHECKING: + if sys.version_info diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 263043aa8..e3bb987fd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -97,8 +97,8 @@ "ZeroconfServiceTypes", ] -if sys.version_info <= (3, 6): - raise ImportError( +if sys.version_info <= (3, 6): # pragma: no cover + raise ImportError( # pragma: no cover ''' Python version > 3.6 required for python-zeroconf. If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 diff --git a/zeroconf/const.py b/zeroconf/const.py index 8107a2af4..0f26d80ac 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -20,6 +20,7 @@ USA """ +import contextlib import re import socket @@ -39,10 +40,8 @@ _MDNS_ADDR = '224.0.0.251' _MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) _MDNS_ADDR6 = 'ff02::fb' -try: +with contextlib.suppress(OSError): # can't use AF_INET6, IPv6 is disabled _MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) -except OSError: # can't use AF_INET6, IPv6 is disabled - pass _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 From 57cccc4dcbdc9df52672297968ccb55054122049 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 16:20:34 -1000 Subject: [PATCH 0529/1433] Make a dispatch dict for ServiceStateChange listeners (#859) --- zeroconf/_services/browser.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index bc368edb0..5f2dbd31e 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -58,6 +58,12 @@ # https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 _FIRST_QUERY_DELAY_RANDOM_INTERVAL = (20, 120) # ms +_ON_CHANGE_DISPATCH = { + ServiceStateChange.Added: "add_service", + ServiceStateChange.Removed: "remove_service", + ServiceStateChange.Updated: "update_service", +} + if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 from .._core import Zeroconf # pylint: disable=cyclic-import @@ -159,25 +165,18 @@ def generate_service_query( def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: """Generate a service_state_changed handlers from a listener.""" + assert listener is not None + if not hasattr(listener, 'update_service'): + warnings.warn( + "%r has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory." % (listener,), + FutureWarning, + ) def on_change( zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange ) -> None: - assert listener is not None - args = (zeroconf, service_type, name) - if state_change is ServiceStateChange.Added: - listener.add_service(*args) - elif state_change is ServiceStateChange.Removed: - listener.remove_service(*args) - elif state_change is ServiceStateChange.Updated: - if hasattr(listener, 'update_service'): - listener.update_service(*args) - else: - warnings.warn( - "%r has no update_service method. Provide one (it can be empty if you " - "don't care about the updates), it'll become mandatory." % (listener,), - FutureWarning, - ) + getattr(listener, _ON_CHANGE_DISPATCH[state_change])(zeroconf, service_type, name) return on_change From af83c766c2ae72bd23184c6f6300e4d620c7b3e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 16:34:02 -1000 Subject: [PATCH 0530/1433] Add unit coverage for shutdown_loop (#860) --- tests/utils/test_aio.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index 52a23dea6..fd33234f8 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -6,6 +6,8 @@ import asyncio import contextlib +import threading +import time from unittest.mock import patch import pytest @@ -56,3 +58,28 @@ async def _async_wait_or_timeout(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task + + +def test_shutdown_loop() -> None: + """Test shutting down an event loop.""" + loop = None + loop_thread_ready = threading.Event() + + def _run_loop() -> None: + nonlocal loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop_thread_ready.set() + loop.run_forever() + + loop_thread = threading.Thread(target=_run_loop, daemon=True) + loop_thread.start() + loop_thread_ready.wait() + + aioutils.shutdown_loop(loop) + for _ in range(5): + if not loop.is_running(): + break + time.sleep(0.05) + + assert loop.is_running() is False From f5368692d7907e440ca81f0acee9744f79dbae80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 16:50:35 -1000 Subject: [PATCH 0531/1433] Remove unreachable code in AsyncListener.datagram_received (#863) --- zeroconf/_core.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a70d43e77..535105739 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -211,13 +211,11 @@ def datagram_received( # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore scope = None - elif len(addrs) == 4: + else: # https://github.com/python/mypy/issues/1178 addr, port, flow, scope = addrs # type: ignore log.debug('IPv6 scope_id %d associated to the receiving interface', scope) v6_flow_scope = (flow, scope) - else: - return now = current_time_millis() if self.suppress_duplicate_packet(data, now): From c516919064687551299f23e23bf0797888020041 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 16:50:44 -1000 Subject: [PATCH 0532/1433] Ensure protocol and sending errors are logged once (#862) --- zeroconf/_core.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 535105739..b5e971b4c 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -22,7 +22,6 @@ import asyncio import contextlib -import errno import itertools import random import socket @@ -313,6 +312,10 @@ def _respond_query( def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" + assert self.transport is not None + self.log_warning_once( + 'Error with socket %d: %s', self.transport.get_extra_info('socket').fileno(), exc + ) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) @@ -714,24 +717,13 @@ def async_send( if self._GLOBAL_DONE: return s = transport.get_extra_info('socket') - try: - if addr is None: - real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR - elif not can_send_to(s, addr): - continue - else: - real_addr = addr - transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) - except OSError as exc: - if exc.errno == errno.ENETUNREACH and s.family == socket.AF_INET6: - # with IPv6 we don't have a reliable way to determine if an interface actually has - # IPV6 support, so we have to try and ignore errors. - continue - # on send errors, log the exception and keep going - self.log_exception_warning('Error sending through socket %d', s.fileno()) - except Exception: # pylint: disable=broad-except # TODO stop catching all Exceptions - # on send errors, log the exception and keep going - self.log_exception_warning('Error sending through socket %d', s.fileno()) + if addr is None: + real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + elif not can_send_to(s, addr): + continue + else: + real_addr = addr + transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) def _close(self) -> None: """Set global done and remove all service listeners.""" From c64064ad3b38a40775637c0fd8877d9d00d2d537 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 17:16:39 -1000 Subject: [PATCH 0533/1433] Update changelog (#864) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index 47e3c5846..5342489ff 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,15 @@ See examples directory for more. Changelog ========= +0.32.0 Release Candidate 5 +========================== + +* Ensure protocol and sending errors are logged once (#862) @bdraco + +* Remove unreachable code in AsyncListener.datagram_received (#863) @bdraco + +* Make a dispatch dict for ServiceStateChange listeners (#859) @bdraco + 0.32.0 Release Candidate 4 ========================== From 6ef65fc7cafc3d4089a2b943da224c6cb027b4b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 21:05:29 -1000 Subject: [PATCH 0534/1433] Add test coverage for duplicate properties in a TXT record (#865) --- tests/services/test_info.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 37f98aa18..6a5ae4282 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -441,6 +441,41 @@ def get_service_info_helper(zc, type, name): zc.remove_all_service_listeners() zc.close() + def test_service_info_duplicate_properties_txt_records(self): + """Verify the first property is always used when there are duplicates in a txt record.""" + + zc = r.Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + ttl = 120 + now = r.current_time_millis() + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + info.async_update_records( + zc, + now, + [ + r.RecordUpdate( + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==\x04dd=0\x04jl=2\x04qq=0\x0brr=6fLM5A==\x04ci=3', + ), + None, + ) + ], + ) + assert info.properties[b"dd"] == b"0" + assert info.properties[b"jl"] == b"2" + assert info.properties[b"ci"] == b"2" + zc.close() + def test_multiple_addresses(): type_ = "_http._tcp.local." From dcf18c8a32652c6aa70af180b6a5261f4277faa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 21:20:32 -1000 Subject: [PATCH 0535/1433] Add test coverage to ensure ServiceBrowser ignores unrelated updates (#866) --- tests/test_aio.py | 92 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index f22bf9660..9da099fa0 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -15,7 +15,16 @@ import pytest from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes -from zeroconf import DNSIncoming, ServiceStateChange, Zeroconf, const +from zeroconf import ( + DNSIncoming, + DNSOutgoing, + DNSPointer, + DNSService, + DNSAddress, + ServiceStateChange, + Zeroconf, + const, +) from zeroconf.const import _LISTENER_TIME from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf._services import ServiceListener @@ -823,3 +832,84 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): assert second_outgoing.questions[0].unicast == False finally: await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_browser_ignores_unrelated_updates(): + """Test that the ServiceBrowser ignores unrelated updates.""" + + # instantiate a zeroconf instance + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + type_ = "_veryuniqueone._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener(ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + zc.cache.async_add_records( + [info.dns_pointer(), info.dns_service(), *info.dns_addresses(), info.dns_text()] + ) + + browser = AsyncServiceBrowser(zc, type_, None, listener) + + generated = DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + DNSPointer( + "_unrelated._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "zoom._unrelated._tcp.local.", + ), + 0, + ) + generated.add_answer_at_time( + DNSAddress( + "zoom._unrelated._tcp.local.", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"1234" + ), + 0, + ) + generated.add_answer_at_time( + DNSService( + "zoom._unrelated._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 81, + 'unrelated.local.', + ), + 0, + ) + + zc.handle_response(DNSIncoming(generated.packets()[0])) + + await browser.async_cancel() + await asyncio.sleep(0) + + assert callbacks == [ + ('add', type_, registration_name), + ] + await aiozc.async_close() From 22ff6b56d7b6531d2af5c50dca66fd2be2b276f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 21:47:17 -1000 Subject: [PATCH 0536/1433] Break apart new_socket to be testable (#867) --- tests/utils/test_net.py | 40 +++++++++++++++++++++++++++++++++ zeroconf/_utils/net.py | 49 +++++++++++++++++++++++++---------------- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 7890f381d..16c2b4853 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -8,6 +8,7 @@ import errno import ifaddr import pytest +import socket import unittest from zeroconf._utils import net as netutils @@ -99,3 +100,42 @@ def test_autodetect_ip_version(): assert r.autodetect_ip_version([]) is r.IPVersion.V4Only assert r.autodetect_ip_version(["::1", "1.2.3.4"]) is r.IPVersion.All assert r.autodetect_ip_version(["::1"]) is r.IPVersion.V6Only + + +def test_disable_ipv6_only_or_raise(): + """Test that IPV6_V6ONLY failing logs a nice error message and still raises.""" + errors_logged = [] + + def _log_error(*args): + nonlocal errors_logged + errors_logged.append(args) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + with pytest.raises(OSError), patch.object(netutils.log, "error", _log_error), patch( + "socket.socket.setsockopt", side_effect=OSError + ): + netutils.disable_ipv6_only_or_raise(sock) + + assert ( + errors_logged[0][0] + == 'Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6' + ) + + +@pytest.mark.skipif(not hasattr(socket, 'SO_REUSEPORT'), reason="System does not have SO_REUSEPORT") +def test_set_so_reuseport_if_available_is_present(): + """Test that setting socket.SO_REUSEPORT only OSError errno.ENOPROTOOPT is trapped.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError): + netutils.set_so_reuseport_if_available(sock) + + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + netutils.set_so_reuseport_if_available(sock) + + +@pytest.mark.skipif(hasattr(socket, 'SO_REUSEPORT'), reason="System has SO_REUSEPORT") +def test_set_so_reuseport_if_available_not_present(): + """Test that we do not try to set SO_REUSEPORT if it is not present.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + with patch("socket.socket.setsockopt", side_effect=OSError): + netutils.set_so_reuseport_if_available(sock) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 80a4377bf..b30c828a5 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -161,6 +161,34 @@ def normalize_interface_choice( return result +def disable_ipv6_only_or_raise(s: socket.socket) -> None: + """Make V6 sockets work for both V4 and V6 (required for Windows).""" + try: + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) + except OSError: + log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') + raise + + +def set_so_reuseport_if_available(s: socket.socket) -> None: + """Set SO_REUSEADDR on a socket if available.""" + # SO_REUSEADDR should be equivalent to SO_REUSEPORT for + # multicast UDP sockets (p 731, "TCP/IP Illustrated, + # Volume 2"), but some BSD-derived systems require + # SO_REUSEPORT to be specified explicitly. Also, not all + # versions of Python have SO_REUSEPORT available. + # Catch OSError and socket.error for kernel versions <3.9 because lacking + # SO_REUSEPORT support. + if not hasattr(socket, 'SO_REUSEPORT'): + return + + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # pylint: disable=no-member + except OSError as err: + if err.errno != errno.ENOPROTOOPT: + raise + + def new_socket( # pylint: disable=too-many-branches bind_addr: Union[Tuple[str], Tuple[str, int, int]], port: int = _MDNS_PORT, @@ -180,28 +208,11 @@ def new_socket( # pylint: disable=too-many-branches s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) if ip_version == IPVersion.All: - # make V6 sockets work for both V4 and V6 (required for Windows) - try: - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) - except OSError: - log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') - raise + disable_ipv6_only_or_raise(s) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # SO_REUSEADDR should be equivalent to SO_REUSEPORT for - # multicast UDP sockets (p 731, "TCP/IP Illustrated, - # Volume 2"), but some BSD-derived systems require - # SO_REUSEPORT to be specified explicitly. Also, not all - # versions of Python have SO_REUSEPORT available. - # Catch OSError and socket.error for kernel versions <3.9 because lacking - # SO_REUSEPORT support. - if hasattr(socket, 'SO_REUSEPORT'): - try: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # pylint: disable=no-member - except OSError as err: - if err.errno != errno.ENOPROTOOPT: - raise + set_so_reuseport_if_available(s) if port == _MDNS_PORT: ttl = struct.pack(b'B', 255) From 4ed903698b10f434cfbbe601998f27c10d2fb9db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 22:43:19 -1000 Subject: [PATCH 0537/1433] Fix deadlock when event loop is shutdown during service registration (#869) --- tests/test_core.py | 32 ++++++++++++++++++++++++++++++++ tests/utils/test_aio.py | 14 ++++++++++++++ zeroconf/_core.py | 9 +++++++-- zeroconf/_services/info.py | 4 ++-- zeroconf/_utils/aio.py | 17 ++++++++++++----- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 85571ddd5..2a3f368bb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,6 +12,7 @@ import socket import sys import time +import threading import unittest import unittest.mock from typing import cast @@ -715,3 +716,34 @@ def test_guard_against_duplicate_packets(): assert listener.suppress_duplicate_packet(b"other packet", current_time_millis() + 1000) is False assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is False zc.close() + + +def test_shutdown_while_register_in_process(): + """Test we can shutdown while registering a service in another thread.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # start a browser + type_ = "_homeassistant._tcp.local." + name = "MyTestHome" + info_service = r.ServiceInfo( + type_, + '%s.%s' % (name, type_), + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-90.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + def _background_register(): + zc.register_service(info_service) + + bgthread = threading.Thread(target=_background_register, daemon=True) + bgthread.start() + time.sleep(0.3) + + zc.close() + bgthread.join() diff --git a/tests/utils/test_aio.py b/tests/utils/test_aio.py index fd33234f8..524fd9733 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_aio.py @@ -64,6 +64,7 @@ def test_shutdown_loop() -> None: """Test shutting down an event loop.""" loop = None loop_thread_ready = threading.Event() + runcoro_thread_ready = threading.Event() def _run_loop() -> None: nonlocal loop @@ -76,6 +77,18 @@ def _run_loop() -> None: loop_thread.start() loop_thread_ready.wait() + async def _still_running(): + await asyncio.sleep(5) + + def _run_coro() -> None: + runcoro_thread_ready.set() + asyncio.run_coroutine_threadsafe(_still_running(), loop).result(1) + + runcoro_thread = threading.Thread(target=_run_coro, daemon=True) + runcoro_thread.start() + runcoro_thread_ready.wait() + + time.sleep(0.1) aioutils.shutdown_loop(loop) for _ in range(5): if not loop.is_running(): @@ -83,3 +96,4 @@ def _run_loop() -> None: time.sleep(0.05) assert loop.is_running() is False + runcoro_thread.join() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index b5e971b4c..ef4d4d706 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -21,6 +21,7 @@ """ import asyncio +import concurrent.futures import contextlib import itertools import random @@ -71,6 +72,7 @@ ) _TC_DELAY_RANDOM_INTERVAL = (400, 500) +_CLOSE_TIMEOUT = 3 class AsyncEngine: @@ -170,7 +172,7 @@ def close(self) -> None: return if not self.loop.is_running(): return - asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result() + asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result(_CLOSE_TIMEOUT) class AsyncListener(asyncio.Protocol, QuietLogger): @@ -416,7 +418,10 @@ def listeners(self) -> List[RecordUpdateListener]: def wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" assert self.loop is not None - asyncio.run_coroutine_threadsafe(self.async_wait(timeout), self.loop).result() + with contextlib.suppress(concurrent.futures.TimeoutError): + asyncio.run_coroutine_threadsafe(self.async_wait(timeout), self.loop).result( + millis_to_seconds(timeout) + ) async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 9d1c37f3e..52dabd2b7 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -37,7 +37,7 @@ _is_v6_address, ) from .._utils.struct import int2byte -from .._utils.time import current_time_millis +from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _CLASS_IN, _CLASS_UNIQUE, @@ -427,7 +427,7 @@ def request( raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") return asyncio.run_coroutine_threadsafe( self.async_request(zc, timeout, question_type), zc.loop - ).result() + ).result(millis_to_seconds(timeout) + 1) async def async_request( self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/aio.py index 7cc3b7fa4..57c1fb189 100644 --- a/zeroconf/_utils/aio.py +++ b/zeroconf/_utils/aio.py @@ -25,6 +25,10 @@ import queue from typing import Any, List, Optional, Set, cast +_TASK_AWAIT_TIMEOUT = 1 +_GET_ALL_TASKS_TIMEOUT = 1 +_WAIT_FOR_LOOP_TASKS_TIMEOUT = 2 # Must be larger than _TASK_AWAIT_TIMEOUT + def get_best_available_queue() -> queue.Queue: """Create the best available queue type.""" @@ -73,16 +77,19 @@ async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio. async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: """Wait for the event loop thread we started to shutdown.""" - await asyncio.wait(wait_tasks, timeout=1) + await asyncio.wait(wait_tasks, timeout=_TASK_AWAIT_TIMEOUT) def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" - pending_tasks = set(asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result()) - done_tasks = set(task for task in pending_tasks if not task.done()) - pending_tasks -= done_tasks + pending_tasks = set( + asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result(_GET_ALL_TASKS_TIMEOUT) + ) + pending_tasks -= set(task for task in pending_tasks if task.done()) if pending_tasks: - asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result() + asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result( + _WAIT_FOR_LOOP_TASKS_TIMEOUT + ) loop.call_soon_threadsafe(loop.stop) From 972da99e4dd9d0fe1c1e0786da45d66fd43a717a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Jun 2021 22:46:04 -1000 Subject: [PATCH 0538/1433] Update changelog (#870) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 5342489ff..3943d9e2c 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,13 @@ See examples directory for more. Changelog ========= +0.32.0 Release Candidate 6 +========================== + +* Fix deadlock when event loop is shutdown during service registration (#869) @bdraco + +* Break apart new_socket to be testable (#867) @bdraco + 0.32.0 Release Candidate 5 ========================== From 471bacd3200aa1216054c0e52b2e5842e9760aa0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 15:49:08 -1000 Subject: [PATCH 0539/1433] Add coverage to ensure unrelated A records do not generate ServiceBrowser callbacks (#874) closes #871 --- tests/test_aio.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/test_aio.py b/tests/test_aio.py index 9da099fa0..fb3f07ea2 100644 --- a/tests/test_aio.py +++ b/tests/test_aio.py @@ -21,6 +21,7 @@ DNSPointer, DNSService, DNSAddress, + DNSText, ServiceStateChange, Zeroconf, const, @@ -868,7 +869,22 @@ def update_service(self, zc, type_, name) -> None: address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) zc.cache.async_add_records( - [info.dns_pointer(), info.dns_service(), *info.dns_addresses(), info.dns_text()] + [ + info.dns_pointer(), + info.dns_service(), + *info.dns_addresses(), + info.dns_text(), + DNSService( + "zoom._unrelated._tcp.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 81, + 'unrelated.local.', + ), + ] ) browser = AsyncServiceBrowser(zc, type_, None, listener) @@ -885,21 +901,16 @@ def update_service(self, zc, type_, name) -> None: 0, ) generated.add_answer_at_time( - DNSAddress( - "zoom._unrelated._tcp.local.", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"1234" - ), + DNSAddress("unrelated.local.", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"1234"), 0, ) generated.add_answer_at_time( - DNSService( + DNSText( "zoom._unrelated._tcp.local.", - const._TYPE_SRV, - const._CLASS_IN, - const._DNS_HOST_TTL, - 0, - 0, - 81, - 'unrelated.local.', + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"zoom", ), 0, ) From decd8a26aa8a89ceefcd9452fe562f2eeaa3fecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 16:14:33 -1000 Subject: [PATCH 0540/1433] Fix flapping test test_integration_with_listener_class (#876) --- tests/test_services.py | 194 +++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 94 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 12ad95ba8..b1e2d8904 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -10,6 +10,7 @@ import os import unittest from threading import Event +from unittest.mock import patch import pytest @@ -95,100 +96,105 @@ def update_service(self, zeroconf, type, name): ) zeroconf_registrar.register_service(info_service) - try: - service_added.wait(1) - assert service_added.is_set() - - # short pause to allow multicast timers to expire - time.sleep(3) - - # clear the answer cache to force query - _clear_cache(zeroconf_browser) - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties == {} - - # get service info without answer cache - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_none'] is None - assert info.properties[b'prop_string'] == properties['prop_string'] - assert info.properties[b'prop_float'] == b'1.0' - assert info.properties[b'prop_blank'] == properties['prop_blank'] - assert info.properties[b'prop_true'] == b'1' - assert info.properties[b'prop_false'] == b'0' - assert info.addresses == addresses[:1] # no V6 by default - assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - - # Populate the cache - zeroconf_browser.get_service_info(subtype, registration_name) - - # get service info with only the cache - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - # get service info with only the cache with the lowercase name - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - # Ensure uppercase output is preserved - assert cached_info.name == registration_name - assert cached_info.key == registration_name.lower() - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - info = zeroconf_browser.get_service_info(subtype, registration_name) - assert info is not None - assert info.properties is not None - assert info.properties[b'prop_none'] is None - - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_none'] is None - - # test TXT record update - sublistener = MySubListener() - zeroconf_browser.add_service_listener(registration_name, sublistener) - properties['prop_blank'] = b'an updated string' - desc.update(properties) - info_service = ServiceInfo( - subtype, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.update_service(info_service) - service_updated.wait(1) - assert service_updated.is_set() - - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_blank'] == properties['prop_blank'] - - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] - - zeroconf_registrar.unregister_service(info_service) - service_removed.wait(1) - assert service_removed.is_set() - - finally: - zeroconf_registrar.close() - zeroconf_browser.remove_service_listener(listener) - zeroconf_browser.close() + with patch.object( + zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False + ), patch.object( + zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False + ): + try: + service_added.wait(1) + assert service_added.is_set() + + # short pause to allow multicast timers to expire + time.sleep(3) + + # clear the answer cache to force query + _clear_cache(zeroconf_browser) + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties == {} + + # get service info without answer cache + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_none'] is None + assert info.properties[b'prop_string'] == properties['prop_string'] + assert info.properties[b'prop_float'] == b'1.0' + assert info.properties[b'prop_blank'] == properties['prop_blank'] + assert info.properties[b'prop_true'] == b'1' + assert info.properties[b'prop_false'] == b'0' + assert info.addresses == addresses[:1] # no V6 by default + assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + + # Populate the cache + zeroconf_browser.get_service_info(subtype, registration_name) + + # get service info with only the cache + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + # get service info with only the cache with the lowercase name + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + # Ensure uppercase output is preserved + assert cached_info.name == registration_name + assert cached_info.key == registration_name.lower() + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + info = zeroconf_browser.get_service_info(subtype, registration_name) + assert info is not None + assert info.properties is not None + assert info.properties[b'prop_none'] is None + + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_none'] is None + + # test TXT record update + sublistener = MySubListener() + zeroconf_browser.add_service_listener(registration_name, sublistener) + properties['prop_blank'] = b'an updated string' + desc.update(properties) + info_service = ServiceInfo( + subtype, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.update_service(info_service) + service_updated.wait(1) + assert service_updated.is_set() + + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_blank'] == properties['prop_blank'] + + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] + + zeroconf_registrar.unregister_service(info_service) + service_removed.wait(1) + assert service_removed.is_set() + + finally: + zeroconf_registrar.close() + zeroconf_browser.remove_service_listener(listener) + zeroconf_browser.close() def test_servicelisteners_raise_not_implemented(): From f0770fea80b00f2340815fa983968f68a15c702e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 16:19:11 -1000 Subject: [PATCH 0541/1433] Break apart net_socket for easier testing (#875) --- tests/utils/test_net.py | 17 ++++++++++++++++ zeroconf/_utils/net.py | 45 +++++++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 16c2b4853..7c445b47e 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -139,3 +139,20 @@ def test_set_so_reuseport_if_available_not_present(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) with patch("socket.socket.setsockopt", side_effect=OSError): netutils.set_so_reuseport_if_available(sock) + + +def test_set_mdns_port_socket_options_for_ip_version(): + """Test OSError with errno with EINVAL and bind address '' from setsockopt IP_MULTICAST_TTL does not raise.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Should raise on EPERM always + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ('',), r.IPVersion.V4Only) + + # Should raise on EINVAL always when bind address is not '' + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ('127.0.0.1',), r.IPVersion.V4Only) + + # Should not raise on EINVAL when bind address is '' + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ('',), r.IPVersion.V4Only) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index b30c828a5..937dc1160 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -189,6 +189,28 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: raise +def set_mdns_port_socket_options_for_ip_version( + s: socket.socket, bind_addr: Union[Tuple[str], Tuple[str, int, int]], ip_version: IPVersion +) -> None: + """Set ttl/hops and loop for mdns port.""" + if ip_version != IPVersion.V6Only: + ttl = struct.pack(b'B', 255) + loop = struct.pack(b'B', 1) + # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and + # IP_MULTICAST_LOOP socket options as an unsigned char. + try: + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + except socket.error as e: + if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS + raise + + if ip_version != IPVersion.V4Only: + # However, char doesn't work here (at least on Linux) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) + s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + + def new_socket( # pylint: disable=too-many-branches bind_addr: Union[Tuple[str], Tuple[str, int, int]], port: int = _MDNS_PORT, @@ -202,34 +224,17 @@ def new_socket( # pylint: disable=too-many-branches apple_p2p, bind_addr, ) - if ip_version == IPVersion.V4Only: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - else: - s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + socket_family = socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6 + s = socket.socket(socket_family, socket.SOCK_DGRAM) if ip_version == IPVersion.All: disable_ipv6_only_or_raise(s) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - set_so_reuseport_if_available(s) if port == _MDNS_PORT: - ttl = struct.pack(b'B', 255) - loop = struct.pack(b'B', 1) - if ip_version != IPVersion.V6Only: - # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and - # IP_MULTICAST_LOOP socket options as an unsigned char. - try: - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) - except socket.error as e: - if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS - raise - if ip_version != IPVersion.V4Only: - # However, char doesn't work here (at least on Linux) - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) - s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version) if apple_p2p: # SO_RECV_ANYIF = 0x1104 From ab83819ad6b6ff727a894271dde3e4be6c28cb2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 16:28:08 -1000 Subject: [PATCH 0542/1433] Add coverge for disconnected adapters in add_multicast_member (#877) --- tests/utils/test_net.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 7c445b47e..399bd6acd 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -156,3 +156,32 @@ def test_set_mdns_port_socket_options_for_ip_version(): # Should not raise on EINVAL when bind address is '' with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): netutils.set_mdns_port_socket_options_for_ip_version(sock, ('',), r.IPVersion.V4Only) + + +def test_add_multicast_member(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + interface = '127.0.0.1' + + # EPERM should always raise + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): + netutils.add_multicast_member(sock, interface) + + # EADDRINUSE should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRINUSE, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # EADDRNOTAVAIL should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # EINVAL should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # ENOPROTOOPT should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # No error should return True + with patch("socket.socket.setsockopt"): + assert netutils.add_multicast_member(sock, interface) is True From 86e2ab9db3c7bd47b6e81837d594280ced3b30f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 16:43:19 -1000 Subject: [PATCH 0543/1433] Add coverage to ensure loading zeroconf._logger does not override logging level (#878) --- tests/test_logger.py | 18 +++++++++++++++++- zeroconf/_logger.py | 9 +++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_logger.py b/tests/test_logger.py index 2c661cf98..205ce0ff9 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -4,8 +4,24 @@ """Unit tests for logger.py.""" +import logging from unittest.mock import patch -from zeroconf._logger import QuietLogger +from zeroconf._logger import QuietLogger, set_logger_level_if_unset + + +def test_loading_logger(): + """Test loading logger does not change level unless it is unset.""" + log = logging.getLogger('zeroconf') + log.setLevel(logging.CRITICAL) + set_logger_level_if_unset() + log = logging.getLogger('zeroconf') + assert log.level == logging.CRITICAL + + log = logging.getLogger('zeroconf') + log.setLevel(logging.NOTSET) + set_logger_level_if_unset() + log = logging.getLogger('zeroconf') + assert log.level == logging.WARNING def test_log_warning_once(): diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index 3577bb053..78c211480 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -27,8 +27,13 @@ log = logging.getLogger(__name__.split('.')[0]) log.addHandler(logging.NullHandler()) -if log.level == logging.NOTSET: - log.setLevel(logging.WARN) + +def set_logger_level_if_unset() -> None: + if log.level == logging.NOTSET: + log.setLevel(logging.WARN) + + +set_logger_level_if_unset() class QuietLogger: From be1d3bbe0ee12254d11e3d8b75c2faba950fabce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jun 2021 21:29:50 -1000 Subject: [PATCH 0544/1433] Update changelog (#879) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 3943d9e2c..5aec201d0 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,13 @@ See examples directory for more. Changelog ========= +0.32.0 Release Candidate 7 +========================== + +This release offers 100% line and branch coverage + +* Break apart new_socket for easier testing (#875) @bdraco + 0.32.0 Release Candidate 6 ========================== From b9eae5a6f8f86bfe60446f133cad5fc33d072959 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 08:09:01 -1000 Subject: [PATCH 0545/1433] Revert name change of zeroconf.asyncio to zeroconf.aio (#885) - Now that `__init__.py` no longer needs to import `asyncio`, the name conflict is not a concern. Fixes #883 --- docs/api.rst | 2 +- examples/async_apple_scanner.py | 2 +- examples/async_browser.py | 2 +- examples/async_registration.py | 2 +- examples/async_service_info_request.py | 2 +- tests/services/test_browser.py | 2 +- tests/services/test_info.py | 2 +- tests/{test_aio.py => test_asyncio.py} | 6 +++--- tests/test_core.py | 2 +- tests/test_handlers.py | 2 +- tests/utils/{test_aio.py => test_asyncio.py} | 6 +++--- zeroconf/_core.py | 2 +- zeroconf/_services/browser.py | 2 +- zeroconf/_services/info.py | 2 +- zeroconf/_utils/{aio.py => asyncio.py} | 0 zeroconf/{aio.py => asyncio.py} | 0 16 files changed, 18 insertions(+), 18 deletions(-) rename tests/{test_aio.py => test_asyncio.py} (99%) rename tests/utils/{test_aio.py => test_asyncio.py} (93%) rename zeroconf/_utils/{aio.py => asyncio.py} (100%) rename zeroconf/{aio.py => asyncio.py} (100%) diff --git a/docs/api.rst b/docs/api.rst index 20c53727f..1704db5af 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,7 +6,7 @@ python-zeroconf API reference :undoc-members: :show-inheritance: -.. automodule:: zeroconf.aio +.. automodule:: zeroconf.asyncio :members: :undoc-members: :show-inheritance: diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index 573640b0a..f10f6ef63 100644 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -8,7 +8,7 @@ from typing import Any, Optional, cast from zeroconf import DNSQuestionType, IPVersion, ServiceStateChange, Zeroconf -from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf HOMESHARING_SERVICE: str = "_appletv-v2._tcp.local." DEVICE_SERVICE: str = "_touch-able._tcp.local." diff --git a/examples/async_browser.py b/examples/async_browser.py index 85192e140..f0e0851cf 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -11,7 +11,7 @@ from typing import Any, Optional, cast from zeroconf import IPVersion, ServiceStateChange, Zeroconf -from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes def async_on_service_state_change( diff --git a/examples/async_registration.py b/examples/async_registration.py index 7e02ea7c0..53d14ce1a 100644 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -9,7 +9,7 @@ from typing import List from zeroconf import IPVersion -from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf async def register_services(infos: List[AsyncServiceInfo]) -> None: diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 8ea961eb7..885eb99c4 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -13,7 +13,7 @@ from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf -from zeroconf.aio import AsyncServiceInfo, AsyncZeroconf +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf HAP_TYPE = "_hap._tcp.local." diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 36f459c7c..26684e093 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -22,7 +22,7 @@ from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo -from zeroconf.aio import AsyncZeroconf +from zeroconf.asyncio import AsyncZeroconf from .. import has_working_ipv6, _inject_response, _wait_for_start diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 6a5ae4282..02ba581e4 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -18,7 +18,7 @@ import zeroconf as r from zeroconf import DNSAddress, const from zeroconf._services.info import ServiceInfo -from zeroconf.aio import AsyncZeroconf +from zeroconf.asyncio import AsyncZeroconf from .. import has_working_ipv6, _inject_response diff --git a/tests/test_aio.py b/tests/test_asyncio.py similarity index 99% rename from tests/test_aio.py rename to tests/test_asyncio.py index fb3f07ea2..759ab5b33 100644 --- a/tests/test_aio.py +++ b/tests/test_asyncio.py @@ -14,7 +14,7 @@ import pytest -from zeroconf.aio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes from zeroconf import ( DNSIncoming, DNSOutgoing, @@ -422,7 +422,7 @@ async def test_service_info_async_request() -> None: _clear_cache(aiozc.zeroconf) # Generating the race condition is almost impossible # without patching since its a TOCTOU race - with patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): + with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): await aiosinfo.async_request(aiozc.zeroconf, 3000) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] @@ -826,7 +826,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): with patch.object(zeroconf_info, "async_send", send): aiosinfo = AsyncServiceInfo(type_, registration_name) # Patch _is_complete so we send multiple times - with patch("zeroconf.aio.AsyncServiceInfo._is_complete", False): + with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): await aiosinfo.async_request(aiozc.zeroconf, 1200) try: assert first_outgoing.questions[0].unicast == True diff --git a/tests/test_core.py b/tests/test_core.py index 2a3f368bb..d80514f7c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,7 +20,7 @@ import zeroconf as r from zeroconf import _core, _protocol, const, Zeroconf, current_time_millis -from zeroconf.aio import AsyncZeroconf +from zeroconf.asyncio import AsyncZeroconf from . import has_working_ipv6, _clear_cache, _inject_response, _wait_for_start diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 8fe2c56dd..bab50a558 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -17,7 +17,7 @@ from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const from zeroconf._dns import DNSRRSet -from zeroconf.aio import AsyncZeroconf +from zeroconf.asyncio import AsyncZeroconf from . import _clear_cache, _inject_response diff --git a/tests/utils/test_aio.py b/tests/utils/test_asyncio.py similarity index 93% rename from tests/utils/test_aio.py rename to tests/utils/test_asyncio.py index 524fd9733..ccafb72fb 100644 --- a/tests/utils/test_aio.py +++ b/tests/utils/test_asyncio.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- -"""Unit tests for zeroconf._utils.aio.""" +"""Unit tests for zeroconf._utils.asyncio.""" import asyncio import contextlib @@ -12,7 +12,7 @@ import pytest -from zeroconf._utils import aio as aioutils +from zeroconf._utils import asyncio as aioutils @pytest.mark.asyncio @@ -25,7 +25,7 @@ async def test_async_get_all_tasks() -> None: await aioutils._async_get_all_tasks(aioutils.get_running_loop()) if not hasattr(asyncio, 'all_tasks'): return - with patch("zeroconf._utils.aio.asyncio.all_tasks", side_effect=RuntimeError): + with patch("zeroconf._utils.asyncio.asyncio.all_tasks", side_effect=RuntimeError): await aioutils._async_get_all_tasks(aioutils.get_running_loop()) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index ef4d4d706..a20e5639f 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -43,7 +43,7 @@ from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.aio import get_running_loop, shutdown_loop, wait_event_or_timeout +from ._utils.asyncio import get_running_loop, shutdown_loop, wait_event_or_timeout from ._utils.name import service_type_name from ._utils.net import ( IPVersion, diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 5f2dbd31e..fecf35c9f 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -38,7 +38,7 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.aio import get_best_available_queue +from .._utils.asyncio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 52dabd2b7..3e371b17c 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -29,7 +29,7 @@ from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.aio import get_running_loop +from .._utils.asyncio import get_running_loop from .._utils.name import service_type_name from .._utils.net import ( IPVersion, diff --git a/zeroconf/_utils/aio.py b/zeroconf/_utils/asyncio.py similarity index 100% rename from zeroconf/_utils/aio.py rename to zeroconf/_utils/asyncio.py diff --git a/zeroconf/aio.py b/zeroconf/asyncio.py similarity index 100% rename from zeroconf/aio.py rename to zeroconf/asyncio.py From b9dc12dee8b4a7f6d8e1f599948bf16e5e7fab47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 08:09:21 -1000 Subject: [PATCH 0546/1433] Disable pylint in the CI (#886) --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4f9a350..01b181feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,11 +56,6 @@ jobs: run: | ${{ matrix.venvcmd }} make black_check - - name: Run pylint - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} - run: | - ${{ matrix.venvcmd }} - make pylint - name: Run tests run: | ${{ matrix.venvcmd }} From 14cf9362c9ae947bcee5911b9c593ca76f50d529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 08:29:49 -1000 Subject: [PATCH 0547/1433] Collapse changelog for 0.32.0 (#887) --- README.rst | 531 +---------------------------------------------------- 1 file changed, 7 insertions(+), 524 deletions(-) diff --git a/README.rst b/README.rst index 5aec201d0..536ddc5ac 100644 --- a/README.rst +++ b/README.rst @@ -140,31 +140,14 @@ See examples directory for more. Changelog ========= -0.32.0 Release Candidate 7 -========================== +0.32.0 (Unreleased) +=================== -This release offers 100% line and branch coverage - -* Break apart new_socket for easier testing (#875) @bdraco - -0.32.0 Release Candidate 6 -========================== - -* Fix deadlock when event loop is shutdown during service registration (#869) @bdraco - -* Break apart new_socket to be testable (#867) @bdraco - -0.32.0 Release Candidate 5 -========================== - -* Ensure protocol and sending errors are logged once (#862) @bdraco - -* Remove unreachable code in AsyncListener.datagram_received (#863) @bdraco - -* Make a dispatch dict for ServiceStateChange listeners (#859) @bdraco +Documentation for breaking changes era on the side of the caution and likely +overstates the risk on many of these. If you are not accessing zeroconf internals, +you can likely not be concerned with the breaking changes below: -0.32.0 Release Candidate 4 -========================== +This release offers 100% line and branch coverage * Make ServiceInfo first question QU (#852) @bdraco @@ -185,22 +168,6 @@ This release offers 100% line and branch coverage also asks the first question as QU since ServiceInfo is commonly called from ServiceBrowser callbacks -0.32.0 Release Candidate 3 -========================== - -* Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849) @bdraco - - Simplifies scheduling as there is no more need to sleep in a loop as - we now schedule future callbacks with call_later - - Simplifies cancelation as there is no more coroutine to cancel, only a timer handle - We no longer have to handle the canceled error and cleaning up the awaitable - - Solves the infrequent test failures in test_backoff and test_integration - -0.32.0 Release Candidate 2 -========================== - * Limit duplicate packet suppression to 1s intervals (#841) @bdraco Only suppress duplicate packets that happen within the same @@ -216,18 +183,6 @@ This release offers 100% line and branch coverage multi-packet known answer supression since it was not expecting to get the same data more than once - -0.32.0 Release Candidate 1 -========================== - -No changes - -0.32.0 Beta 6 -============= - -This beta addresses two potential areas where zeroconf can be overwhelmed and -deny service to legitimate queriers. - * BREAKING CHANGE: Drop oversize packets before processing them (#826) @bdraco Oversized packets can quickly overwhelm the system and deny @@ -242,16 +197,6 @@ deny service to legitimate queriers. Apple uses a 15s minimum TTL, however we do not have the same level of rate limit and safe guards so we use 1/4 of the recommended value. -0.32.0 Beta 5 -============= - -* Only wake up the query loop when there is a change in the next query time (#818) @bdraco - - The ServiceBrowser query loop (async_browser_task) was being awoken on - every packet because it was using `zeroconf.async_wait` which wakes - up on every new packet. We only need to awaken the loop when the next time - we are going to send a query has changed. - * New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) @bdraco https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 @@ -263,22 +208,6 @@ deny service to legitimate queriers. the network, and has the secondary advantage that most responders will answer a QU question without the typical delay answering QM questions. - -0.32.0 Beta 4 -============= - -* Simplify wait_event_or_timeout (#810) @bdraco - - This function always did the same thing on timeout and - wait complete so we can use the same callback. This - solves the CI failing due to the test coverage flapping - back and forth as the timeout would rarely happen. - -* Make DNSHinfo and DNSAddress use the same match order as DNSPointer and DNSText (#808) @bdraco - - We want to check the data that is most likely to be unique first - so we can reject the __eq__ as soon as possible. - * Qualify IPv6 link-local addresses with scope_id (#343) @ibygrave When a service is advertised on an IPv6 address where @@ -290,28 +219,8 @@ deny service to legitimate queriers. return qualified addresses to avoid breaking compatibility on the existing parsed_addresses(). -0.32.0 Beta 3 -============= - * Skip network adapters that are disconnected (#327) @ZLJasonG -* Add slots to DNS classes (#803) @bdraco - - On a busy network that receives many mDNS packets per second, we - will not know the answer to most of the questions being asked. - In this case the creating the DNS* objects are usually garbage - collected within 1s as they are not needed. We now set __slots__ - to speed up the creation and destruction of these objects - -0.32.0 Beta 2 -============= - -* Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) @bdraco - -* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) @bdraco - - This is fixing ServiceBrowser missing an add when the record is already in the cache. - * Pass both the new and old records to async_update_records (#792) @bdraco Pass the old_record (cached) as the value and the new_record (wire) @@ -320,23 +229,6 @@ deny service to legitimate queriers. when generating the async_update_records call. This avoids the overhead of multiple cache lookups for each listener. -* Make add_listener and remove_listener threadsafe (#794) @bdraco - -* Ensure outgoing ServiceBrowser questions are seen by the question history (#790) @bdraco - -0.32.0 Beta 1 -============= - -Documentation for breaking changes era on the side of the caution and likely -overstates the risk on many of these. If you are not accessing zeroconf internals, -you can likely not be concerned with the breaking changes below: - -* BREAKING CHANGE: zeroconf.asyncio has been renamed zeroconf.aio (#503) @bdraco - - The asyncio name could shadow system asyncio in some cases. If - zeroconf is in sys.path, this would result in loading zeroconf.asyncio - when system asyncio was intended. - * BREAKING CHANGE: Update internal version check to match docs (3.6+) (#491) @bdraco Python version eariler then 3.6 were likely broken with zeroconf @@ -442,71 +334,6 @@ you can likely not be concerned with the breaking changes below: * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco -* Add async_apple_scanner example (#719) @bdraco - -* Add support for requesting QU questions to ServiceBrowser and ServiceInfo (#787) @bdraco - -* Ensure the queue is created before adding listeners to ServiceBrowser (#785) @bdraco - - The callback from the listener could generate an event that would - fire in async context that should have gone to the queue which - could result in the consumer running a sync call in the event loop - and blocking it. - -* Add a guard to prevent running ServiceInfo.request in async context (#784) @bdraco - -* Inline utf8 decoding when processing incoming packets (#782) @bdraco - -* Drop utf cache from _dns (#781) (later reverted) @bdraco - -* Switch to using a simple cache instead of lru_cache (#779) (later reverted) @bdraco - -* Fix Responding to Address Queries (RFC6762 section 6.2) (#777) @bdraco - -* Fix deadlock on ServiceBrowser shutdown with PyPy (#774) @bdraco - -* Add a guard against the task list changing when shutting down (#776) @bdraco - -* Improve performance of parsing DNSIncoming by caching read_utf (#769) (later reverted) @bdraco - -* Switch to using an asyncio.Event for async_wait (#759) @bdraco - - We no longer need to check for thread safety under a asyncio.Condition - as the ServiceBrowser and ServiceInfo internals schedule coroutines - in the eventloop. - -* Simplify ServiceBrowser callsbacks (#756) @bdraco - -* Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) @bdraco - -- This guarding is no longer needed as the ServiceBrowser loop - now runs in the event loop and the thread safety guard is no - longer needed - -* Drop AsyncServiceListener (#754) @bdraco (Never shipped) - -* Run ServiceBrowser queries in the event loop (#752) @bdraco - -* Remove unused argument from AsyncZeroconf (#751) @bdraco - -* Fix warning about Zeroconf._async_notify_all not being awaited in sync shutdown (#750) @bdraco - -* Update async_service_info_request example to ensure it runs in the right event loop (#749) @bdraco - -* Run ServiceInfo requests in the event loop (#748) @bdraco - -* Remove support for notify listeners (#733) @bdraco (Never shipped) - -* Relocate service browser tests to tests/services/test_browser.py (#745) @bdraco - -* Relocate ServiceInfo to zeroconf._services.info (#741) @bdraco - -* Run question answer callbacks from add_listener in the event loop (#740) @bdraco - - Calling async_update_records and async_update_records_complete should always - happen in the event loop to ensure implementers do not need to worry about - thread safety - * Remove second level caching from ServiceBrowsers (#737) @bdraco The ServiceBrowser had its own cache of the last time it @@ -514,23 +341,6 @@ you can likely not be concerned with the breaking changes below: presenting a source of truth problem that lead to unexpected queries when the two disagreed. -* Breakout ServiceBrowser handler from listener creation (#736) @bdraco - - Add coverage for the handler from listener - -* Add fast cache lookup functions (#732) @bdraco - - The majority of our lookups happen in the event loop so there is no need - for them to be threadsafe. Now that the codebase is more clear about what - needs to be threadsafe and what does not need to be threadsafe we can use - the much faster non-threadsafe versions in the places where we are calling - from the event loop. - -* Switch to using DNSRRSet in RecordManager (#735) @bdraco - - DNSRRSet is able to do O(1) lookups of records assuming - there are no collisions. - * Fix server cache to be case-insensitive (#731) @bdraco If the server name had uppercase chars and any of the @@ -549,33 +359,12 @@ you can likely not be concerned with the breaking changes below: unique record and we never have a source of truth problem determining the TTL of a record from the cache. -* Rename handlers and internals to make it clear what is threadsafe (#726) @bdraco - - It was too easy to get confused about what was threadsafe and - what was not threadsafe which lead to unexpected failures. - Rename functions to make it clear what will be run in the event - loop and what is expected to be threadsafe - * Fix ServiceInfo with multiple A records (#725) @bdraco If there were multiple A records for the host, ServiceInfo would always return the last one that was in the incoming packet which was usually not the one that was wanted. -* Synchronize time for fate sharing (#718) @bdraco - -* Cleanup typing in zero._core and document ignores (#714) @bdraco - -* Cleanup typing in zeroconf._logger (#715) @bdraco - -* Cleanup typing in zeroconf._utils.net (#713) @bdraco - -* Cleanup typing in zeroconf._services (#711) @bdraco - -* Cleanup typing in zeroconf._services.registry (#712) @bdraco - -* Add setter for DNSQuestion to easily make a QU question (#710) @bdraco - * Set stale unique records to expire 1s in the future instead of instant removal (#706) @bdraco tools.ietf.org/html/rfc6762#section-10.2 @@ -588,44 +377,11 @@ you can likely not be concerned with the breaking changes below: cooperating responders one second to send out their own response to "rescue" the records before they expire and are deleted. -* Fix thread safety in _ServiceBrowser.update_records_complete (#708) @bdraco - -* Split DNSOutgoing/DNSIncoming/DNSMessage into zeroconf._protocol (#705) @bdraco - -* Abstract DNSOutgoing ttl write into _write_ttl (#695) @bdraco - -* Rollback data in one call instead of poping one byte at a time in DNS Outgoing (#696) @bdraco - * Suppress additionals when answer is suppressed (#690) @bdraco -* Move setting DNS created and ttl into its own function (#692) @bdraco - -* Add truncated property to DNSMessage to lookup the TC bit (#686) @bdraco - -* Check if SO_REUSEPORT exists instead of using an exception catch (#682) @bdraco - -* Use DNSRRSet for known answer suppression (#680) @bdraco - - DNSRRSet uses hash table lookups under the hood which - is much faster than the linear searches used by - DNSRecord.suppressed_by - -* Add DNSRRSet class for quick hashtable lookups of records (#678) @bdraco - - This class will be used to do fast checks to see - if records should be suppressed by a set of answers. - * Allow unregistering a service multiple times (#679) @bdraco -* Remove unreachable BadTypeInNameException check in _ServiceBrowser (#677) @bdraco - -* Update async_browser.py example to use AsyncZeroconfServiceTypes (#665) @bdraco - -* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.aio (#658) @bdraco - -* Remove all calls to the executor in AsyncZeroconf (#653) @bdraco - -* Set __all__ in zeroconf.aio to ensure private functions do now show in the docs (#652) @bdraco +* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.asyncio (#658) @bdraco * Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) @bdraco @@ -639,137 +395,15 @@ you can likely not be concerned with the breaking changes below: time. To avoid this, we now remove the services from the registry right after we generate the goodbye packet -* Use ServiceInfo.key/ServiceInfo.server_key instead of lowering in ServiceRegistry (#647) @bdraco - -* Ensure the ServiceInfo.key gets updated when the name is changed externally (#645) @bdraco - -* Ensure AsyncZeroconf.async_close can be called multiple times like Zeroconf.close (#638) @bdraco - -* Ensure eventloop shutdown is threadsafe (#636) @bdraco - -* Return early in the shutdown/close process (#632) @bdraco - -* Remove unreachable cache check for DNSAddresses (#629) @bdraco - - The ServiceBrowser would check to see if a DNSAddress was - already in the cache and return early to avoid sending - updates when the address already was held in the cache. - This check was not needed since there is already a check - a few lines before as `self.zc.cache.get(record)` which - effectively does the same thing. This lead to the check - never being covered in the tests and 2 cache lookups when - only one was needed. - -* Add test for wait_condition_or_timeout_times_out util (#630) @bdraco - -* Return early on invalid data received (#628) @bdraco - - Improve coverage for handling invalid incoming data - -* Add test to ensure ServiceBrowser sees port change as an update (#625) @bdraco - -* Fix random test failures due to monkey patching not being undone between tests (#626) @bdraco - - Switch patching to use unitest.mock.patch to ensure the patch - is reverted when the test is completed - * Ensure zeroconf can be loaded when the system disables IPv6 (#624) @bdraco -* Eliminate aio sender thread (#622) @bdraco - -* Replace select loop with asyncio loop (#504) @bdraco - -* Add is_recent property to DNSRecord (#620) @bdraco - - RFC 6762 defines recent as not multicast within one quarter of its TTL - datatracker.ietf.org/doc/html/rfc6762#section-5.4 - -* Breakout the query response handler into its own class (#615) @bdraco - -* Add the ability for ServiceInfo.dns_addresses to filter by address type (#612) @bdraco - -* Make DNSRecords hashable (#611) @bdraco - - Allows storing them in a set for de-duplication - - Needed to be able to check for duplicates to solve #604 - * Ensure the QU bit is set for probe queries (#609) @bdraco The bit should be set per datatracker.ietf.org/doc/html/rfc6762#section-8.1 -* Log destination when sending packets (#606) @bdraco - -* Fix docs version to match readme (cpython 3.6+) (#602) @bdraco - -* Add ZeroconfServiceTypes to zeroconf.__all__ (#601) @bdraco - - This class is in the readme, but is not exported by - default - -* Add id_ param to allow setting the id in the DNSOutgoing constructor (#599) @bdraco - -* Add unicast property to DNSQuestion to determine if the QU bit is set (#593) @bdraco - -* Reduce branching in DNSOutgoing.add_answer_at_time (#592) @bdraco - -* Breakout DNSCache into zeroconf.cache (#568) @bdraco - -* Removed protected imports from zeroconf namespace (#567) @bdraco - -* Fix invalid typing in ServiceInfo._set_text (#554) @bdraco - -* Move QueryHandler and RecordManager handlers into zeroconf.handlers (#551) @bdraco - -* Move ServiceListener to zeroconf.services (#550) @bdraco - -* Move the ServiceRegistry into its own module (#549) @bdraco - -* Move ServiceStateChange to zeroconf.services (#548) @bdraco - -* Relocate core functions into zeroconf.core (#547) @bdraco - -* Breakout service classes into zeroconf.services (#544) @bdraco - -* Move service_type_name to zeroconf.utils.name (#543) @bdraco - -* Relocate DNS classes to zeroconf.dns (#541) @bdraco - -* Update zeroconf.aio import locations (#539) @bdraco - -* Move int2byte to zeroconf.utils.struct (#540) @bdraco - -* Breakout network utils into zeroconf.utils.net (#537) @bdraco - -* Move time utility functions into zeroconf.utils.time (#536) @bdraco - -* Avoid making DNSOutgoing aware of the Zeroconf object (#535) @bdraco - -* Move logger into zeroconf.logger (#533) @bdraco - -* Move exceptions into zeroconf.exceptions (#532) @bdraco - -* Move constants into const.py (#531) @bdraco - -* Move asyncio utils into zeroconf.utils.aio (#530) @bdraco - -* Move ipversion auto detection code into its own function (#524) @bdraco - * Breaking change: Update python compatibility as PyPy3 7.2 is required (#523) @bdraco -* Remove broad exception catch from RecordManager.remove_listener (#517) @bdraco - -* Small cleanups to RecordManager.add_listener (#516) @bdraco - -* Move RecordUpdateListener management into RecordManager (#514) @bdraco - -* Break out record updating into RecordManager (#512) @bdraco - -* Remove uneeded wait in the Engine thread (#511) @bdraco - -* Extract code for handling queries into QueryHandler (#507) @bdraco - * Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco * Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco @@ -783,142 +417,16 @@ you can likely not be concerned with the breaking changes below: exceeds _MAX_MSG_TYPICAL datatracker.ietf.org/doc/html/rfc6762#section-17 -* Make a base class for DNSIncoming and DNSOutgoing (#497) @bdraco - -* Remove unused __ne__ code from Python 2 era (#492) @bdraco - -* Lint before testing in the CI (#488) @bdraco - -* Add AsyncServiceBrowser example (#487) @bdraco - -* Move threading daemon property into ServiceBrowser class (#486) @bdraco - -* Enable test_integration_with_listener_class test on PyPy (#485) @bdraco - -* AsyncServiceBrowser must recheck for handlers to call when holding condition (#483) - - There was a short race condition window where the AsyncServiceBrowser - could add to _handlers_to_call in the Engine thread, have the - condition notify_all called, but since the AsyncServiceBrowser was - not yet holding the condition it would not know to stop waiting - and process the handlers to call. - -* Relocate ServiceBrowser wait time calculation to seperate function (#484) @bdraco - - Eliminate the need to duplicate code between the ServiceBrowser - and AsyncServiceBrowser to calculate the wait time. - -* Switch from using an asyncio.Event to asyncio.Condition for waiting (#482) @bdraco - -* ServiceBrowser must recheck for handlers to call when holding condition (#477) @bdraco - - There was a short race condition window where the ServiceBrowser - could add to _handlers_to_call in the Engine thread, have the - condition notify_all called, but since the ServiceBrowser was - not yet holding the condition it would not know to stop waiting - and process the handlers to call. - -* Provide a helper function to convert milliseconds to seconds (#481) @bdraco - -* Fix AsyncServiceInfo.async_request not waiting long enough (#480) @bdraco - -* Add support for updating multiple records at once to ServiceInfo (#474) @bdraco - -* Narrow exception catch in DNSAddress.__repr__ to only expected exceptions (#473) @bdraco - -* Add test coverage to ensure ServiceInfo rejects expired records (#468) @bdraco - -* Reduce branching in service_type_name (#472) @bdraco - -* Fix flakey test_update_record (#470) @bdraco - -* Reduce branching in Zeroconf.handle_response (#467) @bdraco - * Ensure PTR questions asked in uppercase are answered (#465) @bdraco -* Clear cache between ServiceTypesQuery tests (#466) @bdraco - -* Break apart Zeroconf.handle_query to reduce branching (#462) @bdraco - * Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek -* Use constant for service type enumeration (#461) @bdraco - -* Reduce branching in Zeroconf.handle_response (#459) @bdraco - -* Reduce branching in Zeroconf.handle_query (#460) @bdraco - -* Enable pylint (#438) @bdraco - -* Trap OSError directly in Zeroconf.send instead of checking isinstance (#453) @bdraco - -* Disable protected-access on the ServiceBrowser usage of _handlers_lock (#452) @bdraco - -* Mark functions with too many branches in need of refactoring (#455) @bdraco - -* Disable pylint no-self-use check on abstract methods (#451) @bdraco - -* Use unique name in test_async_service_browser test (#450) @bdraco - -* Disable no-member check for WSAEINVAL false positive (#454) @bdraco - -* Mark methods used by asyncio without self use (#447) @bdraco - -* Extract _get_queue from zeroconf.asyncio._AsyncSender (#444) @bdraco - -* Fix redefining argument with the local name 'record' in ServiceInfo.update_record (#448) @bdraco - -* Remove unneeded-not in new_socket (#445) @bdraco - -* Disable broad except checks in places we still catch broad exceptions (#443) @bdraco - -* Merge _TYPE_CNAME and _TYPE_PTR comparison in DNSIncoming.read_others (#442) @bdraco - -* Convert unnecessary use of a comprehension to a list (#441) @bdraco - -* Remove unused now argument from ServiceInfo._process_record (#440) @bdraco - -* Disable pylint too-many-branches for functions that need refactoring (#439) @bdraco - -* Cleanup unused variables (#437) @bdraco - -* Cleanup unnecessary else after returns (#436) @bdraco - -* Add zeroconf.asyncio to the docs (#434) @bdraco - -* Fix warning when generating sphinx docs (#432) @bdraco - * Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco -* Seperate non-thread specific code from ServiceBrowser into _ServiceBrowserBase (#428) @bdraco - -* Remove is_type_unique as it is unused (#426) - -* Avoid checking the registry when answering requests for _services._dns-sd._udp.local. (#425) @bdraco - - _services._dns-sd._udp.local. is a special case and should never - be in the registry - -* Remove unused argument from ServiceInfo.dns_addresses (#423) @bdraco - -* Add methods to generate DNSRecords from ServiceInfo (#422) @bdraco - -* Seperate logic for consuming records in ServiceInfo (#421) @bdraco - -* Seperate query generation for ServiceBrowser (#420) @bdraco - -* Add async_request example with browse (#415) @bdraco - -* Add async_register_service/async_unregister_service example (#414) @bdraco - * Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco -* Add support for registering notify listeners (#409) @bdraco - * Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco -* Use a dedicated thread for sending outgoing packets with asyncio (#404) @bdraco - * Fix IPv6 setup under MacOS when binding to "" (#392) @bdraco * Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco @@ -928,17 +436,6 @@ you can likely not be concerned with the breaking changes below: the .join() was never waited for when a new Zeroconf object was created -* Simplify DNSPointer processing in ServiceBrowser (#386) @bdraco - -* Ensure the cache is checked for name conflict after final service query with asyncio (#382) @bdraco - -* Complete ServiceInfo request as soon as all questions are answered (#380) @bdraco - - Closes a small race condition where there were no questions - to ask because the cache was populated in between checks - -* Coalesce browser questions scheduled at the same time (#379) @bdraco - * Ensure duplicate packets do not trigger duplicate updates (#376) @bdraco If TXT or SRV records update was already processed and then @@ -951,12 +448,6 @@ you can likely not be concerned with the breaking changes below: * Reduce length of ServiceBrowser thread name with many types (#373) @bdraco -* Remove Callable quoting (#371) @bdraco - -* Abstract check to see if a record matches a type the ServiceBrowser wants (#369) @bdraco - -* Reduce complexity of ServiceBrowser enqueue_callback (#368) @bdraco - * Fix empty answers being added in ServiceInfo.request (#367) @bdraco * Ensure ServiceInfo populates all AAAA records (#366) @bdraco @@ -970,10 +461,6 @@ you can likely not be concerned with the breaking changes below: Move duplicate code that checked if the ServiceInfo was complete into its own function -* Remove black python 3.5 exception block (#365) @bdraco - -* Small cleanup of ServiceInfo.update_record (#364) @bdraco - * Add new cache function get_all_by_details (#363) @bdraco When working with IPv6, multiple AAAA records can exist for a given host. get_by_details would only return the @@ -982,10 +469,6 @@ you can likely not be concerned with the breaking changes below: Fix a case where the cache list can change during iteration -* Small cleanups to asyncio tests (#362) @bdraco - -* Improve test coverage for name conflicts (#357) @bdraco - * Return task objects created by AsyncZeroconf (#360) @nocarryr 0.31.0 From d31fd103cc942574f7fbc75e5346cc3d3eaf7ee1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 08:36:03 -1000 Subject: [PATCH 0548/1433] Remove extra newlines between changelog entries (#888) --- README.rst | 55 ------------------------------------------------------ 1 file changed, 55 deletions(-) diff --git a/README.rst b/README.rst index 536ddc5ac..4b39b5915 100644 --- a/README.rst +++ b/README.rst @@ -167,14 +167,12 @@ This release offers 100% line and branch coverage This change puts ServiceInfo inline with ServiceBrowser which also asks the first question as QU since ServiceInfo is commonly called from ServiceBrowser callbacks - * Limit duplicate packet suppression to 1s intervals (#841) @bdraco Only suppress duplicate packets that happen within the same second. Legitimate queriers will retry the question if they are suppressed. The limit was reduced to one second to be in line with rfc6762 - * Make multipacket known answer suppression per interface (#836) @bdraco The suppression was happening per instance of Zeroconf instead @@ -182,21 +180,18 @@ This release offers 100% line and branch coverage interfaces (usually and wifi and ethernet), this would confuse the multi-packet known answer supression since it was not expecting to get the same data more than once - * BREAKING CHANGE: Drop oversize packets before processing them (#826) @bdraco Oversized packets can quickly overwhelm the system and deny service to legitimate queriers. In practice this is usually due to broken mDNS implementations rather than malicious actors. - * BREAKING CHANGE: Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) @bdraco We now enforce a minimum TTL for PTR records to avoid ServiceBrowsers generating excessive queries refresh queries. Apple uses a 15s minimum TTL, however we do not have the same level of rate limit and safe guards so we use 1/4 of the recommended value. - * New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) @bdraco https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 @@ -207,7 +202,6 @@ This release offers 100% line and branch coverage a breaking change to increase). This reduces the amount of traffic on the network, and has the secondary advantage that most responders will answer a QU question without the typical delay answering QM questions. - * Qualify IPv6 link-local addresses with scope_id (#343) @ibygrave When a service is advertised on an IPv6 address where @@ -218,9 +212,7 @@ This release offers 100% line and branch coverage A new API `parsed_scoped_addresses()` is provided to return qualified addresses to avoid breaking compatibility on the existing parsed_addresses(). - * Skip network adapters that are disconnected (#327) @ZLJasonG - * Pass both the new and old records to async_update_records (#792) @bdraco Pass the old_record (cached) as the value and the new_record (wire) @@ -228,12 +220,10 @@ This release offers 100% line and branch coverage check the cache since we will always have the old_record when generating the async_update_records call. This avoids the overhead of multiple cache lookups for each listener. - * BREAKING CHANGE: Update internal version check to match docs (3.6+) (#491) @bdraco Python version eariler then 3.6 were likely broken with zeroconf already, however the version is now explictly checked. - * BREAKING CHANGE: RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco This allows the listener to receive all the records that have @@ -258,7 +248,6 @@ This release offers 100% line and branch coverage I/O. Before 0.32+ these functions ran in a select() loop and should not have been doing any blocking I/O, but it was not clear to implementors that I/O would block the loop. - * BREAKING CHANGE: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco When manually creating a zeroconf.Engine object, it is no longer started automatically. @@ -266,7 +255,6 @@ This release offers 100% line and branch coverage The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. - * BREAKING CHANGE: Remove DNSOutgoing.packet backwards compatibility (#569) @bdraco DNSOutgoing.packet only returned a partial message when the @@ -275,12 +263,10 @@ This release offers 100% line and branch coverage which always returns a complete payload in #248 As packet() should not be used since it will end up missing data, it has been removed - * BREAKING CHANGE: Mark DNSOutgoing write functions as protected (#633) @bdraco These functions are not intended to be used by external callers and the API is not likely to be stable in the future - * BREAKING CHANGE: Prefix cache functions that are non threadsafe with async_ (#724) @bdraco Adding (`zc.cache.add` -> `zc.cache.async_add_records`), removing (`zc.cache.remove` -> @@ -293,36 +279,26 @@ This release offers 100% line and branch coverage We never expect these functions will be called externally, however it was possible so this is documented as a breaking change. It is highly recommended that external callers do not modify the cache directly. - * TRAFFIC REDUCTION: Add support for handling QU questions (#621) @bdraco Implements RFC 6762 sec 5.4: Questions Requesting Unicast Responses datatracker.ietf.org/doc/html/rfc6762#section-5.4 - * TRAFFIC REDUCTION: Protect the network against excessive packet flooding (#619) @bdraco - * TRAFFIC REDUCTION: Suppress additionals when they are already in the answers section (#617) @bdraco - * TRAFFIC REDUCTION: Avoid including additionals when the answer is suppressed by known-answer supression (#614) @bdraco - * TRAFFIC REDUCTION: Implement multi-packet known answer supression (#687) @bdraco Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 - * TRAFFIC REDUCTION: Efficiently bucket queries with known answers (#698) @bdraco - * TRAFFIC REDUCTION: Implement duplicate question supression (#770) @bdraco http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 - * MAJOR BUG: Ensure matching PTR queries are returned with the ANY query (#618) @bdraco - * MAJOR BUG: Fix lookup of uppercase names in registry (#597) @bdraco If the ServiceInfo was registered with an uppercase name and the query was for a lowercase name, it would not be found and vice-versa. - * MAJOR BUG: Ensure unicast responses can be sent to any source port (#598) @bdraco Unicast responses were only being sent if the source port @@ -331,22 +307,18 @@ This release offers 100% line and branch coverage dig -p 5353 @224.0.0.251 media-12.local The above query will now see a response - * MAJOR BUG: Fix queries for AAAA records (#616) @bdraco - * Remove second level caching from ServiceBrowsers (#737) @bdraco The ServiceBrowser had its own cache of the last time it saw a service which was reimplementing the DNSCache and presenting a source of truth problem that lead to unexpected queries when the two disagreed. - * Fix server cache to be case-insensitive (#731) @bdraco If the server name had uppercase chars and any of the matching records were lowercase, the server would not be found - * Fix cache handling of records with different TTLs (#729) @bdraco There should only be one unique record in the cache at @@ -358,13 +330,11 @@ This release offers 100% line and branch coverage to ensure that the newest record always replaces the same unique record and we never have a source of truth problem determining the TTL of a record from the cache. - * Fix ServiceInfo with multiple A records (#725) @bdraco If there were multiple A records for the host, ServiceInfo would always return the last one that was in the incoming packet which was usually not the one that was wanted. - * Set stale unique records to expire 1s in the future instead of instant removal (#706) @bdraco tools.ietf.org/html/rfc6762#section-10.2 @@ -376,17 +346,11 @@ This release offers 100% line and branch coverage incorrectly sends goodbye packets for its records, it gives the other cooperating responders one second to send out their own response to "rescue" the records before they expire and are deleted. - * Suppress additionals when answer is suppressed (#690) @bdraco - * Allow unregistering a service multiple times (#679) @bdraco - * Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.asyncio (#658) @bdraco - * Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) @bdraco - * Add async_unregister_all_services to AsyncZeroconf (#649) @bdraco - * Ensure services are removed from the registry when calling unregister_all_services (#644) @bdraco There was a race condition where a query could be answered for a service @@ -394,18 +358,14 @@ This release offers 100% line and branch coverage being broadcast after the goodbye if a query came in at just the right time. To avoid this, we now remove the services from the registry right after we generate the goodbye packet - * Ensure zeroconf can be loaded when the system disables IPv6 (#624) @bdraco - * Ensure the QU bit is set for probe queries (#609) @bdraco The bit should be set per datatracker.ietf.org/doc/html/rfc6762#section-8.1 * Breaking change: Update python compatibility as PyPy3 7.2 is required (#523) @bdraco - * Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco - * Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco Ensure that questions that exceed the max packet size are @@ -416,40 +376,27 @@ This release offers 100% line and branch coverage Ensure only one resource record is sent when a record exceeds _MAX_MSG_TYPICAL datatracker.ietf.org/doc/html/rfc6762#section-17 - * Ensure PTR questions asked in uppercase are answered (#465) @bdraco - * Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek - * Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco - * Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco - * Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco - * Fix IPv6 setup under MacOS when binding to "" (#392) @bdraco - * Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco There was a short window where the ServiceBrowser thread could be left running after Zeroconf is closed because the .join() was never waited for when a new Zeroconf object was created - * Ensure duplicate packets do not trigger duplicate updates (#376) @bdraco If TXT or SRV records update was already processed and then recieved again, it was possible for a second update to be called back in the ServiceBrowser - * Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) @bdraco - * Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco - * Reduce length of ServiceBrowser thread name with many types (#373) @bdraco - * Fix empty answers being added in ServiceInfo.request (#367) @bdraco - * Ensure ServiceInfo populates all AAAA records (#366) @bdraco Use get_all_by_details to ensure all records are loaded @@ -460,7 +407,6 @@ This release offers 100% line and branch coverage Move duplicate code that checked if the ServiceInfo was complete into its own function - * Add new cache function get_all_by_details (#363) @bdraco When working with IPv6, multiple AAAA records can exist for a given host. get_by_details would only return the @@ -468,7 +414,6 @@ This release offers 100% line and branch coverage Fix a case where the cache list can change during iteration - * Return task objects created by AsyncZeroconf (#360) @nocarryr 0.31.0 From 9abb40cf331bc0acc5fdbb03fce5c958cec8b41e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 09:13:05 -1000 Subject: [PATCH 0549/1433] Reformat backwards incompatible changes to match previous versions (#889) --- README.rst | 122 ++++++++++++++++++++++++----------------------------- 1 file changed, 56 insertions(+), 66 deletions(-) diff --git a/README.rst b/README.rst index 4b39b5915..44a370f4c 100644 --- a/README.rst +++ b/README.rst @@ -143,10 +143,6 @@ Changelog 0.32.0 (Unreleased) =================== -Documentation for breaking changes era on the side of the caution and likely -overstates the risk on many of these. If you are not accessing zeroconf internals, -you can likely not be concerned with the breaking changes below: - This release offers 100% line and branch coverage * Make ServiceInfo first question QU (#852) @bdraco @@ -180,18 +176,6 @@ This release offers 100% line and branch coverage interfaces (usually and wifi and ethernet), this would confuse the multi-packet known answer supression since it was not expecting to get the same data more than once -* BREAKING CHANGE: Drop oversize packets before processing them (#826) @bdraco - - Oversized packets can quickly overwhelm the system and deny - service to legitimate queriers. In practice this is usually - due to broken mDNS implementations rather than malicious - actors. -* BREAKING CHANGE: Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) @bdraco - - We now enforce a minimum TTL for PTR records to avoid - ServiceBrowsers generating excessive queries refresh queries. - Apple uses a 15s minimum TTL, however we do not have the same - level of rate limit and safe guards so we use 1/4 of the recommended value. * New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) @bdraco https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 @@ -213,61 +197,14 @@ This release offers 100% line and branch coverage return qualified addresses to avoid breaking compatibility on the existing parsed_addresses(). * Skip network adapters that are disconnected (#327) @ZLJasonG -* Pass both the new and old records to async_update_records (#792) @bdraco - - Pass the old_record (cached) as the value and the new_record (wire) - to async_update_records instead of forcing each consumer to - check the cache since we will always have the old_record - when generating the async_update_records call. This avoids - the overhead of multiple cache lookups for each listener. -* BREAKING CHANGE: Update internal version check to match docs (3.6+) (#491) @bdraco - - Python version eariler then 3.6 were likely broken with zeroconf - already, however the version is now explictly checked. -* BREAKING CHANGE: RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco - - This allows the listener to receive all the records that have - been updated in a single transaction such as a packet or - cache expiry. - - update_record has been deprecated in favor of async_update_records - A compatibility shim exists to ensure classes that use - RecordUpdateListener as a base class continue to have - update_record called, however they should be updated - as soon as possible. - - A new method async_update_records_complete is now called on each - listener when all listeners have completed processing updates - and the cache has been updated. This allows ServiceBrowsers - to delay calling handlers until they are sure the cache - has been updated as its a common pattern to call for - ServiceInfo when a ServiceBrowser handler fires. - - The async_ prefix was choosen to make it clear that these - functions run in the eventloop and should never do blocking - I/O. Before 0.32+ these functions ran in a select() loop and - should not have been doing any blocking I/O, but it was not - clear to implementors that I/O would block the loop. -* BREAKING CHANGE: Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco +* Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco When manually creating a zeroconf.Engine object, it is no longer started automatically. It must manually be started by calling .start() on the created object. The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. -* BREAKING CHANGE: Remove DNSOutgoing.packet backwards compatibility (#569) @bdraco - - DNSOutgoing.packet only returned a partial message when the - DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL - This was a legacy function that was replaced with .packets() - which always returns a complete payload in #248 As packet() - should not be used since it will end up missing data, it has - been removed -* BREAKING CHANGE: Mark DNSOutgoing write functions as protected (#633) @bdraco - - These functions are not intended to be used by external - callers and the API is not likely to be stable in the future -* BREAKING CHANGE: Prefix cache functions that are non threadsafe with async_ (#724) @bdraco +* Prefix cache functions that are non threadsafe with async_ (#724) @bdraco Adding (`zc.cache.add` -> `zc.cache.async_add_records`), removing (`zc.cache.remove` -> `zc.cache.async_remove_records`), and expiring the cache (`zc.cache.expire` -> @@ -364,7 +301,6 @@ This release offers 100% line and branch coverage The bit should be set per datatracker.ietf.org/doc/html/rfc6762#section-8.1 -* Breaking change: Update python compatibility as PyPy3 7.2 is required (#523) @bdraco * Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco * Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco @@ -416,6 +352,60 @@ This release offers 100% line and branch coverage iteration * Return task objects created by AsyncZeroconf (#360) @nocarryr +Technically backwards incompatible: + +* Update internal version check to match docs (3.6+) (#491) @bdraco + + Python version eariler then 3.6 were likely broken with zeroconf + already, however the version is now explictly checked. +* Update python compatibility as PyPy3 7.2 is required (#523) @bdraco + +Backwards incompatible: + +* Drop oversize packets before processing them (#826) @bdraco + + Oversized packets can quickly overwhelm the system and deny + service to legitimate queriers. In practice this is usually + due to broken mDNS implementations rather than malicious + actors. +* Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) @bdraco + + We now enforce a minimum TTL for PTR records to avoid + ServiceBrowsers generating excessive queries refresh queries. + Apple uses a 15s minimum TTL, however we do not have the same + level of rate limit and safe guards so we use 1/4 of the recommended value. +* RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco + + This allows the listener to receive all the records that have + been updated in a single transaction such as a packet or + cache expiry. + + update_record has been deprecated in favor of async_update_records + A compatibility shim exists to ensure classes that use + RecordUpdateListener as a base class continue to have + update_record called, however they should be updated + as soon as possible. + + A new method async_update_records_complete is now called on each + listener when all listeners have completed processing updates + and the cache has been updated. This allows ServiceBrowsers + to delay calling handlers until they are sure the cache + has been updated as its a common pattern to call for + ServiceInfo when a ServiceBrowser handler fires. + + The async_ prefix was choosen to make it clear that these + functions run in the eventloop and should never do blocking + I/O. Before 0.32+ these functions ran in a select() loop and + should not have been doing any blocking I/O, but it was not + clear to implementors that I/O would block the loop. +* Pass both the new and old records to async_update_records (#792) @bdraco + + Pass the old_record (cached) as the value and the new_record (wire) + to async_update_records instead of forcing each consumer to + check the cache since we will always have the old_record + when generating the async_update_records call. This avoids + the overhead of multiple cache lookups for each listener. + 0.31.0 ====== From 0d911568d367f1520acb19bdf830fe188b6ffb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 09:25:48 -1000 Subject: [PATCH 0550/1433] Rewrite 0.32.0 changelog in past tense (#890) --- README.rst | 111 +++++++++++++++++++++++------------------------------ 1 file changed, 47 insertions(+), 64 deletions(-) diff --git a/README.rst b/README.rst index 44a370f4c..288c6358f 100644 --- a/README.rst +++ b/README.rst @@ -145,7 +145,7 @@ Changelog This release offers 100% line and branch coverage -* Make ServiceInfo first question QU (#852) @bdraco +* Made ServiceInfo first question QU (#852) @bdraco We want an immediate response when making a request with ServiceInfo by asking a QU question, most responders will not delay the response @@ -163,13 +163,13 @@ This release offers 100% line and branch coverage This change puts ServiceInfo inline with ServiceBrowser which also asks the first question as QU since ServiceInfo is commonly called from ServiceBrowser callbacks -* Limit duplicate packet suppression to 1s intervals (#841) @bdraco +* Limited duplicate packet suppression to 1s intervals (#841) @bdraco Only suppress duplicate packets that happen within the same second. Legitimate queriers will retry the question if they are suppressed. The limit was reduced to one second to be in line with rfc6762 -* Make multipacket known answer suppression per interface (#836) @bdraco +* Made multipacket known answer suppression per interface (#836) @bdraco The suppression was happening per instance of Zeroconf instead of per interface. Since the same network can be seen on multiple @@ -186,7 +186,7 @@ This release offers 100% line and branch coverage a breaking change to increase). This reduces the amount of traffic on the network, and has the secondary advantage that most responders will answer a QU question without the typical delay answering QM questions. -* Qualify IPv6 link-local addresses with scope_id (#343) @ibygrave +* IPv6 link-local addresses are now qualified with scope_id (#343) @ibygrave When a service is advertised on an IPv6 address where the scope is link local, i.e. fe80::/64 (see RFC 4007) @@ -196,47 +196,35 @@ This release offers 100% line and branch coverage A new API `parsed_scoped_addresses()` is provided to return qualified addresses to avoid breaking compatibility on the existing parsed_addresses(). -* Skip network adapters that are disconnected (#327) @ZLJasonG -* Ensure listeners do not miss initial packets if Engine starts too quickly (#387) @bdraco +* Network adapters that are disconnected are now skipped (#327) @ZLJasonG +* Fixed listeners missing initial packets if Engine starts too quickly (#387) @bdraco When manually creating a zeroconf.Engine object, it is no longer started automatically. It must manually be started by calling .start() on the created object. The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. -* Prefix cache functions that are non threadsafe with async_ (#724) @bdraco - - Adding (`zc.cache.add` -> `zc.cache.async_add_records`), removing (`zc.cache.remove` -> - `zc.cache.async_remove_records`), and expiring the cache (`zc.cache.expire` -> - `zc.cache.async_expire`) the cache is not threadsafe and must be called from the - event loop (previously the Engine select loop before 0.32) - - These functions should only be run from the event loop as they are NOT thread safe. - - We never expect these functions will be called externally, however it was possible so this - is documented as a breaking change. It is highly recommended that external callers do not - modify the cache directly. -* TRAFFIC REDUCTION: Add support for handling QU questions (#621) @bdraco +* TRAFFIC REDUCTION: Added support for handling QU questions (#621) @bdraco Implements RFC 6762 sec 5.4: Questions Requesting Unicast Responses datatracker.ietf.org/doc/html/rfc6762#section-5.4 -* TRAFFIC REDUCTION: Protect the network against excessive packet flooding (#619) @bdraco -* TRAFFIC REDUCTION: Suppress additionals when they are already in the answers section (#617) @bdraco -* TRAFFIC REDUCTION: Avoid including additionals when the answer is suppressed by known-answer supression (#614) @bdraco -* TRAFFIC REDUCTION: Implement multi-packet known answer supression (#687) @bdraco +* TRAFFIC REDUCTION: Implemented protect the network against excessive packet flooding (#619) @bdraco +* TRAFFIC REDUCTION: Additionals are now suppressed when they are already in the answers section (#617) @bdraco +* TRAFFIC REDUCTION: Additionals are no longer included when the answer is suppressed by known-answer supression (#614) @bdraco +* TRAFFIC REDUCTION: Implemented multi-packet known answer supression (#687) @bdraco Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 -* TRAFFIC REDUCTION: Efficiently bucket queries with known answers (#698) @bdraco -* TRAFFIC REDUCTION: Implement duplicate question supression (#770) @bdraco +* TRAFFIC REDUCTION: Implemented efficent bucketing of queries with known answers (#698) @bdraco +* TRAFFIC REDUCTION: Implemented duplicate question supression (#770) @bdraco http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 -* MAJOR BUG: Ensure matching PTR queries are returned with the ANY query (#618) @bdraco -* MAJOR BUG: Fix lookup of uppercase names in registry (#597) @bdraco +* MAJOR BUG: Fixed answering matching PTR queries with the ANY query (#618) @bdraco +* MAJOR BUG: Fixed lookup of uppercase names in registry (#597) @bdraco If the ServiceInfo was registered with an uppercase name and the query was for a lowercase name, it would not be found and vice-versa. -* MAJOR BUG: Ensure unicast responses can be sent to any source port (#598) @bdraco +* MAJOR BUG: Fixed unicast responses from any source port (#598) @bdraco Unicast responses were only being sent if the source port was 53, this prevented responses when testing with dig: @@ -244,19 +232,19 @@ This release offers 100% line and branch coverage dig -p 5353 @224.0.0.251 media-12.local The above query will now see a response -* MAJOR BUG: Fix queries for AAAA records (#616) @bdraco -* Remove second level caching from ServiceBrowsers (#737) @bdraco +* MAJOR BUG: Fixed queries for AAAA records not being answered (#616) @bdraco +* Removed second level caching from ServiceBrowsers (#737) @bdraco The ServiceBrowser had its own cache of the last time it saw a service which was reimplementing the DNSCache and presenting a source of truth problem that lead to unexpected queries when the two disagreed. -* Fix server cache to be case-insensitive (#731) @bdraco +* Fixed server cache not being case-insensitive (#731) @bdraco If the server name had uppercase chars and any of the matching records were lowercase, the server would not be found -* Fix cache handling of records with different TTLs (#729) @bdraco +* Fixed cache handling of records with different TTLs (#729) @bdraco There should only be one unique record in the cache at a time as having multiple unique records will different @@ -267,12 +255,14 @@ This release offers 100% line and branch coverage to ensure that the newest record always replaces the same unique record and we never have a source of truth problem determining the TTL of a record from the cache. -* Fix ServiceInfo with multiple A records (#725) @bdraco +* Fixed ServiceInfo with multiple A records (#725) @bdraco If there were multiple A records for the host, ServiceInfo would always return the last one that was in the incoming packet which was usually not the one that was wanted. -* Set stale unique records to expire 1s in the future instead of instant removal (#706) @bdraco +* Fixed stale unique records expiring too quickly (#706) @bdraco + + Recods now expire 1s in the future instead of instant removal. tools.ietf.org/html/rfc6762#section-10.2 Queriers receiving a Multicast DNS response with a TTL of zero SHOULD @@ -283,26 +273,25 @@ This release offers 100% line and branch coverage incorrectly sends goodbye packets for its records, it gives the other cooperating responders one second to send out their own response to "rescue" the records before they expire and are deleted. -* Suppress additionals when answer is suppressed (#690) @bdraco -* Allow unregistering a service multiple times (#679) @bdraco -* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.asyncio (#658) @bdraco -* Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) @bdraco -* Add async_unregister_all_services to AsyncZeroconf (#649) @bdraco -* Ensure services are removed from the registry when calling unregister_all_services (#644) @bdraco +* Fixed exception when unregistering a service multiple times (#679) @bdraco +* Added an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.asyncio (#658) @bdraco +* Fixed interface_index_to_ip6_address not skiping ipv4 adapters (#651) @bdraco +* Added async_unregister_all_services to AsyncZeroconf (#649) @bdraco +* Fixed services not being removed from the registry when calling unregister_all_services (#644) @bdraco There was a race condition where a query could be answered for a service in the registry while goodbye packets which could result a fresh record being broadcast after the goodbye if a query came in at just the right time. To avoid this, we now remove the services from the registry right after we generate the goodbye packet -* Ensure zeroconf can be loaded when the system disables IPv6 (#624) @bdraco -* Ensure the QU bit is set for probe queries (#609) @bdraco +* Fixed zeroconf exception on load when the system disables IPv6 (#624) @bdraco +* Fixed the QU bit missing from for probe queries (#609) @bdraco The bit should be set per datatracker.ietf.org/doc/html/rfc6762#section-8.1 -* Set the TC bit for query packets where the known answers span multiple packets (#494) @bdraco -* Ensure packets are properly seperated when exceeding maximum size (#498) @bdraco +* Fixed the TC bit mising for query packets where the known answers span multiple packets (#494) @bdraco +* Fixed packets not being properly seperated when exceeding maximum size (#498) @bdraco Ensure that questions that exceed the max packet size are moved to the next packet. This fixes DNSQuestions being @@ -312,28 +301,28 @@ This release offers 100% line and branch coverage Ensure only one resource record is sent when a record exceeds _MAX_MSG_TYPICAL datatracker.ietf.org/doc/html/rfc6762#section-17 -* Ensure PTR questions asked in uppercase are answered (#465) @bdraco -* Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek -* Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco -* Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco -* Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco -* Fix IPv6 setup under MacOS when binding to "" (#392) @bdraco -* Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) @bdraco +* Fixed PTR questions asked in uppercase not being answered (#465) @bdraco +* Added Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek +* Implemented an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco +* Added async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco +* Implemented allowing passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco +* Fixed IPv6 setup under MacOS when binding to "" (#392) @bdraco +* Fixed ZeroconfServiceTypes.find not always cancels the ServiceBrowser (#389) @bdraco There was a short window where the ServiceBrowser thread could be left running after Zeroconf is closed because the .join() was never waited for when a new Zeroconf object was created -* Ensure duplicate packets do not trigger duplicate updates (#376) @bdraco +* Fixed duplicate packets triggering duplicate updates (#376) @bdraco If TXT or SRV records update was already processed and then recieved again, it was possible for a second update to be called back in the ServiceBrowser -* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) @bdraco -* Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco -* Reduce length of ServiceBrowser thread name with many types (#373) @bdraco -* Fix empty answers being added in ServiceInfo.request (#367) @bdraco -* Ensure ServiceInfo populates all AAAA records (#366) @bdraco +* Fixed ServiceStateChange.Updated event happening for IPs that already existed (#375) @bdraco +* Fixed RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco +* Reduced length of ServiceBrowser thread name with many types (#373) @bdraco +* Fixed empty answers being added in ServiceInfo.request (#367) @bdraco +* Fixed ServiceInfo not populating all AAAA records (#366) @bdraco Use get_all_by_details to ensure all records are loaded into addresses. @@ -343,13 +332,7 @@ This release offers 100% line and branch coverage Move duplicate code that checked if the ServiceInfo was complete into its own function -* Add new cache function get_all_by_details (#363) @bdraco - When working with IPv6, multiple AAAA records can exist - for a given host. get_by_details would only return the - latest record in the cache. - - Fix a case where the cache list can change during - iteration +* Fixed a case where the cache list can change during iteration (#363) @bdraco * Return task objects created by AsyncZeroconf (#360) @nocarryr Technically backwards incompatible: From ba235dd8bc65de4f461f76fd2bf4647844437e1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 09:34:03 -1000 Subject: [PATCH 0551/1433] Fix spelling and grammar errors in 0.32.0 changelog (#891) --- README.rst | 59 +++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/README.rst b/README.rst index 288c6358f..8f8a36a62 100644 --- a/README.rst +++ b/README.rst @@ -143,22 +143,22 @@ Changelog 0.32.0 (Unreleased) =================== -This release offers 100% line and branch coverage +This release offers 100% line and branch coverage. * Made ServiceInfo first question QU (#852) @bdraco - We want an immediate response when making a request with ServiceInfo - by asking a QU question, most responders will not delay the response + We want an immediate response when requesting with ServiceInfo + by asking a QU question; most responders will not delay the response and respond right away to our question. This also improves compatibility with split networks as we may not have been able to see the response - otherwise. If the responder has not multicast the record recently + otherwise. If the responder has not multicast the record recently, it may still choose to do so in addition to responding via unicast Reduces traffic when there are multiple zeroconf instances running on the network running ServiceBrowsers If we don't get an answer on the first try, we ask a QM question - in the event we can't receive a unicast response for some reason + in the event, we can't receive a unicast response for some reason This change puts ServiceInfo inline with ServiceBrowser which also asks the first question as QU since ServiceInfo is commonly @@ -211,16 +211,16 @@ This release offers 100% line and branch coverage datatracker.ietf.org/doc/html/rfc6762#section-5.4 * TRAFFIC REDUCTION: Implemented protect the network against excessive packet flooding (#619) @bdraco * TRAFFIC REDUCTION: Additionals are now suppressed when they are already in the answers section (#617) @bdraco -* TRAFFIC REDUCTION: Additionals are no longer included when the answer is suppressed by known-answer supression (#614) @bdraco +* TRAFFIC REDUCTION: Additionals are no longer included when the answer is suppressed by known-answer suppression (#614) @bdraco * TRAFFIC REDUCTION: Implemented multi-packet known answer supression (#687) @bdraco Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 -* TRAFFIC REDUCTION: Implemented efficent bucketing of queries with known answers (#698) @bdraco -* TRAFFIC REDUCTION: Implemented duplicate question supression (#770) @bdraco +* TRAFFIC REDUCTION: Implemented efficient bucketing of queries with known answers (#698) @bdraco +* TRAFFIC REDUCTION: Implemented duplicate question suppression (#770) @bdraco http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 * MAJOR BUG: Fixed answering matching PTR queries with the ANY query (#618) @bdraco -* MAJOR BUG: Fixed lookup of uppercase names in registry (#597) @bdraco +* MAJOR BUG: Fixed lookup of uppercase names in the registry (#597) @bdraco If the ServiceInfo was registered with an uppercase name and the query was for a lowercase name, it would not be found and vice-versa. @@ -236,13 +236,13 @@ This release offers 100% line and branch coverage * Removed second level caching from ServiceBrowsers (#737) @bdraco The ServiceBrowser had its own cache of the last time it - saw a service which was reimplementing the DNSCache and + saw a service that was reimplementing the DNSCache and presenting a source of truth problem that lead to unexpected queries when the two disagreed. * Fixed server cache not being case-insensitive (#731) @bdraco If the server name had uppercase chars and any of the - matching records were lowercase, the server would not be + matching records were lowercase, and the server would not be found * Fixed cache handling of records with different TTLs (#729) @bdraco @@ -251,18 +251,18 @@ This release offers 100% line and branch coverage TTLs in the cache can result in unexpected behavior since some functions returned all matching records and some fetched from the right side of the list to return the - newest record. Intead we now store the records in a dict + newest record. Instead we now store the records in a dict to ensure that the newest record always replaces the same - unique record and we never have a source of truth problem + unique record, and we never have a source of truth problem determining the TTL of a record from the cache. * Fixed ServiceInfo with multiple A records (#725) @bdraco If there were multiple A records for the host, ServiceInfo would always return the last one that was in the incoming - packet which was usually not the one that was wanted. + packet, which was usually not the one that was wanted. * Fixed stale unique records expiring too quickly (#706) @bdraco - Recods now expire 1s in the future instead of instant removal. + Records now expire 1s in the future instead of instant removal. tools.ietf.org/html/rfc6762#section-10.2 Queriers receiving a Multicast DNS response with a TTL of zero SHOULD @@ -280,7 +280,7 @@ This release offers 100% line and branch coverage * Fixed services not being removed from the registry when calling unregister_all_services (#644) @bdraco There was a race condition where a query could be answered for a service - in the registry while goodbye packets which could result a fresh record + in the registry, while goodbye packets which could result in a fresh record being broadcast after the goodbye if a query came in at just the right time. To avoid this, we now remove the services from the registry right after we generate the goodbye packet @@ -290,8 +290,8 @@ This release offers 100% line and branch coverage The bit should be set per datatracker.ietf.org/doc/html/rfc6762#section-8.1 -* Fixed the TC bit mising for query packets where the known answers span multiple packets (#494) @bdraco -* Fixed packets not being properly seperated when exceeding maximum size (#498) @bdraco +* Fixed the TC bit missing for query packets where the known answers span multiple packets (#494) @bdraco +* Fixed packets not being properly separated when exceeding maximum size (#498) @bdraco Ensure that questions that exceed the max packet size are moved to the next packet. This fixes DNSQuestions being @@ -316,7 +316,7 @@ This release offers 100% line and branch coverage * Fixed duplicate packets triggering duplicate updates (#376) @bdraco If TXT or SRV records update was already processed and then - recieved again, it was possible for a second update to be + received again, it was possible for a second update to be called back in the ServiceBrowser * Fixed ServiceStateChange.Updated event happening for IPs that already existed (#375) @bdraco * Fixed RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco @@ -327,7 +327,7 @@ This release offers 100% line and branch coverage Use get_all_by_details to ensure all records are loaded into addresses. - Only load A/AAAA records from cache once in load_from_cache + Only load A/AAAA records from the cache once in load_from_cache if there is a SRV record present Move duplicate code that checked if the ServiceInfo was complete @@ -339,8 +339,8 @@ Technically backwards incompatible: * Update internal version check to match docs (3.6+) (#491) @bdraco - Python version eariler then 3.6 were likely broken with zeroconf - already, however the version is now explictly checked. + Python version earlier then 3.6 were likely broken with zeroconf + already, however, the version is now explicitly checked. * Update python compatibility as PyPy3 7.2 is required (#523) @bdraco Backwards incompatible: @@ -348,15 +348,14 @@ Backwards incompatible: * Drop oversize packets before processing them (#826) @bdraco Oversized packets can quickly overwhelm the system and deny - service to legitimate queriers. In practice this is usually - due to broken mDNS implementations rather than malicious - actors. -* Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) @bdraco + service to legitimate queriers. In practice, this is usually due to broken mDNS + implementations rather than malicious actors. +* Guard against excessive ServiceBrowser queries from PTR records significantly lowerthan recommended (#824) @bdraco We now enforce a minimum TTL for PTR records to avoid ServiceBrowsers generating excessive queries refresh queries. - Apple uses a 15s minimum TTL, however we do not have the same - level of rate limit and safe guards so we use 1/4 of the recommended value. + Apple uses a 15s minimum TTL, however, we do not have the same + level of rate limit and safeguards, so we use 1/4 of the recommended value. * RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco This allows the listener to receive all the records that have @@ -366,7 +365,7 @@ Backwards incompatible: update_record has been deprecated in favor of async_update_records A compatibility shim exists to ensure classes that use RecordUpdateListener as a base class continue to have - update_record called, however they should be updated + update_record called, however, they should be updated as soon as possible. A new method async_update_records_complete is now called on each @@ -376,7 +375,7 @@ Backwards incompatible: has been updated as its a common pattern to call for ServiceInfo when a ServiceBrowser handler fires. - The async_ prefix was choosen to make it clear that these + The async_ prefix was chosen to make it clear that these functions run in the eventloop and should never do blocking I/O. Before 0.32+ these functions ran in a select() loop and should not have been doing any blocking I/O, but it was not From 34f6e498dec18b84dab1c27c75348916bceef8e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jun 2021 09:37:34 -1000 Subject: [PATCH 0552/1433] Reformat changelog to match prior versions (#892) --- README.rst | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 8f8a36a62..54e20e278 100644 --- a/README.rst +++ b/README.rst @@ -204,27 +204,12 @@ This release offers 100% line and branch coverage. The Engine thread is now started after all the listeners have been added to avoid a race condition where packets could be missed at startup. -* TRAFFIC REDUCTION: Added support for handling QU questions (#621) @bdraco - - Implements RFC 6762 sec 5.4: - Questions Requesting Unicast Responses - datatracker.ietf.org/doc/html/rfc6762#section-5.4 -* TRAFFIC REDUCTION: Implemented protect the network against excessive packet flooding (#619) @bdraco -* TRAFFIC REDUCTION: Additionals are now suppressed when they are already in the answers section (#617) @bdraco -* TRAFFIC REDUCTION: Additionals are no longer included when the answer is suppressed by known-answer suppression (#614) @bdraco -* TRAFFIC REDUCTION: Implemented multi-packet known answer supression (#687) @bdraco - - Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 -* TRAFFIC REDUCTION: Implemented efficient bucketing of queries with known answers (#698) @bdraco -* TRAFFIC REDUCTION: Implemented duplicate question suppression (#770) @bdraco - - http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 -* MAJOR BUG: Fixed answering matching PTR queries with the ANY query (#618) @bdraco -* MAJOR BUG: Fixed lookup of uppercase names in the registry (#597) @bdraco +* Fixed answering matching PTR queries with the ANY query (#618) @bdraco +* Fixed lookup of uppercase names in the registry (#597) @bdraco If the ServiceInfo was registered with an uppercase name and the query was for a lowercase name, it would not be found and vice-versa. -* MAJOR BUG: Fixed unicast responses from any source port (#598) @bdraco +* Fixed unicast responses from any source port (#598) @bdraco Unicast responses were only being sent if the source port was 53, this prevented responses when testing with dig: @@ -232,7 +217,7 @@ This release offers 100% line and branch coverage. dig -p 5353 @224.0.0.251 media-12.local The above query will now see a response -* MAJOR BUG: Fixed queries for AAAA records not being answered (#616) @bdraco +* Fixed queries for AAAA records not being answered (#616) @bdraco * Removed second level caching from ServiceBrowsers (#737) @bdraco The ServiceBrowser had its own cache of the last time it @@ -335,6 +320,24 @@ This release offers 100% line and branch coverage. * Fixed a case where the cache list can change during iteration (#363) @bdraco * Return task objects created by AsyncZeroconf (#360) @nocarryr +Traffic Reduction: + +* Added support for handling QU questions (#621) @bdraco + + Implements RFC 6762 sec 5.4: + Questions Requesting Unicast Responses + datatracker.ietf.org/doc/html/rfc6762#section-5.4 +* Implemented protect the network against excessive packet flooding (#619) @bdraco +* Additionals are now suppressed when they are already in the answers section (#617) @bdraco +* Additionals are no longer included when the answer is suppressed by known-answer suppression (#614) @bdraco +* Implemented multi-packet known answer supression (#687) @bdraco + + Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 +* Implemented efficient bucketing of queries with known answers (#698) @bdraco +* Implemented duplicate question suppression (#770) @bdraco + + http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + Technically backwards incompatible: * Update internal version check to match docs (3.6+) (#491) @bdraco From ea7bc8592e418332e5b9973007698d3cd79754d9 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 30 Jun 2021 03:23:58 +0200 Subject: [PATCH 0553/1433] Release version 0.32.0 --- README.rst | 4 ++-- zeroconf/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 54e20e278..af33baade 100644 --- a/README.rst +++ b/README.rst @@ -140,8 +140,8 @@ See examples directory for more. Changelog ========= -0.32.0 (Unreleased) -=================== +0.32.0 +====== This release offers 100% line and branch coverage. diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e3bb987fd..b39d7436e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.31.0' +__version__ = '0.32.0' __license__ = 'LGPL' From 82ff150e0a72a7e20823a0c805f48f117bf1e274 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Wed, 30 Jun 2021 03:29:06 +0200 Subject: [PATCH 0554/1433] Fix readme formatting It wasn't proper reStructuredText before: % twine check dist/* Checking dist/zeroconf-0.32.0-py3-none-any.whl: FAILED `long_description` has syntax errors in markup and would not be rendered on PyPI. line 381: Error: Unknown target name: "async". warning: `long_description_content_type` missing. defaulting to `text/x-rst`. Checking dist/zeroconf-0.32.0.tar.gz: FAILED `long_description` has syntax errors in markup and would not be rendered on PyPI. line 381: Error: Unknown target name: "async". warning: `long_description_content_type` missing. defaulting to `text/x-rst`. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index af33baade..2b38fa7a9 100644 --- a/README.rst +++ b/README.rst @@ -378,7 +378,7 @@ Backwards incompatible: has been updated as its a common pattern to call for ServiceInfo when a ServiceBrowser handler fires. - The async_ prefix was chosen to make it clear that these + The async\_ prefix was chosen to make it clear that these functions run in the eventloop and should never do blocking I/O. Before 0.32+ these functions ran in a select() loop and should not have been doing any blocking I/O, but it was not From 90bc8ca8dce1af26ea81c5d6ecb17cf6ea664a71 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Sun, 4 Jul 2021 15:15:22 +0100 Subject: [PATCH 0555/1433] Add test for running sync code within executor (#894) --- tests/test_asyncio.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 759ab5b33..2e504b68c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -101,6 +101,17 @@ async def test_async_with_sync_passed_in_closed_in_async() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_sync_within_event_loop_executor() -> None: + """Test sync version still works from an executor within an event loop.""" + def sync_code(): + zc = Zeroconf(interfaces=['127.0.0.1']) + assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None + zc.close() + + await asyncio.get_event_loop().run_in_executor(None, sync_code) + + @pytest.mark.asyncio async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" From 56c7d692d67b7f56c386a7f1f4e45ebfc4e8366a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jul 2021 09:16:02 -0500 Subject: [PATCH 0556/1433] Increase timeout in ServiceInfo.request to handle loaded systems (#895) It can take a few seconds for a loaded system to run the `async_request` coroutine when the event loop is busy or the system is CPU bound (example being Home Assistant startup). We now add an additional `_LOADED_SYSTEM_TIMEOUT` (10s) to the `run_coroutine_threadsafe` calls to ensure the coroutine has the total amount of time to run up to its internal timeout (default of 3000ms). Ten seconds is a bit large of a timeout; however, its only unused in cases where we wrap other timeouts. We now expect the only instance the `run_coroutine_threadsafe` result timeout will happen in a production circumstance is when someone is running a `ServiceInfo.request()` in a thread and another thread calls `Zeroconf.close()` at just the right moment that the future is never completed unless the system is so loaded that it is nearly unresponsive. The timeout for `run_coroutine_threadsafe` is the maximum time a thread can cleanly shut down when zeroconf is closed out in another thread, which should always be longer than the underlying thread operation. --- tests/services/test_info.py | 12 ++++++++++++ tests/test_asyncio.py | 14 ++++++++++++++ tests/utils/test_asyncio.py | 17 ++++++++++++++++- zeroconf/_core.py | 5 ++++- zeroconf/_services/info.py | 3 ++- zeroconf/_utils/asyncio.py | 4 ++-- zeroconf/const.py | 6 ++++++ 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 02ba581e4..8ac8beda7 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -739,3 +739,15 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) assert first_outgoing.questions[0].unicast == False zeroconf.close() + + +def test_request_timeout(): + """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + start_time = r.current_time_millis() + assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") is None + end_time = r.current_time_millis() + zeroconf.close() + # 3000ms for the default timeout + # 1000ms for loaded systems + schedule overhead + assert (end_time - start_time) < 3000 + 1000 diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 2e504b68c..f4722389d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -935,3 +935,17 @@ def update_service(self, zc, type_, name) -> None: ('add', type_, registration_name), ] await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_request_timeout(): + """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + start_time = current_time_millis() + assert await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") is None + end_time = current_time_millis() + await aiozc.async_close() + # 3000ms for the default timeout + # 1000ms for loaded systems + schedule overhead + assert (end_time - start_time) < 3000 + 1000 diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index ccafb72fb..2939b5ab5 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -5,6 +5,7 @@ """Unit tests for zeroconf._utils.asyncio.""" import asyncio +import concurrent.futures import contextlib import threading import time @@ -12,7 +13,9 @@ import pytest +from zeroconf._core import _CLOSE_TIMEOUT from zeroconf._utils import asyncio as aioutils +from zeroconf.const import _LOADED_SYSTEM_TIMEOUT @pytest.mark.asyncio @@ -82,7 +85,8 @@ async def _still_running(): def _run_coro() -> None: runcoro_thread_ready.set() - asyncio.run_coroutine_threadsafe(_still_running(), loop).result(1) + with contextlib.suppress(concurrent.futures.TimeoutError): + asyncio.run_coroutine_threadsafe(_still_running(), loop).result(1) runcoro_thread = threading.Thread(target=_run_coro, daemon=True) runcoro_thread.start() @@ -97,3 +101,14 @@ def _run_coro() -> None: assert loop.is_running() is False runcoro_thread.join() + + +def test_cumulative_timeouts_less_than_close_plus_buffer(): + """Test that the combined async timeouts are shorter than the close timeout with the buffer. + + We want to make sure that the close timeout is the one that gets + raised if something goes wrong. + """ + assert ( + aioutils._TASK_AWAIT_TIMEOUT + aioutils._GET_ALL_TASKS_TIMEOUT + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT + ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a20e5639f..2f5ef5074 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -62,6 +62,7 @@ _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, + _LOADED_SYSTEM_TIMEOUT, _MAX_MSG_ABSOLUTE, _MDNS_ADDR, _MDNS_ADDR6, @@ -172,7 +173,9 @@ def close(self) -> None: return if not self.loop.is_running(): return - asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result(_CLOSE_TIMEOUT) + asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result( + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT + ) class AsyncListener(asyncio.Protocol, QuietLogger): diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 3e371b17c..7bc81c8df 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -45,6 +45,7 @@ _DNS_OTHER_TTL, _FLAGS_QR_QUERY, _LISTENER_TIME, + _LOADED_SYSTEM_TIMEOUT, _TYPE_A, _TYPE_AAAA, _TYPE_PTR, @@ -427,7 +428,7 @@ def request( raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") return asyncio.run_coroutine_threadsafe( self.async_request(zc, timeout, question_type), zc.loop - ).result(millis_to_seconds(timeout) + 1) + ).result(millis_to_seconds(timeout) + _LOADED_SYSTEM_TIMEOUT) async def async_request( self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index 57c1fb189..c68c0f00e 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -26,8 +26,8 @@ from typing import Any, List, Optional, Set, cast _TASK_AWAIT_TIMEOUT = 1 -_GET_ALL_TASKS_TIMEOUT = 1 -_WAIT_FOR_LOOP_TASKS_TIMEOUT = 2 # Must be larger than _TASK_AWAIT_TIMEOUT +_GET_ALL_TASKS_TIMEOUT = 3 +_WAIT_FOR_LOOP_TASKS_TIMEOUT = 3 # Must be larger than _TASK_AWAIT_TIMEOUT def get_best_available_queue() -> queue.Queue: diff --git a/zeroconf/const.py b/zeroconf/const.py index 0f26d80ac..afdcb2d4b 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -34,6 +34,12 @@ _DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10000 # ms +_LOADED_SYSTEM_TIMEOUT = 10 # s +# If the system is loaded or the event +# loop was blocked by another task that was doing I/O in the loop +# (shouldn't happen but it does in practice) we need to give +# a buffer timeout to ensure a coroutine can finish before +# the future times out # Some DNS constants From a93301d0fd493bf18147187bf8efed1a4ea02214 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jul 2021 09:32:32 -0500 Subject: [PATCH 0557/1433] Update changelog (#899) --- README.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.rst b/README.rst index 2b38fa7a9..76fd77447 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,29 @@ See examples directory for more. Changelog ========= + +0.32.1 (Unreleased) +=================== + +* Increase timeout in ServiceInfo.request to handle loaded systems (#895) @bdraco + + It can take a few seconds for a loaded system to run the `async_request` + coroutine when the event loop is busy, or the system is CPU bound (example being + Home Assistant startup). We now add an additional `_LOADED_SYSTEM_TIMEOUT` (10s) + to the `run_coroutine_threadsafe` calls to ensure the coroutine has the total + amount of time to run up to its internal timeout (default of 3000ms). + + Ten seconds is a bit large of a timeout; however, it is only used in cases + where we wrap other timeouts. We now expect the only instance the + `run_coroutine_threadsafe` result timeout will happen in a production + circumstance is when someone is running a `ServiceInfo.request()` in a thread and + another thread calls `Zeroconf.close()` at just the right moment that the future + is never completed unless the system is so loaded that it is nearly unresponsive. + + The timeout for `run_coroutine_threadsafe` is the maximum time a thread can + cleanly shut down when zeroconf is closed out in another thread, which should + always be longer than the underlying thread operation. + 0.32.0 ====== From fc089be1f412d991f44daeecd0944198d3a638a5 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 5 Jul 2021 09:43:30 +0200 Subject: [PATCH 0558/1433] Fix the changelog's one sentence's tense --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 76fd77447..10f28890f 100644 --- a/README.rst +++ b/README.rst @@ -144,7 +144,7 @@ Changelog 0.32.1 (Unreleased) =================== -* Increase timeout in ServiceInfo.request to handle loaded systems (#895) @bdraco +* Increased timeout in ServiceInfo.request to handle loaded systems (#895) @bdraco It can take a few seconds for a loaded system to run the `async_request` coroutine when the event loop is busy, or the system is CPU bound (example being From 675fd6fc959e76e4e3690e5c7a02db269ca9ef60 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 5 Jul 2021 09:45:11 +0200 Subject: [PATCH 0559/1433] Release version 0.32.1 --- README.rst | 4 ++-- zeroconf/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 10f28890f..7cefb3794 100644 --- a/README.rst +++ b/README.rst @@ -141,8 +141,8 @@ Changelog ========= -0.32.1 (Unreleased) -=================== +0.32.1 +====== * Increased timeout in ServiceInfo.request to handle loaded systems (#895) @bdraco diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b39d7436e..56c61ff3f 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.32.0' +__version__ = '0.32.1' __license__ = 'LGPL' From f8af0fb251938dcb410127b2af2b8b407989aa08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 09:40:17 -1000 Subject: [PATCH 0560/1433] Disable N818 in flake8 (#905) - We cannot rename these exceptions now without a breaking change as they have existed for many years --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index e9dc052f1..e208561b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ testpaths = tests show-source = 1 application-import-names=zeroconf max-line-length=110 -ignore=E203,W503 +ignore=E203,W503,N818 [mypy] ignore_missing_imports = true From e417fc0f5ed7eaa47a0dcaffdbc6fe335bfcc058 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 10:16:28 -1000 Subject: [PATCH 0561/1433] Reduce duplicate code between zeroconf.asyncio and zeroconf._core (#904) --- zeroconf/_core.py | 101 +++++++++++++++++++++++++------------ zeroconf/_utils/asyncio.py | 9 +++- zeroconf/asyncio.py | 54 ++++---------------- 3 files changed, 87 insertions(+), 77 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 2f5ef5074..a89862117 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -29,7 +29,7 @@ import sys import threading from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, Tuple, Type, Union, cast +from typing import Awaitable, Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType @@ -43,7 +43,7 @@ from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.asyncio import get_running_loop, shutdown_loop, wait_event_or_timeout +from ._utils.asyncio import await_awaitable, get_running_loop, shutdown_loop, wait_event_or_timeout from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -74,6 +74,7 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) _CLOSE_TIMEOUT = 3 +_REGISTER_BROADCASTS = 3 class AsyncEngine: @@ -478,6 +479,27 @@ def register_service( allow_name_change: bool = False, cooperating_responders: bool = False, ) -> None: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service. The name of the service may be changed if needed to make + it unique on the network. Additionally multiple cooperating responders + can register the same service on the network for resilience + (if you want this behavior set `cooperating_responders` to `True`).""" + assert self.loop is not None + asyncio.run_coroutine_threadsafe( + await_awaitable( + self.async_register_service(info, ttl, allow_name_change, cooperating_responders) + ), + self.loop, + ).result(millis_to_seconds(_REGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT) + + async def async_register_service( + self, + info: ServiceInfo, + ttl: Optional[int] = None, + allow_name_change: bool = False, + cooperating_responders: bool = False, + ) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service. The name of the service may be changed if needed to make @@ -489,36 +511,34 @@ def register_service( # Setting TTLs via ServiceInfo is preferred info.host_ttl = ttl info.other_ttl = ttl - self.check_service(info, allow_name_change, cooperating_responders) + + await self.async_wait_for_start() + await self.async_check_service(info, allow_name_change, cooperating_responders) self.registry.add(info) - self._broadcast_service(info, _REGISTER_TIME, None) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service.""" + assert self.loop is not None + asyncio.run_coroutine_threadsafe(await_awaitable(self.async_update_service(info)), self.loop).result( + millis_to_seconds(_REGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT + ) + async def async_update_service(self, info: ServiceInfo) -> Awaitable: + """Registers service information to the network with a default TTL. + Zeroconf will then respond to requests for information for that + service.""" self.registry.update(info) - self._broadcast_service(info, _REGISTER_TIME, None) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) - def _broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: """Send a broadcasts to announce a service at intervals.""" - now = current_time_millis() - next_time = now - i = 0 - while i < 3: - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - - self.send_service_broadcast(info, ttl) - i += 1 - next_time += interval - - def send_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> None: - """Send a broadcast to announce a service.""" - self.send(self.generate_service_broadcast(info, ttl)) + for i in range(_REGISTER_BROADCASTS): + if i != 0: + await asyncio.sleep(millis_to_seconds(interval)) + self.async_send(self.generate_service_broadcast(info, ttl)) def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> DNSOutgoing: """Generate a broadcast to announce a service.""" @@ -526,10 +546,6 @@ def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> D self._add_broadcast_answer(out, info, ttl) return out - def send_service_query(self, info: ServiceInfo) -> None: - """Send a query to lookup a service.""" - self.send(self.generate_service_query(info)) - def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use """Generate a query to lookup a service.""" out = DNSOutgoing(_FLAGS_QR_QUERY | _FLAGS_AA) @@ -559,9 +575,16 @@ def _add_broadcast_answer( # pylint: disable=no-self-use out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: + """Unregister a service.""" + assert self.loop is not None + asyncio.run_coroutine_threadsafe( + await_awaitable(self.async_unregister_service(info)), self.loop + ).result(millis_to_seconds(_UNREGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT) + + async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service.""" self.registry.remove(info) - self._broadcast_service(info, _UNREGISTER_TIME, 0) + return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: """Generate a DNSOutgoing goodbye for all services and remove them from the registry.""" @@ -574,6 +597,22 @@ def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: self.registry.remove(service_infos) return out + async def async_unregister_all_services(self) -> None: + """Unregister all registered services. + + Unlike async_register_service and async_unregister_service, this + method does not return a future and is always expected to be + awaited since its only called at shutdown. + """ + # Send Goodbye packets https://datatracker.ietf.org/doc/html/rfc6762#section-10.1 + out = self.generate_unregister_all_services() + if not out: + return + for i in range(_REGISTER_BROADCASTS): + if i != 0: + await asyncio.sleep(millis_to_seconds(_UNREGISTER_TIME)) + self.async_send(out) + def unregister_all_services(self) -> None: """Unregister all registered services.""" # Send Goodbye packets https://datatracker.ietf.org/doc/html/rfc6762#section-10.1 @@ -592,7 +631,7 @@ def unregister_all_services(self) -> None: i += 1 next_time += _UNREGISTER_TIME - def check_service( + async def async_check_service( self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False ) -> None: """Checks the network for a unique service name, modifying the @@ -603,7 +642,7 @@ def check_service( next_instance_number = 2 next_time = now = current_time_millis() i = 0 - while i < 3: + while i < _REGISTER_BROADCASTS: # check for a name conflict while self.cache.current_entry_with_name_and_alias(info.type, info.name): if not allow_name_change: @@ -617,11 +656,11 @@ def check_service( i = 0 if now < next_time: - self.wait(next_time - now) + await self.async_wait(next_time - now) now = current_time_millis() continue - self.send_service_query(info) + self.async_send(self.generate_service_query(info)) i += 1 next_time += _CHECK_TIME diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index c68c0f00e..395c331b8 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -23,8 +23,9 @@ import asyncio import contextlib import queue -from typing import Any, List, Optional, Set, cast +from typing import Any, Awaitable, List, Optional, Set, cast +# The combined timeouts should be lower than _CLOSE_TIMEOUT + _WAIT_FOR_LOOP_TASKS_TIMEOUT _TASK_AWAIT_TIMEOUT = 1 _GET_ALL_TASKS_TIMEOUT = 3 _WAIT_FOR_LOOP_TASKS_TIMEOUT = 3 # Must be larger than _TASK_AWAIT_TIMEOUT @@ -80,6 +81,12 @@ async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: await asyncio.wait(wait_tasks, timeout=_TASK_AWAIT_TIMEOUT) +async def await_awaitable(aw: Awaitable) -> None: + """Wait on an awaitable and the task it returns.""" + task = await aw + await task + + def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" pending_tasks = set( diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 67ff1c120..08478044b 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -26,20 +26,15 @@ from ._core import Zeroconf from ._dns import DNSQuestionType -from ._exceptions import NonUniqueNameException from ._services import ServiceListener from ._services.browser import _ServiceBrowserBase -from ._services.info import ServiceInfo, instance_name_from_service_info +from ._services.info import ServiceInfo from ._services.types import ZeroconfServiceTypes from ._utils.net import IPVersion, InterfaceChoice, InterfacesType -from ._utils.time import millis_to_seconds from .const import ( _BROWSER_TIME, - _CHECK_TIME, _MDNS_PORT, - _REGISTER_TIME, _SERVICE_TYPE_ENUMERATION_NAME, - _UNREGISTER_TIME, ) @@ -172,16 +167,11 @@ def __init__( ) self.async_browsers: Dict[ServiceListener, AsyncServiceBrowser] = {} - async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: - """Send a broadcasts to announce a service at intervals.""" - for i in range(3): - if i != 0: - await asyncio.sleep(millis_to_seconds(interval)) - self.zeroconf.async_send(self.zeroconf.generate_service_broadcast(info, ttl)) - async def async_register_service( self, info: ServiceInfo, + ttl: Optional[int] = None, + allow_name_change: bool = False, cooperating_responders: bool = False, ) -> Awaitable: """Registers service information to the network with a default TTL. @@ -194,10 +184,9 @@ async def async_register_service( The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ - await self.zeroconf.async_wait_for_start() - await self.async_check_service(info, cooperating_responders) - self.zeroconf.registry.add(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return await self.zeroconf.async_register_service( + info, ttl, allow_name_change, cooperating_responders + ) async def async_unregister_all_services(self) -> None: """Unregister all registered services. @@ -206,30 +195,7 @@ async def async_unregister_all_services(self) -> None: method does not return a future and is always expected to be awaited since its only called at shutdown. """ - out = self.zeroconf.generate_unregister_all_services() - if not out: - return - for i in range(3): - if i != 0: - await asyncio.sleep(millis_to_seconds(_UNREGISTER_TIME)) - self.zeroconf.async_send(out) - - async def async_check_service(self, info: ServiceInfo, cooperating_responders: bool = False) -> None: - """Checks the network for a unique service name.""" - instance_name_from_service_info(info) - if cooperating_responders: - return - self._raise_on_name_conflict(info) - for i in range(3): - if i != 0: - await asyncio.sleep(millis_to_seconds(_CHECK_TIME)) - self.zeroconf.async_send(self.zeroconf.generate_service_query(info)) - self._raise_on_name_conflict(info) - - def _raise_on_name_conflict(self, info: ServiceInfo) -> None: - """Raise NonUniqueNameException if the ServiceInfo has a conflict.""" - if self.zeroconf.cache.current_entry_with_name_and_alias(info.type, info.name): - raise NonUniqueNameException + await self.zeroconf.async_unregister_all_services() async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service. @@ -237,8 +203,7 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ - self.zeroconf.registry.remove(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) + return await self.zeroconf.async_unregister_service(info) async def async_update_service(self, info: ServiceInfo) -> Awaitable: """Registers service information to the network with a default TTL. @@ -248,8 +213,7 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: The service will be broadcast in a task. This task is returned and therefore can be awaited if necessary. """ - self.zeroconf.registry.update(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return await self.zeroconf.async_update_service(info) async def async_close(self) -> None: """Ends the background threads, and prevent this instance from From 9399c57bb2b280c7b433e7fbea7cca2c2f4417ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 10:40:47 -1000 Subject: [PATCH 0562/1433] Centralize running coroutines from threads (#906) - Cleanup to ensure all coros we run from a thread use _LOADED_SYSTEM_TIMEOUT --- zeroconf/_core.py | 30 +++++++++++++++++------------- zeroconf/_services/info.py | 10 +++------- zeroconf/_utils/asyncio.py | 12 +++++++++++- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index a89862117..aadaa2906 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -43,7 +43,13 @@ from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.asyncio import await_awaitable, get_running_loop, shutdown_loop, wait_event_or_timeout +from ._utils.asyncio import ( + await_awaitable, + get_running_loop, + run_coro_with_timeout, + shutdown_loop, + wait_event_or_timeout, +) from ._utils.name import service_type_name from ._utils.net import ( IPVersion, @@ -62,7 +68,6 @@ _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, - _LOADED_SYSTEM_TIMEOUT, _MAX_MSG_ABSOLUTE, _MDNS_ADDR, _MDNS_ADDR6, @@ -73,7 +78,7 @@ ) _TC_DELAY_RANDOM_INTERVAL = (400, 500) -_CLOSE_TIMEOUT = 3 +_CLOSE_TIMEOUT = 3000 # ms _REGISTER_BROADCASTS = 3 @@ -174,9 +179,7 @@ def close(self) -> None: return if not self.loop.is_running(): return - asyncio.run_coroutine_threadsafe(self._async_close(), self.loop).result( - _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT - ) + run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) class AsyncListener(asyncio.Protocol, QuietLogger): @@ -486,12 +489,13 @@ def register_service( can register the same service on the network for resilience (if you want this behavior set `cooperating_responders` to `True`).""" assert self.loop is not None - asyncio.run_coroutine_threadsafe( + run_coro_with_timeout( await_awaitable( self.async_register_service(info, ttl, allow_name_change, cooperating_responders) ), self.loop, - ).result(millis_to_seconds(_REGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT) + _REGISTER_TIME * _REGISTER_BROADCASTS, + ) async def async_register_service( self, @@ -522,8 +526,8 @@ def update_service(self, info: ServiceInfo) -> None: Zeroconf will then respond to requests for information for that service.""" assert self.loop is not None - asyncio.run_coroutine_threadsafe(await_awaitable(self.async_update_service(info)), self.loop).result( - millis_to_seconds(_REGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT + run_coro_with_timeout( + await_awaitable(self.async_update_service(info)), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS ) async def async_update_service(self, info: ServiceInfo) -> Awaitable: @@ -577,9 +581,9 @@ def _add_broadcast_answer( # pylint: disable=no-self-use def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service.""" assert self.loop is not None - asyncio.run_coroutine_threadsafe( - await_awaitable(self.async_unregister_service(info)), self.loop - ).result(millis_to_seconds(_UNREGISTER_TIME * _REGISTER_BROADCASTS) + _LOADED_SYSTEM_TIMEOUT) + run_coro_with_timeout( + self.async_unregister_service(info), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS + ) async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service.""" diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 7bc81c8df..d1bf17e9b 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -20,7 +20,6 @@ USA """ -import asyncio import ipaddress import socket from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, cast @@ -29,7 +28,7 @@ from .._exceptions import BadTypeInNameException from .._protocol import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.asyncio import get_running_loop +from .._utils.asyncio import get_running_loop, run_coro_with_timeout from .._utils.name import service_type_name from .._utils.net import ( IPVersion, @@ -37,7 +36,7 @@ _is_v6_address, ) from .._utils.struct import int2byte -from .._utils.time import current_time_millis, millis_to_seconds +from .._utils.time import current_time_millis from ..const import ( _CLASS_IN, _CLASS_UNIQUE, @@ -45,7 +44,6 @@ _DNS_OTHER_TTL, _FLAGS_QR_QUERY, _LISTENER_TIME, - _LOADED_SYSTEM_TIMEOUT, _TYPE_A, _TYPE_AAAA, _TYPE_PTR, @@ -426,9 +424,7 @@ def request( assert zc.loop is not None and zc.loop.is_running() if zc.loop == get_running_loop(): raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") - return asyncio.run_coroutine_threadsafe( - self.async_request(zc, timeout, question_type), zc.loop - ).result(millis_to_seconds(timeout) + _LOADED_SYSTEM_TIMEOUT) + return bool(run_coro_with_timeout(self.async_request(zc, timeout, question_type), zc.loop, timeout)) async def async_request( self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index 395c331b8..10b8b3d97 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -23,7 +23,10 @@ import asyncio import contextlib import queue -from typing import Any, Awaitable, List, Optional, Set, cast +from typing import Any, Awaitable, Coroutine, List, Optional, Set, cast + +from .time import millis_to_seconds +from ..const import _LOADED_SYSTEM_TIMEOUT # The combined timeouts should be lower than _CLOSE_TIMEOUT + _WAIT_FOR_LOOP_TASKS_TIMEOUT _TASK_AWAIT_TIMEOUT = 1 @@ -87,6 +90,13 @@ async def await_awaitable(aw: Awaitable) -> None: await task +def run_coro_with_timeout(aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float) -> Any: + """Run a coroutine with a timeout.""" + return asyncio.run_coroutine_threadsafe(aw, loop).result( + millis_to_seconds(timeout) + _LOADED_SYSTEM_TIMEOUT + ) + + def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" pending_tasks = set( From bc9e9cf8a5b997ca924730ed091a829f4f961ca3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 11:12:57 -1000 Subject: [PATCH 0563/1433] Implement NSEC record parsing (#903) - This is needed for negative responses https://datatracker.ietf.org/doc/html/rfc6762#section-6.1 --- tests/test_asyncio.py | 1 + tests/test_dns.py | 25 +++++++++++++++++++++++ tests/test_protocol.py | 15 ++++++++++++++ zeroconf/__init__.py | 1 + zeroconf/_dns.py | 46 ++++++++++++++++++++++++++++++++++++------ zeroconf/_protocol.py | 28 ++++++++++++++++++++++++- zeroconf/const.py | 2 ++ 7 files changed, 111 insertions(+), 7 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index f4722389d..ac8f99f82 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -104,6 +104,7 @@ async def test_async_with_sync_passed_in_closed_in_async() -> None: @pytest.mark.asyncio async def test_sync_within_event_loop_executor() -> None: """Test sync version still works from an executor within an event loop.""" + def sync_code(): zc = Zeroconf(interfaces=['127.0.0.1']) assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None diff --git a/tests/test_dns.py b/tests/test_dns.py index 197357067..c55e0f6ab 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -288,6 +288,31 @@ def test_dns_service_record_hashablity(): assert len(record_set) == 4 +def test_dns_nsec_record_hashablity(): + """Test DNSNsec are hashable.""" + nsec1 = r.DNSNsec( + 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2, 3] + ) + nsec2 = r.DNSNsec( + 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2] + ) + + record_set = set([nsec1, nsec2]) + assert len(record_set) == 2 + + record_set.add(nsec1) + assert len(record_set) == 2 + + nsec2_dupe = r.DNSNsec( + 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2] + ) + assert nsec2 == nsec2_dupe + assert nsec2.__hash__() == nsec2_dupe.__hash__() + + record_set.add(nsec2_dupe) + assert len(record_set) == 2 + + def test_rrset_does_not_consider_ttl(): """Test DNSRRSet does not consider the ttl in the hash.""" diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ebdb71105..a08059605 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -722,6 +722,21 @@ def test_qu_packet_parser(): assert ",QU," in str(parsed.questions[0]) +def test_parse_packet_with_nsec_record(): + """Test we can parse a packet with an NSEC record.""" + nsec_packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x03\x08_meshcop\x04_udp\x05local\x00\x00\x0c\x00" + b"\x01\x00\x00\x11\x94\x00\x0f\x0cMyHome54 (2)\xc0\x0c\xc0+\x00\x10\x80\x01\x00\x00\x11\x94\x00" + b")\x0bnn=MyHome54\x13xp=695034D148CC4784\x08tv=0.0.0\xc0+\x00!\x80\x01\x00\x00\x00x\x00\x15\x00" + b"\x00\x00\x00\xc0'\x0cMaster-Bed-2\xc0\x1a\xc0+\x00/\x80\x01\x00\x00\x11\x94\x00\t\xc0+\x00\x05" + b"\x00\x00\x80\x00@" + ) + parsed = DNSIncoming(nsec_packet) + nsec_record = parsed.answers[3] + assert "nsec," in str(nsec_record) + assert nsec_record.rdtypes == [16, 33] + + def test_records_same_packet_share_fate(): """Test records in the same packet all have the same created time.""" out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 56c61ff3f..666914b2b 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -28,6 +28,7 @@ DNSAddress, DNSEntry, DNSHinfo, + DNSNsec, DNSPointer, DNSQuestion, DNSRecord, diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 93db9859c..31a2da3a2 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -22,7 +22,7 @@ import enum import socket -from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -116,11 +116,7 @@ class DNSQuestion(DNSEntry): def answered_by(self, rec: 'DNSRecord') -> bool: """Returns true if the question is answered by the record""" - return ( - self.class_ == rec.class_ - and (self.type == rec.type or self.type == _TYPE_ANY) - and self.name == rec.name - ) + return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name def __hash__(self) -> int: return hash((self.name, self.class_, self.type)) @@ -446,6 +442,44 @@ def __repr__(self) -> str: return self.to_string("%s:%s" % (self.server, self.port)) +class DNSNsec(DNSRecord): + + """A DNS NSEC record""" + + __slots__ = ('next', 'rdtypes') + + def __init__( + self, + name: str, + type_: int, + class_: int, + ttl: int, + next: str, + rdtypes: List[int], + created: Optional[float] = None, + ) -> None: + super().__init__(name, type_, class_, ttl, created) + self.next = next + self.rdtypes = rdtypes + + def __eq__(self, other: Any) -> bool: + """Tests equality on cpu and os""" + return ( + isinstance(other, DNSNsec) + and self.next == other.next + and self.rdtypes == other.rdtypes + and DNSEntry.__eq__(self, other) + ) + + def __hash__(self) -> int: + """Hash to compare like DNSNSec.""" + return hash((*self._entry_tuple(), self.next, *self.rdtypes)) + + def __repr__(self) -> str: + """String representation""" + return self.to_string(self.next + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes])) + + class DNSRRSet: """A set of dns records independent of the ttl.""" diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 79f483def..ae2f43a31 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast -from ._dns import DNSAddress, DNSHinfo, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from ._exceptions import IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log from ._utils.struct import int2byte @@ -43,6 +43,7 @@ _TYPE_AAAA, _TYPE_CNAME, _TYPE_HINFO, + _TYPE_NSEC, _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, @@ -201,6 +202,18 @@ def read_others(self) -> None: rec = DNSAddress( domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id ) + elif type_ == _TYPE_NSEC: + name_start = self.offset + name = self.read_name() + rec = DNSNsec( + domain, + type_, + class_, + ttl, + name, + self.read_bitmap(name_start + length), + self.now, + ) else: # Try to ignore types we don't know about # Skip the payload for the resource record so the next @@ -210,6 +223,19 @@ def read_others(self) -> None: if rec is not None: self.answers.append(rec) + def read_bitmap(self, end: int) -> List[int]: + """Reads an NSEC bitmap from the packet.""" + rdtypes = [] + while self.offset < end: + window = self.data[self.offset] + bitmap_length = self.data[self.offset + 1] + for i, byte in enumerate(self.data[self.offset + 2 : self.offset + 2 + bitmap_length]): + for bit in range(0, 8): + if byte & (0x80 >> bit): + rdtypes.append(bit + window * 256 + i * 8) + self.offset += 2 + bitmap_length + return rdtypes + def read_name(self) -> str: """Reads a domain name from the packet""" result = '' diff --git a/zeroconf/const.py b/zeroconf/const.py index afdcb2d4b..76a75dbde 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -103,6 +103,7 @@ _TYPE_TXT = 16 _TYPE_AAAA = 28 _TYPE_SRV = 33 +_TYPE_NSEC = 47 _TYPE_ANY = 255 # Mapping constants to names @@ -136,6 +137,7 @@ _TYPE_AAAA: "quada", _TYPE_SRV: "srv", _TYPE_ANY: "any", + _TYPE_NSEC: "nsec", } _HAS_A_TO_Z = re.compile(r'[A-Za-z]') From 057873128ff05a0b2d6eae07510e23d705d10bae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 11:23:14 -1000 Subject: [PATCH 0564/1433] Upgrade syntax to python 3.6 (#907) --- examples/async_apple_scanner.py | 6 ++-- examples/async_browser.py | 6 ++-- examples/async_service_info_request.py | 4 +-- examples/browser.py | 6 ++-- examples/self_test.py | 4 +-- tests/conftest.py | 1 - tests/test_asyncio.py | 35 +++++++++--------- tests/test_cache.py | 21 ++++++----- tests/test_core.py | 43 +++++++++++----------- tests/test_dns.py | 23 ++++++------ tests/test_exceptions.py | 7 ++-- tests/test_handlers.py | 49 +++++++++++++------------- tests/test_history.py | 37 ++++++++----------- tests/test_init.py | 7 ++-- tests/test_logger.py | 1 - tests/test_protocol.py | 5 ++- tests/test_services.py | 3 +- tests/test_updates.py | 3 +- zeroconf/_core.py | 2 +- zeroconf/_dns.py | 8 ++--- zeroconf/_handlers.py | 18 +++++----- zeroconf/_protocol.py | 6 ++-- zeroconf/asyncio.py | 2 +- 23 files changed, 136 insertions(+), 161 deletions(-) diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index f10f6ef63..88b54e4a7 100644 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -36,7 +36,7 @@ def async_on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: - print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) + print(f"Service {name} of type {service_type} state changed: {state_change}") if state_change is not ServiceStateChange.Added: return base_name = name[: -len(service_type) - 1] @@ -55,11 +55,11 @@ async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) - print(" Server: %s" % (info.server,)) + print(f" Server: {info.server}") if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(" %s: %s" % (key, value)) + print(f" {key}: {value}") else: print(" No properties") else: diff --git a/examples/async_browser.py b/examples/async_browser.py index f0e0851cf..1cce5c205 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -17,7 +17,7 @@ def async_on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: - print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) + print(f"Service {name} of type {service_type} state changed: {state_change}") if state_change is not ServiceStateChange.Added: return asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) @@ -32,11 +32,11 @@ async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) - print(" Server: %s" % (info.server,)) + print(f" Server: {info.server}") if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(" %s: %s" % (key, value)) + print(f" {key}: {value}") else: print(" No properties") else: diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 885eb99c4..dd8265b7a 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -36,11 +36,11 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) - print(" Server: %s" % (info.server,)) + print(f" Server: {info.server}") if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(" %s: %s" % (key, value)) + print(f" {key}: {value}") else: print(" No properties") else: diff --git a/examples/browser.py b/examples/browser.py index 8525e9b9b..8c50e4097 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -16,7 +16,7 @@ def on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange ) -> None: - print("Service %s of type %s state changed: %s" % (name, service_type, state_change)) + print(f"Service {name} of type {service_type} state changed: {state_change}") if state_change is ServiceStateChange.Added: info = zeroconf.get_service_info(service_type, name) @@ -26,11 +26,11 @@ def on_service_state_change( addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) - print(" Server: %s" % (info.server,)) + print(f" Server: {info.server}") if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(" %s: %s" % (key, value)) + print(f" {key}: {value}") else: print(" No properties") else: diff --git a/examples/self_test.py b/examples/self_test.py index 35007db13..2178629b5 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -14,7 +14,7 @@ # Test a few module features, including service registration, service # query (for Zoe), and service unregistration. - print("Multicast DNS Service Discovery for Python, version %s" % (__version__,)) + print(f"Multicast DNS Service Discovery for Python, version {__version__}") r = Zeroconf() print("1. Testing registration of a service...") desc = {'version': '0.10', 'a': 'test value', 'b': 'another value'} @@ -40,7 +40,7 @@ queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") assert queried_info assert set(queried_info.parsed_addresses()) == expected - print(" Getting self: %s" % (queried_info,)) + print(f" Getting self: {queried_info}") print(" Query done.") print("4. Testing unregister of service information...") r.unregister_service(info) diff --git a/tests/conftest.py b/tests/conftest.py index c05c4b9b7..d4ea1632a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ conftest for zeroconf tests. """ diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index ac8f99f82..34709d85b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for aio.py.""" @@ -57,11 +56,9 @@ def verify_threads_ended(): yield threads_after = frozenset(threading.enumerate()) non_executor_threads = frozenset( - [ - thread - for thread in threads_after - if "asyncio" not in thread.name and "ThreadPoolExecutor" not in thread.name - ] + thread + for thread in threads_after + if "asyncio" not in thread.name and "ThreadPoolExecutor" not in thread.name ) threads = non_executor_threads - threads_before assert not threads @@ -119,7 +116,7 @@ async def test_async_service_registration() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" calls = [] @@ -179,7 +176,7 @@ async def test_async_service_registration_name_conflict() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test-srvc2-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -227,7 +224,7 @@ async def test_async_service_registration_name_does_not_match_type() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test-srvc3-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -254,7 +251,7 @@ async def test_async_tasks() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test-srvc4-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" calls = [] @@ -320,7 +317,7 @@ async def test_async_wait_unblocks_on_update() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test-srvc4-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -356,8 +353,8 @@ async def test_service_info_async_request() -> None: type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "abc" - registration_name = "%s.%s" % (name, type_) - registration_name2 = "%s.%s" % (name2, type_) + registration_name = f"{name}.{type_}" + registration_name2 = f"{name2}.{type_}" # Start a tasks BEFORE the registration that will keep trying # and see the registration a bit later @@ -454,7 +451,7 @@ async def test_async_service_browser() -> None: aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test9-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" calls = [] @@ -513,7 +510,7 @@ async def test_async_context_manager() -> None: """Test using an async context manager.""" type_ = "_test10-sr-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" async with AsyncZeroconf(interfaces=['127.0.0.1']) as aiozc: info = ServiceInfo( @@ -539,8 +536,8 @@ async def test_async_unregister_all_services() -> None: type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "abc" - registration_name = "%s.%s" % (name, type_) - registration_name2 = "%s.%s" % (name2, type_) + registration_name = f"{name}.{type_}" + registration_name2 = f"{name2}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -594,7 +591,7 @@ async def test_async_unregister_all_services() -> None: async def test_async_zeroconf_service_types(): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} @@ -808,7 +805,7 @@ async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): zeroconf_info = aiozc.zeroconf name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( diff --git a/tests/test_cache.py b/tests/test_cache.py index 4b3a8a18e..559b43573 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._cache. """ @@ -98,7 +97,7 @@ def test_async_all_by_details(self): record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == set([record1, record2]) + assert set(cache.async_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == {record1, record2} def test_async_entries_with_server(self): record1 = r.DNSService( @@ -109,8 +108,8 @@ def test_async_entries_with_server(self): ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_entries_with_server('ab')) == set([record1, record2]) - assert set(cache.async_entries_with_server('AB')) == set([record1, record2]) + assert set(cache.async_entries_with_server('ab')) == {record1, record2} + assert set(cache.async_entries_with_server('AB')) == {record1, record2} def test_async_entries_with_name(self): record1 = r.DNSService( @@ -121,8 +120,8 @@ def test_async_entries_with_name(self): ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_entries_with_name('irrelevant')) == set([record1, record2]) - assert set(cache.async_entries_with_name('Irrelevant')) == set([record1, record2]) + assert set(cache.async_entries_with_name('irrelevant')) == {record1, record2} + assert set(cache.async_entries_with_name('Irrelevant')) == {record1, record2} # These functions have been seen in other projects so @@ -152,7 +151,7 @@ def test_get_all_by_details(self): record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == set([record1, record2]) + assert set(cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == {record1, record2} def test_entries_with_server(self): record1 = r.DNSService( @@ -163,8 +162,8 @@ def test_entries_with_server(self): ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.entries_with_server('ab')) == set([record1, record2]) - assert set(cache.entries_with_server('AB')) == set([record1, record2]) + assert set(cache.entries_with_server('ab')) == {record1, record2} + assert set(cache.entries_with_server('AB')) == {record1, record2} def test_entries_with_name(self): record1 = r.DNSService( @@ -175,8 +174,8 @@ def test_entries_with_name(self): ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.entries_with_name('irrelevant')) == set([record1, record2]) - assert set(cache.entries_with_name('Irrelevant')) == set([record1, record2]) + assert set(cache.entries_with_name('irrelevant')) == {record1, record2} + assert set(cache.entries_with_name('Irrelevant')) == {record1, record2} def test_current_entry_with_name_and_alias(self): record1 = r.DNSPointer( diff --git a/tests/test_core.py b/tests/test_core.py index d80514f7c..fee9c79dd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._core """ @@ -55,28 +54,26 @@ async def test_reaper(): aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zeroconf = aiozc.zeroconf cache = zeroconf.cache - original_entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = set( - [ - r.DNSPointer( - "_hap._tcp.local.", - const._TYPE_PTR, - const._CLASS_IN, - 10000, - 'known-to-other._hap._tcp.local.', - ) - ] - ) + other_known_answers = { + r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + 'known-to-other._hap._tcp.local.', + ) + } zeroconf.question_history.add_question_at_time(question, now, other_known_answers) assert zeroconf.question_history.suppresses(question, now, other_known_answers) - entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) await asyncio.sleep(1.2) - entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) + entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) await aiozc.async_close() assert not zeroconf.question_history.suppresses(question, now, other_known_answers) assert entries != original_entries @@ -367,7 +364,7 @@ def test_register_service_with_custom_ttl(): name = "MyTestHome" info_service = r.ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -423,9 +420,9 @@ def test_tc_bit_defers(): name2 = "knownname2" name3 = "knownname3" - registration_name = "%s.%s" % (name, type_) - registration2_name = "%s.%s" % (name2, type_) - registration3_name = "%s.%s" % (name3, type_) + registration_name = f"{name}.{type_}" + registration2_name = f"{name2}.{type_}" + registration3_name = f"{name3}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." @@ -502,9 +499,9 @@ def test_tc_bit_defers_last_response_missing(): name2 = "knownname2" name3 = "knownname3" - registration_name = "%s.%s" % (name, type_) - registration2_name = "%s.%s" % (name2, type_) - registration3_name = "%s.%s" % (name3, type_) + registration_name = f"{name}.{type_}" + registration2_name = f"{name2}.{type_}" + registration3_name = f"{name3}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." @@ -729,7 +726,7 @@ def test_shutdown_while_register_in_process(): name = "MyTestHome" info_service = r.ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, diff --git a/tests/test_dns.py b/tests/test_dns.py index c55e0f6ab..071e1f650 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._dns. """ @@ -101,7 +100,7 @@ def test_dns_record_reset_ttl(self): def test_service_info_dunder(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" info = ServiceInfo( type_, registration_name, @@ -119,7 +118,7 @@ def test_service_info_dunder(self): def test_service_info_text_properties_not_given(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" info = ServiceInfo( type_=type_, name=registration_name, @@ -166,7 +165,7 @@ def test_dns_record_hashablity_does_not_consider_ttl(): record1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b'same') record2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') - record_set = set([record1, record2]) + record_set = {record1, record2} assert len(record_set) == 1 record_set.add(record1) @@ -187,7 +186,7 @@ def test_dns_address_record_hashablity(): address3 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'c') address4 = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 1, b'c') - record_set = set([address1, address2, address3, address4]) + record_set = {address1, address2, address3, address4} assert len(record_set) == 4 record_set.add(address1) @@ -199,9 +198,9 @@ def test_dns_address_record_hashablity(): assert len(record_set) == 4 # Verify we can remove records - additional_set = set([address1, address2]) + additional_set = {address1, address2} record_set -= additional_set - assert record_set == set([address3, address4]) + assert record_set == {address3, address4} def test_dns_hinfo_record_hashablity(): @@ -209,7 +208,7 @@ def test_dns_hinfo_record_hashablity(): hinfo1 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu1', 'os') hinfo2 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') - record_set = set([hinfo1, hinfo2]) + record_set = {hinfo1, hinfo2} assert len(record_set) == 2 record_set.add(hinfo1) @@ -228,7 +227,7 @@ def test_dns_pointer_record_hashablity(): ptr1 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') ptr2 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') - record_set = set([ptr1, ptr2]) + record_set = {ptr1, ptr2} assert len(record_set) == 2 record_set.add(ptr1) @@ -249,7 +248,7 @@ def test_dns_text_record_hashablity(): text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') - record_set = set([text1, text2, text3, text4]) + record_set = {text1, text2, text3, text4} assert len(record_set) == 4 @@ -271,7 +270,7 @@ def test_dns_service_record_hashablity(): srv3 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 81, 'a') srv4 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab') - record_set = set([srv1, srv2, srv3, srv4]) + record_set = {srv1, srv2, srv3, srv4} assert len(record_set) == 4 @@ -297,7 +296,7 @@ def test_dns_nsec_record_hashablity(): 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2] ) - record_set = set([nsec1, nsec2]) + record_set = {nsec1, nsec2} assert len(record_set) == 2 record_set.add(nsec1) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index aa2f74f60..47e68b75f 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._exceptions """ @@ -107,7 +106,7 @@ def test_bad_types(self): bad_names_to_try = ( '._x._tcp.local.', 'a' * 64 + '._sub._http._tcp.local.', - 'a' * 62 + u'â._sub._http._tcp.local.', + 'a' * 62 + 'â._sub._http._tcp.local.', ) for name in bad_names_to_try: self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) @@ -129,7 +128,7 @@ def test_good_service_names(self): ('_12345-67890-abc._udp.local.', '_12345-67890-abc._udp.local.'), ('x._sub._http._tcp.local.', '_http._tcp.local.'), ('a' * 63 + '._sub._http._tcp.local.', '_http._tcp.local.'), - ('a' * 61 + u'â._sub._http._tcp.local.', '_http._tcp.local.'), + ('a' * 61 + 'â._sub._http._tcp.local.', '_http._tcp.local.'), ) for name, result in good_names_to_try: @@ -140,7 +139,7 @@ def test_good_service_names(self): def test_invalid_addresses(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" bad = ('127.0.0.1', '::1', 42) for addr in bad: diff --git a/tests/test_handlers.py b/tests/test_handlers.py index bab50a558..e90a74bd9 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._handlers """ @@ -46,7 +45,7 @@ def test_ttl(self): # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -160,7 +159,7 @@ def test_name_conflicts(self): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_homeassistant._tcp.local." name = "Home" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" info = ServiceInfo( type_, @@ -189,7 +188,7 @@ def test_register_and_lookup_type_by_uppercase_name(self): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_mylowertype._tcp.local." name = "Home" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" info = ServiceInfo( type_, @@ -224,7 +223,7 @@ def test_ptr_optimization(): # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -280,7 +279,7 @@ def test_any_query_for_ptr(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_anyptr._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") @@ -307,7 +306,7 @@ def test_aaaa_query(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_knownaaaservice._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") @@ -332,7 +331,7 @@ def test_a_and_aaaa_record_fate_sharing(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_a-and-aaaa-service._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") @@ -388,7 +387,7 @@ def test_unicast_response(): # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] @@ -434,8 +433,8 @@ def test_qu_response(): type_ = "_test-srvc-type._tcp.local." other_type_ = "_notthesame._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) - registration_name2 = "%s.%s" % (name, other_type_) + registration_name = f"{name}.{type_}" + registration_name2 = f"{name}.{other_type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] @@ -530,7 +529,7 @@ def test_known_answer_supression(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_knownanswersv8._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." info = ServiceInfo( @@ -643,9 +642,9 @@ def test_multi_packet_known_answer_supression(): name2 = "knownname2" name3 = "knownname3" - registration_name = "%s.%s" % (name, type_) - registration2_name = "%s.%s" % (name2, type_) - registration3_name = "%s.%s" % (name3, type_) + registration_name = f"{name}.{type_}" + registration2_name = f"{name2}.{type_}" + registration3_name = f"{name3}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." @@ -694,7 +693,7 @@ def test_known_answer_supression_service_type_enumeration_query(): zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_otherknown._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." info = ServiceInfo( @@ -704,7 +703,7 @@ def test_known_answer_supression_service_type_enumeration_query(): type_2 = "_otherknown2._tcp.local." name = "knownname" - registration_name2 = "%s.%s" % (name, type_2) + registration_name2 = f"{name}.{type_2}" desc = {'path': '/~paulsm/'} server_name2 = "ash-3.local." info2 = ServiceInfo( @@ -772,7 +771,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): type_ = "_addtest1._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "ash-2.local." info = ServiceInfo( @@ -782,7 +781,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): type_2 = "_addtest2._tcp.local." name = "knownname" - registration_name2 = "%s.%s" % (name, type_2) + registration_name2 = f"{name}.{type_2}" desc = {'path': '/~paulsm/'} server_name2 = "ash-3.local." info2 = ServiceInfo( @@ -904,7 +903,7 @@ async def test_cache_flush_bit(): type_ = "_cacheflush._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "server-uu1.local." info = ServiceInfo( @@ -992,7 +991,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor type_ = "_cacheflush._tcp.local." name = "knownname" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} server_name = "server-uu1.local." info = ServiceInfo( @@ -1013,11 +1012,11 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor ) await asyncio.sleep(0) # flush out the call_soon_threadsafe - assert set([record.new for record in updated]) == set([ptr_record, a_record]) + assert {record.new for record in updated} == {ptr_record, a_record} # The old records should be None so we trigger Add events # in service browsers instead of Update events - assert set([record.old for record in updated]) == set([None]) + assert {record.old for record in updated} == {None} await aiozc.async_close() @@ -1044,7 +1043,7 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu ) assert unicast_out is None assert multicast_out is None - assert zc.question_history.suppresses(question, now, set([known_answer])) + assert zc.question_history.suppresses(question, now, {known_answer}) await aiozc.async_close() @@ -1071,7 +1070,7 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): ) assert unicast_out is None assert multicast_out is None - assert not zc.question_history.suppresses(question, now, set([known_answer])) + assert not zc.question_history.suppresses(question, now, {known_answer}) await aiozc.async_close() diff --git a/tests/test_history.py b/tests/test_history.py index 89159dff6..9da6b5679 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for _history.py.""" @@ -14,20 +13,16 @@ def test_question_suppression(): question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = set( - [ - r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' - ) - ] - ) - our_known_answers = set( - [ - r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' - ) - ] - ) + other_known_answers = { + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + } + our_known_answers = { + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' + ) + } history.add_question_at_time(question, now, other_known_answers) @@ -52,13 +47,11 @@ def test_question_expire(): question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = set( - [ - r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' - ) - ] - ) + other_known_answers = { + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + } history.add_question_at_time(question, now, other_known_answers) # Verify the question is suppressed if the known answers are the same diff --git a/tests/test_init.py b/tests/test_init.py index 5005a75d9..1d1f7086b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf.py """ @@ -127,7 +126,7 @@ def verify_name_change(self, zc, type_, name, number_hosts): desc = {'path': '/~paulsm/'} info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -147,7 +146,7 @@ def verify_name_change(self, zc, type_, name, number_hosts): # in the registry info_service2 = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -160,7 +159,7 @@ def verify_name_change(self, zc, type_, name, number_hosts): def generate_many_hosts(self, zc, type_, name, number_hosts): block_size = 25 - number_hosts = int(((number_hosts - 1) / block_size + 1)) * block_size + number_hosts = int((number_hosts - 1) / block_size + 1) * block_size out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) for i in range(1, number_hosts + 1): next_name = name if i == 1 else '%s-%d' % (name, i) diff --git a/tests/test_logger.py b/tests/test_logger.py index 205ce0ff9..cedda7e95 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for logger.py.""" diff --git a/tests/test_protocol.py b/tests/test_protocol.py index a08059605..e90634750 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._protocol """ @@ -195,8 +194,8 @@ def test_dns_hinfo(self): generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) parsed = r.DNSIncoming(generated.packets()[0]) answer = cast(r.DNSHinfo, parsed.answers[0]) - assert answer.cpu == u'cpu' - assert answer.os == u'os' + assert answer.cpu == 'cpu' + assert answer.os == 'os' generated = r.DNSOutgoing(0) generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) diff --git a/tests/test_services.py b/tests/test_services.py index b1e2d8904..7994cbdc5 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._services. """ @@ -48,7 +47,7 @@ def test_integration_with_listener_class(self): type_ = "_http._tcp.local." subtype = subtype_name + "._sub." + type_ name = "UPPERxxxyyyæøå" - registration_name = "%s.%s" % (name, subtype) + registration_name = f"{name}.{subtype}" class MyListener(r.ServiceListener): def add_service(self, zeroconf, type, name): diff --git a/tests/test_updates.py b/tests/test_updates.py index 1f6f8ad4b..b1d7f1b76 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._services. """ @@ -67,7 +66,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index aadaa2906..31bf2b32b 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -653,7 +653,7 @@ async def async_check_service( raise NonUniqueNameException # change the name and look for a conflict - info.name = '%s-%s.%s' % (instance_name, next_instance_number, info.type) + info.name = f'{instance_name}-{next_instance_number}.{info.type}' next_instance_number += 1 service_type_name(info.name) next_time = now diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 31a2da3a2..33484bfbb 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -100,7 +100,7 @@ def get_type(t: int) -> str: def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: """String representation with additional information""" - return "%s[%s,%s%s,%s]%s" % ( + return "{}[{},{}{},{}]{}".format( hdr, self.get_type(self.type), self.get_class_(self.class_), @@ -142,7 +142,7 @@ def unicast(self, value: bool) -> None: def __repr__(self) -> str: """String representation""" - return "%s[question,%s,%s,%s]" % ( + return "{}[question,{},{},{}]".format( self.get_type(self.type), "QU" if self.unicast else "QM", self.get_class_(self.class_), @@ -230,7 +230,7 @@ def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use def to_string(self, other: Union[bytes, str]) -> str: """String representation with additional information""" - arg = "%s/%s,%s" % (self.ttl, int(self.get_remaining_ttl(current_time_millis())), cast(Any, other)) + arg = f"{self.ttl}/{int(self.get_remaining_ttl(current_time_millis()))},{cast(Any, other)}" return DNSEntry.entry_to_string(self, "record", arg) @@ -439,7 +439,7 @@ def __hash__(self) -> int: def __repr__(self) -> str: """String representation""" - return self.to_string("%s:%s" % (self.server, self.port)) + return self.to_string(f"{self.server}:{self.port}") class DNSNsec(DNSRecord): diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 617be4083..f9bdab742 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -140,11 +140,11 @@ def _construct_outgoing_from_record_set( def _additionals_from_answers_rrset(self, rrset: Set[DNSRecord]) -> Set[DNSRecord]: additionals: Set[DNSRecord] = set() - return additionals.union(*[self._additionals[record] for record in rrset]) + return additionals.union(*(self._additionals[record] for record in rrset)) def _suppress_mcasts_from_last_second(self, rrset: Set[DNSRecord]) -> None: """Remove any records that were already sent in the last second.""" - rrset -= set(record for record in rrset if self._has_mcast_record_in_last_second(record)) + rrset -= {record for record in rrset if self._has_mcast_record_in_last_second(record)} def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -201,13 +201,11 @@ def _add_pointer_answers( # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer(created=now) if not known_answers.suppresses(dns_pointer): - answer_set[dns_pointer] = set( - [ - service.dns_service(created=now), - service.dns_text(created=now), - *service.dns_addresses(created=now), - ] - ) + answer_set[dns_pointer] = { + service.dns_service(created=now), + service.dns_text(created=now), + *service.dns_addresses(created=now), + } def _add_address_answers( self, @@ -271,7 +269,7 @@ def async_response( # pylint: disable=unused-argument threadsafe. """ ucast_source = port != _MDNS_PORT - known_answers = DNSRRSet(itertools.chain(*[msg.answers for msg in msgs])) + known_answers = DNSRRSet(itertools.chain(*(msg.answers for msg in msgs))) query_res = _QueryResponse(self.cache, msgs[0], ucast_source) for msg in msgs: diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index ae2f43a31..f987ce2e2 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -259,10 +259,10 @@ def read_name(self) -> str: next_ = off + 1 off = ((length & 0x3F) << 8) | self.data[off] if off >= first: - raise IncomingDecodeError("Bad domain name (circular) at %s" % (off,)) + raise IncomingDecodeError(f"Bad domain name (circular) at {off}") first = off else: - raise IncomingDecodeError("Bad domain name at %s" % (off,)) + raise IncomingDecodeError(f"Bad domain name at {off}") if next_ >= 0: self.offset = next_ @@ -523,7 +523,7 @@ def _write_record(self, record: DNSRecord, now: float) -> bool: self.write_short(0) # Will get replaced with the actual size record.write(self) # Adjust size for the short we will write before this record - length = sum((len(d) for d in self.data[index + 1 :])) + length = sum(len(d) for d in self.data[index + 1 :]) # Here we replace the 0 length short we wrote # before with the actual length self._replace_short(index, length) diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index 08478044b..ef7e7f649 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -251,7 +251,7 @@ async def async_remove_service_listener(self, listener: ServiceListener) -> None async def async_remove_all_service_listeners(self) -> None: """Removes a listener from the set that is currently listening.""" await asyncio.gather( - *[self.async_remove_service_listener(listener) for listener in list(self.async_browsers)] + *(self.async_remove_service_listener(listener) for listener in list(self.async_browsers)) ) async def __aenter__(self) -> 'AsyncZeroconf': From 69942d5bfb4d92c6a312aea7c17f63fce0401e23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 11:31:40 -1000 Subject: [PATCH 0565/1433] Rename DNSNsec.next to DNSNsec.next_name (#908) --- tests/test_protocol.py | 1 + zeroconf/_dns.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index e90634750..75a69d5e1 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -734,6 +734,7 @@ def test_parse_packet_with_nsec_record(): nsec_record = parsed.answers[3] assert "nsec," in str(nsec_record) assert nsec_record.rdtypes == [16, 33] + assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local." def test_records_same_packet_share_fate(): diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 33484bfbb..7e06cff40 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -446,7 +446,7 @@ class DNSNsec(DNSRecord): """A DNS NSEC record""" - __slots__ = ('next', 'rdtypes') + __slots__ = ('next_name', 'rdtypes') def __init__( self, @@ -454,30 +454,32 @@ def __init__( type_: int, class_: int, ttl: int, - next: str, + next_name: str, rdtypes: List[int], created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_, ttl, created) - self.next = next + self.next_name = next_name self.rdtypes = rdtypes def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" return ( isinstance(other, DNSNsec) - and self.next == other.next + and self.next_name == other.next_name and self.rdtypes == other.rdtypes and DNSEntry.__eq__(self, other) ) def __hash__(self) -> int: """Hash to compare like DNSNSec.""" - return hash((*self._entry_tuple(), self.next, *self.rdtypes)) + return hash((*self._entry_tuple(), self.next_name, *self.rdtypes)) def __repr__(self) -> str: """String representation""" - return self.to_string(self.next + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes])) + return self.to_string( + self.next_name + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes]) + ) class DNSRRSet: From e63ca518c91cda7b9f460436aee4fdac1a7b9567 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 19:55:02 -1000 Subject: [PATCH 0566/1433] Remove duplicate unregister_all_services code (#910) --- zeroconf/_core.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 31bf2b32b..1d3e6cba6 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -619,21 +619,10 @@ async def async_unregister_all_services(self) -> None: def unregister_all_services(self) -> None: """Unregister all registered services.""" - # Send Goodbye packets https://datatracker.ietf.org/doc/html/rfc6762#section-10.1 - out = self.generate_unregister_all_services() - if not out: - return - now = current_time_millis() - next_time = now - i = 0 - while i < 3: - if now < next_time: - self.wait(next_time - now) - now = current_time_millis() - continue - self.send(out) - i += 1 - next_time += _UNREGISTER_TIME + assert self.loop is not None + run_coro_with_timeout( + self.async_unregister_all_services(), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS + ) async def async_check_service( self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False @@ -799,7 +788,14 @@ def close(self) -> None: This method is idempotent and irreversible. """ - self.unregister_all_services() + assert self.loop is not None + if self.loop.is_running(): + if self.loop == get_running_loop(): + log.warning( + "unregister_all_services skipped as it does blocking i/o; use AsyncZeroconf with asyncio" + ) + else: + self.unregister_all_services() self._close() self.engine.close() self._shutdown_threads() From 2d3da7a77699f88bd90ebc09d36b333690385f85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 20:20:02 -1000 Subject: [PATCH 0567/1433] Remove locking from ServiceRegistry (#911) - All calls to the ServiceRegistry are now done in async context which makes them thread safe. Locking is no longer needed. --- tests/services/test_registry.py | 52 +++++++++++++++---------------- tests/services/test_types.py | 8 ++--- tests/test_asyncio.py | 2 +- tests/test_core.py | 20 ++++++------ tests/test_handlers.py | 54 ++++++++++++++++----------------- zeroconf/_core.py | 10 +++--- zeroconf/_handlers.py | 8 ++--- zeroconf/_services/registry.py | 51 +++++++++++-------------------- 8 files changed, 95 insertions(+), 110 deletions(-) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 496cc629b..87c048d5c 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -23,10 +23,10 @@ def test_only_register_once(self): ) registry = r.ServiceRegistry() - registry.add(info) - self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) - registry.remove(info) - registry.add(info) + registry.async_add(info) + self.assertRaises(r.ServiceNameAlreadyRegistered, registry.async_add, info) + registry.async_remove(info) + registry.async_add(info) def test_unregister_multiple_times(self): """Verify we can unregister a service multiple times. @@ -46,10 +46,10 @@ def test_unregister_multiple_times(self): ) registry = r.ServiceRegistry() - registry.add(info) - self.assertRaises(r.ServiceNameAlreadyRegistered, registry.add, info) - registry.remove(info) - registry.remove(info) + registry.async_add(info) + self.assertRaises(r.ServiceNameAlreadyRegistered, registry.async_add, info) + registry.async_remove(info) + registry.async_remove(info) def test_lookups(self): type_ = "_test-srvc-type._tcp.local." @@ -62,13 +62,13 @@ def test_lookups(self): ) registry = r.ServiceRegistry() - registry.add(info) + registry.async_add(info) - assert registry.get_service_infos() == [info] - assert registry.get_info_name(registration_name) == info - assert registry.get_infos_type(type_) == [info] - assert registry.get_infos_server("ash-2.local.") == [info] - assert registry.get_types() == [type_] + assert registry.async_get_service_infos() == [info] + assert registry.async_get_info_name(registration_name) == info + assert registry.async_get_infos_type(type_) == [info] + assert registry.async_get_infos_server("ash-2.local.") == [info] + assert registry.async_get_types() == [type_] def test_lookups_upper_case_by_lower_case(self): type_ = "_test-SRVC-type._tcp.local." @@ -81,13 +81,13 @@ def test_lookups_upper_case_by_lower_case(self): ) registry = r.ServiceRegistry() - registry.add(info) + registry.async_add(info) - assert registry.get_service_infos() == [info] - assert registry.get_info_name(registration_name.lower()) == info - assert registry.get_infos_type(type_.lower()) == [info] - assert registry.get_infos_server("ash-2.local.") == [info] - assert registry.get_types() == [type_.lower()] + assert registry.async_get_service_infos() == [info] + assert registry.async_get_info_name(registration_name.lower()) == info + assert registry.async_get_infos_type(type_.lower()) == [info] + assert registry.async_get_infos_server("ash-2.local.") == [info] + assert registry.async_get_types() == [type_.lower()] def test_lookups_lower_case_by_upper_case(self): type_ = "_test-srvc-type._tcp.local." @@ -100,10 +100,10 @@ def test_lookups_lower_case_by_upper_case(self): ) registry = r.ServiceRegistry() - registry.add(info) + registry.async_add(info) - assert registry.get_service_infos() == [info] - assert registry.get_info_name(registration_name.upper()) == info - assert registry.get_infos_type(type_.upper()) == [info] - assert registry.get_infos_server("ASH-2.local.") == [info] - assert registry.get_types() == [type_] + assert registry.async_get_service_infos() == [info] + assert registry.async_get_info_name(registration_name.upper()) == info + assert registry.async_get_infos_type(type_.upper()) == [info] + assert registry.async_get_infos_server("ASH-2.local.") == [info] + assert registry.async_get_types() == [type_] diff --git a/tests/services/test_types.py b/tests/services/test_types.py index d14a8b250..f4206cf43 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -50,7 +50,7 @@ def test_integration_with_listener(self): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - zeroconf_registrar.registry.add(info) + zeroconf_registrar.registry.async_add(info) try: with patch.object( zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False @@ -87,7 +87,7 @@ def test_integration_with_listener_v6_records(self): "ash-2.local.", addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) - zeroconf_registrar.registry.add(info) + zeroconf_registrar.registry.async_add(info) try: with patch.object( zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False @@ -124,7 +124,7 @@ def test_integration_with_listener_ipv6(self): "ash-2.local.", addresses=[socket.inet_pton(socket.AF_INET6, addr)], ) - zeroconf_registrar.registry.add(info) + zeroconf_registrar.registry.async_add(info) try: with patch.object( zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False @@ -160,7 +160,7 @@ def test_integration_with_subtype_and_listener(self): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - zeroconf_registrar.registry.add(info) + zeroconf_registrar.registry.async_add(info) try: with patch.object( zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 34709d85b..9ec5e496c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -812,7 +812,7 @@ async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) - zeroconf_info.registry.add(info) + zeroconf_info.registry.async_add(info) # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_info.async_send diff --git a/tests/test_core.py b/tests/test_core.py index fee9c79dd..9a1a14f4c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -335,11 +335,11 @@ def test_goodbye_all_services(): info = r.ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) + zc.registry.async_add(info) out = zc.generate_unregister_all_services() assert out is not None first_packet = out.packets() - zc.registry.add(info) + zc.registry.async_add(info) out2 = zc.generate_unregister_all_services() assert out2 is not None second_packet = out.packets() @@ -348,7 +348,7 @@ def test_goodbye_all_services(): # Verify the registery is empty out3 = zc.generate_unregister_all_services() assert out3 is None - assert zc.registry.get_service_infos() == [] + assert zc.registry.async_get_service_infos() == [] zc.close() @@ -438,9 +438,9 @@ def test_tc_bit_defers(): info3 = r.ServiceInfo( type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) - zc.registry.add(info2) - zc.registry.add(info3) + zc.registry.async_add(info) + zc.registry.async_add(info2) + zc.registry.async_add(info3) protocol = zc.engine.protocols[0] now = r.current_time_millis() @@ -517,9 +517,9 @@ def test_tc_bit_defers_last_response_missing(): info3 = r.ServiceInfo( type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) - zc.registry.add(info2) - zc.registry.add(info3) + zc.registry.async_add(info) + zc.registry.async_add(info2) + zc.registry.async_add(info3) protocol = zc.engine.protocols[0] now = r.current_time_millis() @@ -581,7 +581,7 @@ def test_tc_bit_defers_last_response_missing(): assert source_ip not in protocol._timers # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e90a74bd9..d4144721b 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -87,7 +87,7 @@ def _process_outgoing_packet(out): expected_ttl = None for _ in range(3): _process_outgoing_packet(zc.generate_service_query(info)) - zc.registry.add(info) + zc.registry.async_add(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, None)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 @@ -112,7 +112,7 @@ def _process_outgoing_packet(out): # unregister expected_ttl = 0 - zc.registry.remove(info) + zc.registry.async_remove(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 @@ -121,7 +121,7 @@ def _process_outgoing_packet(out): expected_ttl = None for _ in range(3): _process_outgoing_packet(zc.generate_service_query(info)) - zc.registry.add(info) + zc.registry.async_add(info) # register service with custom TTL expected_ttl = const._DNS_HOST_TTL * 2 assert expected_ttl != const._DNS_HOST_TTL @@ -147,7 +147,7 @@ def _process_outgoing_packet(out): # unregister expected_ttl = 0 - zc.registry.remove(info) + zc.registry.async_remove(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 @@ -284,7 +284,7 @@ def test_any_query_for_ptr(): server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) - zc.registry.add(info) + zc.registry.async_add(info) _clear_cache(zc) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -297,7 +297,7 @@ def test_any_query_for_ptr(): assert multicast_out.answers[0][0].name == type_ assert multicast_out.answers[0][0].alias == registration_name # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() @@ -311,7 +311,7 @@ def test_aaaa_query(): server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) - zc.registry.add(info) + zc.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) @@ -322,7 +322,7 @@ def test_aaaa_query(): ) assert multicast_out.answers[0][0].address == ipv6_address # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() @@ -342,7 +342,7 @@ def test_a_and_aaaa_record_fate_sharing(): aaaa_record = info.dns_addresses(version=r.IPVersion.V6Only)[0] a_record = info.dns_addresses(version=r.IPVersion.V4Only)[0] - zc.registry.add(info) + zc.registry.async_add(info) # Test AAAA query generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -375,7 +375,7 @@ def test_a_and_aaaa_record_fate_sharing(): assert len(multicast_out.answers) == 1 assert len(multicast_out.additionals) == 1 # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() @@ -393,7 +393,7 @@ def test_unicast_response(): type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] ) # register - zc.registry.add(info) + zc.registry.async_add(info) _clear_cache(zc) # query @@ -420,7 +420,7 @@ def test_unicast_response(): assert has_srv and has_txt and has_a # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() @@ -535,7 +535,7 @@ def test_known_answer_supression(): info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) + zc.registry.async_add(info) now = current_time_millis() _clear_cache(zc) @@ -631,7 +631,7 @@ def test_known_answer_supression(): assert not multicast_out or not multicast_out.answers # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) zc.close() @@ -660,9 +660,9 @@ def test_multi_packet_known_answer_supression(): info3 = ServiceInfo( type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) - zc.registry.add(info2) - zc.registry.add(info3) + zc.registry.async_add(info) + zc.registry.async_add(info2) + zc.registry.async_add(info3) now = current_time_millis() _clear_cache(zc) @@ -683,9 +683,9 @@ def test_multi_packet_known_answer_supression(): assert unicast_out is None assert multicast_out is None # unregister - zc.registry.remove(info) - zc.registry.remove(info2) - zc.registry.remove(info3) + zc.registry.async_remove(info) + zc.registry.async_remove(info2) + zc.registry.async_remove(info3) zc.close() @@ -699,7 +699,7 @@ def test_known_answer_supression_service_type_enumeration_query(): info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) + zc.registry.async_add(info) type_2 = "_otherknown2._tcp.local." name = "knownname" @@ -709,7 +709,7 @@ def test_known_answer_supression_service_type_enumeration_query(): info2 = ServiceInfo( type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info2) + zc.registry.async_add(info2) now = current_time_millis() _clear_cache(zc) @@ -755,8 +755,8 @@ def test_known_answer_supression_service_type_enumeration_query(): assert not multicast_out or not multicast_out.answers # unregister - zc.registry.remove(info) - zc.registry.remove(info2) + zc.registry.async_remove(info) + zc.registry.async_remove(info2) zc.close() @@ -777,7 +777,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info) + zc.registry.async_add(info) type_2 = "_addtest2._tcp.local." name = "knownname" @@ -787,7 +787,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): info2 = ServiceInfo( type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] ) - zc.registry.add(info2) + zc.registry.async_add(info2) ptr_record = info.dns_pointer() @@ -888,7 +888,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): assert info2.dns_service() in unicast_out.additionals # unregister - zc.registry.remove(info) + zc.registry.async_remove(info) await aiozc.async_close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1d3e6cba6..5a7f9ff34 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -518,7 +518,7 @@ async def async_register_service( await self.async_wait_for_start() await self.async_check_service(info, allow_name_change, cooperating_responders) - self.registry.add(info) + self.registry.async_add(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) def update_service(self, info: ServiceInfo) -> None: @@ -534,7 +534,7 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that service.""" - self.registry.update(info) + self.registry.async_update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: @@ -587,18 +587,18 @@ def unregister_service(self, info: ServiceInfo) -> None: async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service.""" - self.registry.remove(info) + self.registry.async_remove(info) return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: """Generate a DNSOutgoing goodbye for all services and remove them from the registry.""" - service_infos = self.registry.get_service_infos() + service_infos = self.registry.async_get_service_infos() if not service_infos: return None out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) for info in service_infos: self._add_broadcast_answer(out, info, 0) - self.registry.remove(service_infos) + self.registry.async_remove(service_infos) return out async def async_unregister_all_services(self) -> None: diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index f9bdab742..07b9ac833 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -185,7 +185,7 @@ def _add_service_type_enumeration_query_answers( https://datatracker.ietf.org/doc/html/rfc6763#section-9 """ - for stype in self.registry.get_types(): + for stype in self.registry.async_get_types(): dns_pointer = DNSPointer( _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, now ) @@ -196,7 +196,7 @@ def _add_pointer_answers( self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float ) -> None: """Answer PTR/ANY question.""" - for service in self.registry.get_infos_type(name): + for service in self.registry.async_get_infos_type(name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer(created=now) @@ -216,7 +216,7 @@ def _add_address_answers( type_: int, ) -> None: """Answer A/AAAA/ANY question.""" - for service in self.registry.get_infos_server(name): + for service in self.registry.async_get_infos_server(name): answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() for dns_address in service.dns_addresses(created=now): @@ -247,7 +247,7 @@ def _answer_question( self._add_address_answers(question.name, answer_set, known_answers, now, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): - service = self.registry.get_info_name(question.name) # type: ignore + service = self.registry.async_get_info_name(question.name) # type: ignore if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index ebf5abbb6..4e64c8d7b 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -20,7 +20,6 @@ USA """ -import threading from typing import Dict, List, Optional, Union @@ -31,9 +30,8 @@ class ServiceRegistry: """A registry to keep track of services. - This class exists to ensure services can - be safely added and removed with thread - safety. + The registry must only be accessed from + the event loop as it is not thread safe. """ def __init__( @@ -43,56 +41,43 @@ def __init__( self._services: Dict[str, ServiceInfo] = {} self.types: Dict[str, List] = {} self.servers: Dict[str, List] = {} - self._lock = threading.Lock() # add and remove services thread safe - def add(self, info: ServiceInfo) -> None: + def async_add(self, info: ServiceInfo) -> None: """Add a new service to the registry.""" - with self._lock: - self._add(info) + self._add(info) - def remove(self, info: Union[List[ServiceInfo], ServiceInfo]) -> None: + def async_remove(self, info: Union[List[ServiceInfo], ServiceInfo]) -> None: """Remove a new service from the registry.""" - infos = info if isinstance(info, list) else [info] + self._remove(info if isinstance(info, list) else [info]) - with self._lock: - self._remove(infos) - - def update(self, info: ServiceInfo) -> None: + def async_update(self, info: ServiceInfo) -> None: """Update new service in the registry.""" + self._remove([info]) + self._add(info) - with self._lock: - self._remove([info]) - self._add(info) - - def get_service_infos(self) -> List[ServiceInfo]: + def async_get_service_infos(self) -> List[ServiceInfo]: """Return all ServiceInfo.""" return list(self._services.values()) - def get_info_name(self, name: str) -> Optional[ServiceInfo]: + def async_get_info_name(self, name: str) -> Optional[ServiceInfo]: """Return all ServiceInfo for the name.""" return self._services.get(name.lower()) - def get_types(self) -> List[str]: + def async_get_types(self) -> List[str]: """Return all types.""" return list(self.types.keys()) - def get_infos_type(self, type_: str) -> List[ServiceInfo]: + def async_get_infos_type(self, type_: str) -> List[ServiceInfo]: """Return all ServiceInfo matching type.""" - return self._get_by_index("types", type_) + return self._async_get_by_index("types", type_) - def get_infos_server(self, server: str) -> List[ServiceInfo]: + def async_get_infos_server(self, server: str) -> List[ServiceInfo]: """Return all ServiceInfo matching server.""" - return self._get_by_index("servers", server) + return self._async_get_by_index("servers", server) - def _get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: + def _async_get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" - # Since we do not get under a lock since it would be - # a performance issue, its possible - # the service can be unregistered during the get - # so we must check if info is None - return list( - filter(None, [self._services.get(name) for name in getattr(self, attr).get(key.lower(), [])[:]]) - ) + return [self._services[name] for name in getattr(self, attr).get(key.lower(), [])] def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" From b2a7a00f82d401066166776cecf0857ebbdb56ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 20:32:09 -1000 Subject: [PATCH 0568/1433] Update changelog for 0.33.0 (#912) --- README.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.rst b/README.rst index 7cefb3794..0d726d768 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,21 @@ See examples directory for more. Changelog ========= +0.33.0 (Unreleased) +=================== + +This release eliminates all threading locks as all non-threadsafe operations +now happen in the event loop. + +Technically backwards incompatible: + +* Remove duplicate unregister_all_services code (#910) @bdraco + + Calling Zeroconf.close from same asyncio event loop zeroconf is running in + will now skip unregister_all_services and log a warning as this a blocking + operation and is not async safe and never has been. + + Use AsyncZeroconf instead, or for legacy code call async_unregister_all_services before Zeroconf.close 0.32.1 ====== From 38eb271c952e89260ecac6fac3e723f4206c4648 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 21:03:01 -1000 Subject: [PATCH 0569/1433] Switch periodic cleanup task to call_later (#913) - Simplifies AsyncEngine to avoid the long running task --- tests/test_core.py | 19 +++++++++++++++++++ zeroconf/_core.py | 33 +++++++++++++++++---------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 9a1a14f4c..fd45b1ee5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -74,6 +74,7 @@ async def test_reaper(): entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) await asyncio.sleep(1.2) entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + assert zeroconf.cache.get(record_with_1s_ttl) is None await aiozc.async_close() assert not zeroconf.question_history.suppresses(question, now, other_known_answers) assert entries != original_entries @@ -82,6 +83,24 @@ async def test_reaper(): assert record_with_1s_ttl not in entries +@pytest.mark.asyncio +async def test_reaper_aborts_when_done(): + """Ensure cache cleanup stops when zeroconf is done.""" + with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): + assert _core._CACHE_CLEANUP_INTERVAL == 10 + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf = aiozc.zeroconf + record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) + assert zeroconf.cache.get(record_with_10s_ttl) is not None + assert zeroconf.cache.get(record_with_1s_ttl) is not None + await aiozc.async_close() + await asyncio.sleep(1.2) + assert zeroconf.cache.get(record_with_10s_ttl) is not None + assert zeroconf.cache.get(record_with_1s_ttl) is not None + + class Framework(unittest.TestCase): def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 5a7f9ff34..2909aa365 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -98,7 +98,7 @@ def __init__( self.senders: List[asyncio.DatagramTransport] = [] self._listen_socket = listen_socket self._respond_sockets = respond_sockets - self._cache_cleanup_task: Optional[asyncio.Task] = None + self._cleanup_timer: Optional[asyncio.TimerHandle] = None self._running_event: Optional[asyncio.Event] = None def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: @@ -110,8 +110,10 @@ def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[thr async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: """Set up the instance.""" assert self.loop is not None + self._cleanup_timer = self.loop.call_later( + millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup + ) await self._async_create_endpoints() - self._cache_cleanup_task = self.loop.create_task(self._async_cache_cleanup()) assert self._running_event is not None self._running_event.set() if loop_thread_ready: @@ -142,26 +144,25 @@ async def _async_create_endpoints(self) -> None: if s in sender_sockets: self.senders.append(cast(asyncio.DatagramTransport, transport)) - async def _async_cache_cleanup(self) -> None: + def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" - while not self.zc.done: - now = current_time_millis() - self.zc.question_history.async_expire(now) - self.zc.record_manager.async_updates( - now, [RecordUpdate(record, None) for record in self.zc.cache.async_expire(now)] - ) - self.zc.record_manager.async_updates_complete() - await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) + now = current_time_millis() + self.zc.question_history.async_expire(now) + self.zc.record_manager.async_updates( + now, [RecordUpdate(record, None) for record in self.zc.cache.async_expire(now)] + ) + self.zc.record_manager.async_updates_complete() + assert self.loop is not None + self._cleanup_timer = self.loop.call_later( + millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup + ) async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" self._async_shutdown() - if self._cache_cleanup_task: - self._cache_cleanup_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._cache_cleanup_task - self._cache_cleanup_task = None await asyncio.sleep(0) # flush out any call soons + assert self._cleanup_timer is not None + self._cleanup_timer.cancel() def _async_shutdown(self) -> None: """Shutdown transports and sockets.""" From aa7108481235cc018600d096b093c785447d8769 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Jul 2021 21:27:19 -1000 Subject: [PATCH 0570/1433] Remove Zeroconf.wait as its now unused in the codebase (#914) --- tests/services/test_browser.py | 4 ++-- tests/test_updates.py | 5 +++-- zeroconf/_core.py | 10 ---------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 26684e093..292dee259 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -371,7 +371,7 @@ def mock_incoming_msg( zeroconf, mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), ) - zeroconf.wait(100) + time.sleep(0.1) called_with_refresh_time_check = False @@ -693,7 +693,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.register_service(info_service) - zc.wait(1) + time.sleep(0.001) browser.cancel() diff --git a/tests/test_updates.py b/tests/test_updates.py index b1d7f1b76..ecdf89d4b 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,10 +1,11 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._services. """ +""" Unit tests for zeroconf._updates. """ import logging import socket +import time from threading import Event import pytest @@ -77,7 +78,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.register_service(info_service) - zc.wait(1) + time.sleep(0.001) browser.cancel() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 2909aa365..f605ab13a 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -21,8 +21,6 @@ """ import asyncio -import concurrent.futures -import contextlib import itertools import random import socket @@ -423,14 +421,6 @@ def done(self) -> bool: def listeners(self) -> List[RecordUpdateListener]: return self.record_manager.listeners - def wait(self, timeout: float) -> None: - """Calling task waits for a given number of milliseconds or until notified.""" - assert self.loop is not None - with contextlib.suppress(concurrent.futures.TimeoutError): - asyncio.run_coroutine_threadsafe(self.async_wait(timeout), self.loop).result( - millis_to_seconds(timeout) - ) - async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" assert self.notify_event is not None From b6eaf7249f386f573b0876204ccfdfa02ee9ac5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jul 2021 22:50:23 -1000 Subject: [PATCH 0571/1433] Reduce complexity of DNSRecord (#915) - Use constants for calculations in is_expired/is_stale/is_recent --- tests/services/test_info.py | 3 +-- zeroconf/_dns.py | 32 ++++++++++---------------------- zeroconf/const.py | 3 --- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 8ac8beda7..2060767f7 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -187,8 +187,7 @@ def test_service_info_rejects_expired_records(self): ttl, b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', ) - expired_record.created = 1000 - expired_record._expiration_time = 1000 + expired_record.set_created_ttl(1000, 1) info.update_record(zc, now, expired_record) assert info.properties[b"ci"] == b"2" zc.close() diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 7e06cff40..0f7a5e114 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -31,9 +31,6 @@ _CLASSES, _CLASS_MASK, _CLASS_UNIQUE, - _EXPIRE_FULL_TIME_PERCENT, - _EXPIRE_STALE_TIME_PERCENT, - _RECENT_TIME_PERCENT, _TYPES, _TYPE_ANY, ) @@ -45,6 +42,11 @@ _BASE_MAX_SIZE = _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT # type # class # ttl # length _NAME_COMPRESSION_MIN_SIZE = _LEN_BYTE * 2 +_EXPIRE_FULL_TIME_MS = 1000 +_EXPIRE_STALE_TIME_MS = 500 +_RECENT_TIME_MS = 250 + + if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 from ._protocol import DNSIncoming, DNSOutgoing # pylint: disable=cyclic-import @@ -154,7 +156,7 @@ class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" - __slots__ = ('ttl', 'created', '_expiration_time', '_stale_time', '_recent_time') + __slots__ = ('ttl', 'created') # TODO: Switch to just int ttl def __init__( @@ -163,9 +165,6 @@ def __init__( super().__init__(name, type_, class_) self.ttl = ttl self.created = created or current_time_millis() - self._expiration_time: Optional[float] = None - self._stale_time: Optional[float] = None - self._recent_time: Optional[float] = None def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" @@ -189,27 +188,19 @@ def get_expiration_time(self, percent: int) -> float: # TODO: Switch to just int here def get_remaining_ttl(self, now: float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" - if self._expiration_time is None: - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - return max(0, millis_to_seconds(self._expiration_time - now)) + return max(0, millis_to_seconds((self.created + (_EXPIRE_FULL_TIME_MS * self.ttl)) - now)) def is_expired(self, now: float) -> bool: """Returns true if this record has expired.""" - if self._expiration_time is None: - self._expiration_time = self.get_expiration_time(_EXPIRE_FULL_TIME_PERCENT) - return self._expiration_time <= now + return self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) <= now def is_stale(self, now: float) -> bool: """Returns true if this record is at least half way expired.""" - if self._stale_time is None: - self._stale_time = self.get_expiration_time(_EXPIRE_STALE_TIME_PERCENT) - return self._stale_time <= now + return self.created + (_EXPIRE_STALE_TIME_MS * self.ttl) <= now def is_recent(self, now: float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" - if self._recent_time is None: - self._recent_time = self.get_expiration_time(_RECENT_TIME_PERCENT) - return self._recent_time > now + return self.created + (_RECENT_TIME_MS * self.ttl) > now def reset_ttl(self, other: 'DNSRecord') -> None: """Sets this record's TTL and created time to that of @@ -220,9 +211,6 @@ def set_created_ttl(self, created: float, ttl: Union[float, int]) -> None: """Set the created and ttl of a record.""" self.created = created self.ttl = ttl - self._expiration_time = None - self._stale_time = None - self._recent_time = None def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use """Abstract method""" diff --git a/zeroconf/const.py b/zeroconf/const.py index 76a75dbde..27dc817f8 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -145,10 +145,7 @@ _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') _HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') -_EXPIRE_FULL_TIME_PERCENT = 100 -_EXPIRE_STALE_TIME_PERCENT = 50 _EXPIRE_REFRESH_TIME_PERCENT = 75 -_RECENT_TIME_PERCENT = 25 _LOCAL_TRAILER = '.local.' _TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' From 919b096d6260a4f9f4306b9b4dddb5b026b49462 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 18:07:46 -1000 Subject: [PATCH 0572/1433] Let connection_lost close the underlying socket (#918) - The socket was closed during shutdown before asyncio's connection_lost handler had a chance to close it which resulted in a traceback on win32. - Fixes #917 --- zeroconf/_core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index f605ab13a..37b72d593 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -166,8 +166,6 @@ def _async_shutdown(self) -> None: """Shutdown transports and sockets.""" for transport in itertools.chain(self.senders, self.readers): transport.close() - for s in self._respond_sockets: - s.close() def close(self) -> None: """Close from sync context.""" @@ -328,6 +326,9 @@ def error_received(self, exc: Exception) -> None: def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) + def connection_lost(self, exc: Optional[Exception]) -> None: + """Handle connection lost.""" + class Zeroconf(QuietLogger): From 96be9618ede3c941e23cb23398b9aed11bed1ffa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 18:11:24 -1000 Subject: [PATCH 0573/1433] Update changelog for 0.33.0 release (#919) --- README.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0d726d768..649d3bd88 100644 --- a/README.rst +++ b/README.rst @@ -146,9 +146,17 @@ Changelog This release eliminates all threading locks as all non-threadsafe operations now happen in the event loop. +* Let connection_lost close the underlying socket (#918) @bdraco + + The socket was closed during shutdown before asyncio's connection_lost + handler had a chance to close it which resulted in a traceback on + windows. + + Fixed #917 + Technically backwards incompatible: -* Remove duplicate unregister_all_services code (#910) @bdraco +* Removed duplicate unregister_all_services code (#910) @bdraco Calling Zeroconf.close from same asyncio event loop zeroconf is running in will now skip unregister_all_services and log a warning as this a blocking From 2e0000252f0aecad8b62a649128326a6528b6824 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 18:29:18 -1000 Subject: [PATCH 0574/1433] Add support for bump2version (#920) --- requirements-dev.txt | 1 + setup.cfg | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index dc2f21de2..3035d59d6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ autopep8 black;implementation_name=="cpython" +bump2version coveralls coverage # Version restricted because of https://github.com/PyCQA/pycodestyle/issues/741 - is fixed diff --git a/setup.cfg b/setup.cfg index e208561b0..cfcfb4b0a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,13 @@ +[bumpversion] +current_version = 0.32.1 +commit = True +tag = True +tag_name = {new_version} + +[bumpversion:file:zeroconf/__init__.py] +search = __version__ = '{current_version}' +replace = __version__ = '{new_version}' + [tool:pytest] testpaths = tests From b0b23f96d3b33a627a0d071557a36af97a65dae4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 18:50:47 -1000 Subject: [PATCH 0575/1433] Fix examples/async_registration.py attaching to the correct loop (#921) --- examples/async_registration.py | 49 ++++++++++++++++------------------ 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/examples/async_registration.py b/examples/async_registration.py index 53d14ce1a..c3aab326a 100644 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -5,27 +5,32 @@ import asyncio import logging import socket -import time -from typing import List +from typing import List, Optional from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf -async def register_services(infos: List[AsyncServiceInfo]) -> None: - tasks = [aiozc.async_register_service(info) for info in infos] - background_tasks = await asyncio.gather(*tasks) - await asyncio.gather(*background_tasks) - - -async def unregister_services(infos: List[AsyncServiceInfo]) -> None: - tasks = [aiozc.async_unregister_service(info) for info in infos] - background_tasks = await asyncio.gather(*tasks) - await asyncio.gather(*background_tasks) +class AsyncRunner: + def __init__(self, ip_version: IPVersion) -> None: + self.ip_version = ip_version + self.aiozc: Optional[AsyncZeroconf] = None + async def register_services(self, infos: List[AsyncServiceInfo]) -> None: + self.aiozc = AsyncZeroconf(ip_version=self.ip_version) + tasks = [self.aiozc.async_register_service(info) for info in infos] + background_tasks = await asyncio.gather(*tasks) + await asyncio.gather(*background_tasks) + print("Finished registration, press Ctrl-C to exit...") + while True: + await asyncio.sleep(1) -async def close_aiozc(aiozc: AsyncZeroconf) -> None: - await aiozc.async_close() + async def unregister_services(self, infos: List[AsyncServiceInfo]) -> None: + assert self.aiozc is not None + tasks = [self.aiozc.async_unregister_service(info) for info in infos] + background_tasks = await asyncio.gather(*tasks) + await asyncio.gather(*background_tasks) + await self.aiozc.async_close() if __name__ == '__main__': @@ -60,18 +65,10 @@ async def close_aiozc(aiozc: AsyncZeroconf) -> None: ) ) - print("Registration of 250 services, press Ctrl-C to exit...") - aiozc = AsyncZeroconf(ip_version=ip_version) + print("Registration of 250 services...") loop = asyncio.get_event_loop() - loop.run_until_complete(register_services(infos)) - print("Registration complete.") + runner = AsyncRunner(ip_version) try: - while True: - time.sleep(0.1) + loop.run_until_complete(runner.register_services(infos)) except KeyboardInterrupt: - pass - finally: - print("Unregistering...") - loop.run_until_complete(unregister_services(infos)) - print("Unregistration complete.") - loop.run_until_complete(close_aiozc(aiozc)) + loop.run_until_complete(runner.unregister_services(infos)) From e4a96550398c408c3e1e6944662cc3093db912a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 19:04:32 -1000 Subject: [PATCH 0576/1433] Update changelog for 0.33.0 release (#922) --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 649d3bd88..b87cfb192 100644 --- a/README.rst +++ b/README.rst @@ -140,8 +140,8 @@ See examples directory for more. Changelog ========= -0.33.0 (Unreleased) -=================== +0.33.0 +====== This release eliminates all threading locks as all non-threadsafe operations now happen in the event loop. From cfb28aaf134e566d8a89b397967d1ad1ec66de35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 19:06:30 -1000 Subject: [PATCH 0577/1433] =?UTF-8?q?Bump=20version:=200.32.1=20=E2=86=92?= =?UTF-8?q?=200.33.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 9 ++++----- zeroconf/__init__.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/setup.cfg b/setup.cfg index cfcfb4b0a..53810dcd8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.32.1 +current_version = 0.33.0 commit = True tag = True tag_name = {new_version} @@ -13,9 +13,9 @@ testpaths = tests [flake8] show-source = 1 -application-import-names=zeroconf -max-line-length=110 -ignore=E203,W503,N818 +application-import-names = zeroconf +max-line-length = 110 +ignore = E203,W503,N818 [mypy] ignore_missing_imports = true @@ -28,7 +28,6 @@ warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true warn_return_any = true -# TODO: disallow untyped calls and defs once we have full type hint coverage disallow_untyped_calls = false disallow_untyped_defs = true diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 666914b2b..a5ba52740 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.32.1' +__version__ = '0.33.0' __license__ = 'LGPL' From ed80333896c0710857cc46b5af4d7ba3a81e07c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 22:47:25 -1000 Subject: [PATCH 0578/1433] Update changelog for 0.33.1 (#924) - Fixes overly restrictive directory permissions reported in #923 --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index b87cfb192..9c563dbbc 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,13 @@ See examples directory for more. Changelog ========= +0.33.1 +====== + +* Version number change only with less restrictive directory permissions + + Fixed #923 + 0.33.0 ====== From 6774de3e7f8b461ccb83675bbb05d47949df487b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 22:48:18 -1000 Subject: [PATCH 0579/1433] =?UTF-8?q?Bump=20version:=200.33.0=20=E2=86=92?= =?UTF-8?q?=200.33.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 53810dcd8..18312c8f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.0 +current_version = 0.33.1 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a5ba52740..7b5d02598 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.33.0' +__version__ = '0.33.1' __license__ = 'LGPL' From 1247acd2e6f6154a4e5f2e27a820c55329391d8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jul 2021 19:32:15 -1000 Subject: [PATCH 0580/1433] Remove some pylint workarounds (#925) --- zeroconf/_dns.py | 3 +-- zeroconf/_handlers.py | 3 +-- zeroconf/_logger.py | 2 +- zeroconf/_protocol.py | 3 +-- zeroconf/_services/__init__.py | 3 +-- zeroconf/_services/browser.py | 3 +-- zeroconf/_services/info.py | 3 +-- zeroconf/_updates.py | 3 +-- zeroconf/_utils/net.py | 2 +- 9 files changed, 9 insertions(+), 16 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 0f7a5e114..5b211060a 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -48,8 +48,7 @@ if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from ._protocol import DNSIncoming, DNSOutgoing # pylint: disable=cyclic-import + from ._protocol import DNSIncoming, DNSOutgoing @enum.unique diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 07b9ac833..5e9e6e22b 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -48,8 +48,7 @@ ) if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from ._core import Zeroconf # pylint: disable=cyclic-import + from ._core import Zeroconf _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index 78c211480..e779a7659 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -24,7 +24,7 @@ import sys from typing import Any, Dict, Union, cast -log = logging.getLogger(__name__.split('.')[0]) +log = logging.getLogger(__name__.split('.', maxsplit=1)[0]) log.addHandler(logging.NullHandler()) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index f987ce2e2..7ca277998 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -51,8 +51,7 @@ if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from ._cache import DNSCache # pylint: disable=cyclic-import + from ._cache import DNSCache class DNSMessage: diff --git a/zeroconf/_services/__init__.py b/zeroconf/_services/__init__.py index 3759f1ec9..5b9fbf014 100644 --- a/zeroconf/_services/__init__.py +++ b/zeroconf/_services/__init__.py @@ -25,8 +25,7 @@ if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from .._core import Zeroconf # pylint: disable=cyclic-import + from .._core import Zeroconf @enum.unique diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index fecf35c9f..51f2c8d59 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -65,8 +65,7 @@ } if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from .._core import Zeroconf # pylint: disable=cyclic-import + from .._core import Zeroconf _QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index d1bf17e9b..cede3877d 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -53,8 +53,7 @@ if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from .._core import Zeroconf # pylint: disable=cyclic-import + from .._core import Zeroconf def instance_name_from_service_info(info: "ServiceInfo") -> str: diff --git a/zeroconf/_updates.py b/zeroconf/_updates.py index d7ad56c1a..bc7dcab56 100644 --- a/zeroconf/_updates.py +++ b/zeroconf/_updates.py @@ -27,8 +27,7 @@ if TYPE_CHECKING: - # https://github.com/PyCQA/pylint/issues/3525 - from ._core import Zeroconf # pylint: disable=cyclic-import + from ._core import Zeroconf class RecordUpdate(NamedTuple): diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 937dc1160..d7f127ecc 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -211,7 +211,7 @@ def set_mdns_port_socket_options_for_ip_version( s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) -def new_socket( # pylint: disable=too-many-branches +def new_socket( bind_addr: Union[Tuple[str], Tuple[str, int, int]], port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, From 73e3d1865f4167e7c9f7c23ec4cc7ebfac40f512 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 09:56:13 -0500 Subject: [PATCH 0581/1433] Skip ipv6 interfaces that return ENODEV (#930) --- tests/utils/test_net.py | 8 ++++++++ zeroconf/_utils/net.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 399bd6acd..238e709c1 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -182,6 +182,14 @@ def test_add_multicast_member(): with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): assert netutils.add_multicast_member(sock, interface) is False + # ENODEV should raise for ipv4 + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): + netutils.add_multicast_member(sock, interface) is False + + # ENODEV should return False for ipv6 + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): + assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False + # No error should return True with patch("socket.socket.setsockopt"): assert netutils.add_multicast_member(sock, interface) is True diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index d7f127ecc..3aafe7681 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -291,6 +291,13 @@ def add_multicast_member( interface, ) return False + if is_v6 and _errno == errno.ENODEV: + log.info( + 'Address in use when adding %s to multicast group, ' + 'it is expected to happen when the device does not have ipv6', + interface, + ) + return False raise return True From 97e0b669be60f716e45e963f1bcfcd35b7213626 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 10:07:59 -0500 Subject: [PATCH 0582/1433] Handle duplicate goodbye answers in the same packet (#928) - Solves an exception being thrown when we tried to remove the known answer from the cache when the second goodbye answer in the same packet was processed - We previously swallowed all exceptions on cache removal so this was not visible until 0.32.x which removed the broad exception catch Fixes #926 --- tests/test_handlers.py | 32 ++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 4 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index d4144721b..ebe19f41e 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1122,3 +1122,35 @@ async def test_guard_against_low_ptr_ttl(): assert incoming_answer_normal.ttl == const._DNS_OTHER_TTL assert zc.cache.async_get_unique(good_bye_answer) is None await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_duplicate_goodbye_answers_in_packet(): + """Ensure we do not throw an exception when there are duplicate goodbye records in a packet.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + answer_with_normal_ttl = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'host.local.', + ) + good_bye_answer = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 0, + 'host.local.', + ) + response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + response.add_answer_at_time(answer_with_normal_ttl, 0) + incoming = r.DNSIncoming(response.packets()[0]) + zc.record_manager.async_updates_from_response(incoming) + + response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + response.add_answer_at_time(good_bye_answer, 0) + response.add_answer_at_time(good_bye_answer, 0) + incoming = r.DNSIncoming(response.packets()[0]) + zc.record_manager.async_updates_from_response(incoming) + await aiozc.async_close() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 5e9e6e22b..29ea0b6be 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -331,7 +331,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: updates: List[RecordUpdate] = [] address_adds: List[DNSAddress] = [] other_adds: List[DNSRecord] = [] - removes: List[DNSRecord] = [] + removes: Set[DNSRecord] = set() now = msg.now unique_types: Set[Tuple[str, int, int]] = set() @@ -355,7 +355,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # expired and exists in the cache elif maybe_entry is not None: updates.append(RecordUpdate(record, maybe_entry)) - removes.append(record) + removes.add(record) if unique_types: self._async_mark_unique_cached_records_older_than_1s_to_expire(unique_types, msg.answers, now) From c80b5f7253e521928d6f7e54681675be59371c6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 10:11:24 -0500 Subject: [PATCH 0583/1433] Update changelog for 0.33.2 (#931) --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index 9c563dbbc..b53656be0 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,18 @@ See examples directory for more. Changelog ========= +0.33.2 +====== + +* Handle duplicate goodbye answers in the same packet (#928) @bdraco + + Solves an exception being thrown when we tried to remove the known answer + from the cache when the second goodbye answer in the same packet was processed + + Fixed #926 + +* Skip ipv6 interfaces that return ENODEV (#930) @bdraco + 0.33.1 ====== From 4d30c25fe57425bcae36a539006e44941ef46e2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Jul 2021 05:22:17 -1000 Subject: [PATCH 0584/1433] =?UTF-8?q?Bump=20version:=200.33.1=20=E2=86=92?= =?UTF-8?q?=200.33.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 18312c8f6..65b769fdb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.1 +current_version = 0.33.2 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 7b5d02598..69166393c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.33.1' +__version__ = '0.33.2' __license__ = 'LGPL' From 319992bb093d9b965976bad724512d9bcd05aca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 16:17:56 -0500 Subject: [PATCH 0585/1433] Provide sockname when logging a protocol error (#935) --- tests/test_logger.py | 21 +++++++++++++++++++++ zeroconf/_core.py | 30 ++++++++++++++++++++---------- zeroconf/_logger.py | 11 +++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/test_logger.py b/tests/test_logger.py index cedda7e95..2d8bbb086 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -61,3 +61,24 @@ def test_log_exception_warning(): assert not mock_log_warning.mock_calls assert mock_log_debug.mock_calls + + +def test_log_exception_once(): + """Test we only log with warning level once.""" + quiet_logger = QuietLogger() + exc = Exception() + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_exception_once(exc, "the exceptional exception warning") + + assert mock_log_warning.mock_calls + assert not mock_log_debug.mock_calls + + with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( + "zeroconf._logger.log.debug" + ) as mock_log_debug: + quiet_logger.log_exception_once(exc, "the exceptional exception warning") + + assert not mock_log_warning.mock_calls + assert mock_log_debug.mock_calls diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 37b72d593..b23206014 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -226,10 +226,10 @@ def datagram_received( if self.suppress_duplicate_packet(data, now): # Guard against duplicate packets log.debug( - 'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]', + 'Ignoring duplicate message received from %r:%r [socket %s] (%d bytes) as [%r]', addr, port, - self.transport.get_extra_info('socket').fileno(), + self._socket_description, len(data), data, ) @@ -249,20 +249,20 @@ def datagram_received( msg = DNSIncoming(data, scope, now) if msg.valid: log.debug( - 'Received from %r:%r (socket %d): %r (%d bytes) as [%r]', + 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', addr, port, - self.transport.get_extra_info('socket').fileno(), + self._socket_description, msg, len(data), data, ) else: log.debug( - 'Received from %r:%r (socket %d): (%d bytes) [%r]', + 'Received from %r:%r [socket %s]: (%d bytes) [%r]', addr, port, - self.transport.get_extra_info('socket').fileno(), + self._socket_description, len(data), data, ) @@ -316,12 +316,22 @@ def _respond_query( self.zc.handle_assembled_query(packets, addr, port, v6_flow_scope) + @property + def _socket_description(self) -> str: + """A human readable description of the socket.""" + assert self.transport is not None + fileno = self.transport.get_extra_info('socket').fileno() + sockname = self.transport.get_extra_info('sockname') + return f"{fileno} ({sockname})" + def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" - assert self.transport is not None - self.log_warning_once( - 'Error with socket %d: %s', self.transport.get_extra_info('socket').fileno(), exc - ) + # We preformat the message string with the socket as we want + # log_exception_once to log a warrning message once PER EACH + # different socket in case there are problems with multiple + # sockets + msg_str = f"Error with socket {self._socket_description}): %s" + self.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index e779a7659..932d1a2f1 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -61,3 +61,14 @@ def log_warning_once(cls, *args: Any) -> None: logger = log.debug cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 logger(*args) + + @classmethod + def log_exception_once(cls, exc: Exception, *args: Any) -> None: + msg_str = args[0] + if msg_str not in cls._seen_logs: + cls._seen_logs[msg_str] = 0 + logger = log.warning + else: + logger = log.debug + cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 + logger(*args, exc_info=exc) From 5682a4c3c89043bf8a10e79232933ada5ab71972 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 16:18:14 -0500 Subject: [PATCH 0586/1433] Add support for forward dns compression pointers (#934) - nslookup supports these and some implementations (likely avahi) will generate them - Careful attention was given to make sure we detect loops and do not create anti-patterns described in https://github.com/Forescout/namewreck/blob/main/rfc/draft-dashevskyi-dnsrr-antipatterns-00.txt Fixes https://github.com/home-assistant/core/issues/53937 Fixes https://github.com/home-assistant/core/issues/46985 Fixes https://github.com/home-assistant/core/issues/53668 Fixes #308 --- tests/test_protocol.py | 226 +++++++++++++++++++++++++++++++++++++++++ zeroconf/_protocol.py | 202 +++++++++++++++++++++--------------- 2 files changed, 346 insertions(+), 82 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 75a69d5e1..706afdd33 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -632,6 +632,10 @@ def test_dns_compression_rollback_for_corruption(): # ensure there is no corruption with the dns compression incoming = r.DNSIncoming(packet) assert incoming.valid is True + assert ( + len(incoming.answers) + == incoming.num_answers + incoming.num_authorities + incoming.num_additionals + ) def test_tc_bit_in_query_packet(): @@ -761,3 +765,225 @@ def test_records_same_packet_share_fate(): first_time = dnsin.answers[0].created for answer in dnsin.answers: assert answer.created == first_time + + +def test_dns_compression_invalid_skips_bad_name_compress_in_question(): + """Test our wire parser can skip bad compression in questions.""" + packet = ( + b'\x00\x00\x00\x00\x00\x04\x00\x00\x00\x07\x00\x00\x11homeassistant1128\x05l' + b'ocal\x00\x00\xff\x00\x014homeassistant1128 [534a4794e5ed41879ecf012252d3e02' + b'a]\x0c_workstation\x04_tcp\xc0\x1e\x00\xff\x00\x014homeassistant1127 [534a47' + b'94e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1123 [534a479' + b'4e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1118 [534a4794' + b'e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x01\xc0\x0c\x00\x01\x80' + b'\x01\x00\x00\x00x\x00\x04\xc0\xa8<\xc3\xc0v\x00\x10\x80\x01\x00\x00\x00' + b'x\x00\x01\x00\xc0v\x00!\x80\x01\x00\x00\x00x\x00\x1f\x00\x00\x00\x00' + b'\x00\x00\x11homeassistant1127\x05local\x00\xc0\xb1\x00\x10\x80' + b'\x01\x00\x00\x00x\x00\x01\x00\xc0\xb1\x00!\x80\x01\x00\x00\x00x\x00\x1f' + b'\x00\x00\x00\x00\x00\x00\x11homeassistant1123\x05local\x00\xc0)\x00\x10\x80' + b'\x01\x00\x00\x00x\x00\x01\x00\xc0)\x00!\x80\x01\x00\x00\x00x\x00\x1f' + b'\x00\x00\x00\x00\x00\x00\x11homeassistant1128\x05local\x00' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.questions) == 4 + + +def test_dns_compression_all_invalid(): + """Test our wire parser can skip all invalid data.""" + packet = ( + b'\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00!roborock-vacuum-s5e_miio416' + b'112328\x00\x00/\x80\x01\x00\x00\x00x\x00\t\xc0P\x00\x05@\x00\x00\x00\x00' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.questions) == 0 + assert len(parsed.answers) == 0 + + +def test_invalid_next_name_ignored(): + """Test our wire parser does not throw an an invalid next name. + + The RFC states it should be ignored when used with mDNS. + """ + packet = ( + b'\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x07Android\x05local\x00\x00' + b'\xff\x00\x01\xc0\x0c\x00/\x00\x01\x00\x00\x00x\x00\x08\xc02\x00\x04@' + b'\x00\x00\x08\xc0\x0c\x00\x01\x00\x01\x00\x00\x00x\x00\x04\xc0\xa8X<' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.questions) == 1 + assert len(parsed.answers) == 2 + + +def test_dns_compression_invalid_skips_record(): + """Test our wire parser can skip records we do not know how to parse.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x06\x00\x00\x00\x00\x04_hap\x04_tcp\x05local\x00\x00\x0c" + b"\x00\x01\x00\x00\x11\x94\x00\x16\x13eufy HomeBase2-2464\xc0\x0c\x04Eufy\xc0\x16\x00/" + b"\x80\x01\x00\x00\x00x\x00\x08\xc0\xa6\x00\x04@\x00\x00\x08\xc0'\x00/\x80\x01\x00\x00" + b"\x11\x94\x00\t\xc0'\x00\x05\x00\x00\x80\x00@\xc0=\x00\x01\x80\x01\x00\x00\x00x\x00\x04" + b"\xc0\xa8Dp\xc0'\x00!\x80\x01\x00\x00\x00x\x00\x08\x00\x00\x00\x00\xd1_\xc0=\xc0'\x00" + b"\x10\x80\x01\x00\x00\x11\x94\x00K\x04c#=1\x04ff=2\x14id=38:71:4F:6B:76:00\x08md=T8010" + b"\x06pv=1.1\x05s#=75\x04sf=1\x04ci=2\x0bsh=xaQk4g==" + ) + parsed = r.DNSIncoming(packet) + answer = r.DNSNsec( + 'eufy HomeBase2-2464._hap._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'eufy HomeBase2-2464._hap._tcp.local.', + [const._TYPE_TXT, const._TYPE_SRV], + ) + assert answer in parsed.answers + + +def test_dns_compression_points_forward(): + """Test our wire parser can unpack nsec records with compression.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x07\x00\x00\x00\x00\x0eTV Beneden (2)" + b"\x10_androidtvremote\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x11" + b"\x94\x00\x15\x14bt=D8:13:99:AC:98:F1\xc0\x0c\x00/\x80\x01\x00\x00\x11" + b"\x94\x00\t\xc0\x0c\x00\x05\x00\x00\x80\x00@\tAndroid-3\xc01\x00/\x80" + b"\x01\x00\x00\x00x\x00\x08\xc0\x9c\x00\x04@\x00\x00\x08\xc0l\x00\x01\x80" + b"\x01\x00\x00\x00x\x00\x04\xc0\xa8X\x0f\xc0\x0c\x00!\x80\x01\x00\x00\x00" + b"x\x00\x08\x00\x00\x00\x00\x19B\xc0l\xc0\x1b\x00\x0c\x00\x01\x00\x00\x11" + b"\x94\x00\x02\xc0\x0c\t_services\x07_dns-sd\x04_udp\xc01\x00\x0c\x00\x01" + b"\x00\x00\x11\x94\x00\x02\xc0\x1b" + ) + parsed = r.DNSIncoming(packet) + answer = r.DNSNsec( + 'TV Beneden (2)._androidtvremote._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'TV Beneden (2)._androidtvremote._tcp.local.', + [const._TYPE_TXT, const._TYPE_SRV], + ) + assert answer in parsed.answers + + +def test_dns_compression_points_to_itself(): + """Test our wire parser does not loop forever when a compression pointer points to itself.""" + packet = ( + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01" + b"\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0(\x00\x01\x80\x01\x00\x00\x00" + b"\x01\x00\x04\xc0\xa8\xd0\x06" + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + +def test_dns_compression_points_beyond_packet(): + """Test our wire parser does not fail when the compression pointer points beyond the packet.""" + packet = ( + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01' + b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xe7\x0f\x00\x01\x80\x01\x00\x00' + b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + +def test_dns_compression_generic_failure(): + """Test our wire parser does not loop forever when dns compression is corrupt.""" + packet = ( + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01' + b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00' + b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + +def test_label_length_attack(): + """Test our wire parser does not loop forever when the name exceeds 253 chars.""" + packet = ( + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' + b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x00\x00\x01\x80' + b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0\x0c\x00\x01\x80\x01\x00\x00\x00' + b'\x01\x00\x04\xc0\xa8\xd0\x06' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 0 + + +def test_label_compression_attack(): + """Test our wire parser does not loop forever when exceeding the maximum number of labels.""" + packet = ( + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03atk\x00\x00\x01\x80' + b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' + b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\xc0' + b'\x0c\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x06' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 1 + + +def test_dns_compression_loop_attack(): + """Test our wire parser does not loop forever when dns compression is in a loop.""" + packet = ( + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x03atk\x03dns\x05loc' + b'al\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x04a' + b'tk2\x04dns2\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05' + b'\x04atk3\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0' + b'\x05\x04atk4\x04dns5\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0' + b'\xa8\xd0\x05\x04atk5\x04dns2\xc0^\x00\x01\x80\x01\x00\x00\x00\x01\x00' + b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00' + b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00' + b'\x04\xc0\xa8\xd0\x05' + ) + parsed = r.DNSIncoming(packet) + assert len(parsed.answers) == 0 + + +def test_txt_after_invalid_nsec_name_still_usable(): + """Test that we can see the txt record after the invalid nsec record.""" + packet = ( + b'\x00\x00\x84\x00\x00\x00\x00\x06\x00\x00\x00\x00\x06_sonos\x04_tcp\x05loc' + b'al\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x15\x12Sonos-542A1BC9220E' + b'\xc0\x0c\x12Sonos-542A1BC9220E\xc0\x18\x00/\x80\x01\x00\x00\x00x\x00' + b'\x08\xc1t\x00\x04@\x00\x00\x08\xc0)\x00/\x80\x01\x00\x00\x11\x94\x00' + b'\t\xc0)\x00\x05\x00\x00\x80\x00@\xc0)\x00!\x80\x01\x00\x00\x00x' + b'\x00\x08\x00\x00\x00\x00\x05\xa3\xc0>\xc0>\x00\x01\x80\x01\x00\x00\x00x' + b'\x00\x04\xc0\xa8\x02:\xc0)\x00\x10\x80\x01\x00\x00\x11\x94\x01*2info=/api' + b'/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers=1.24.1\nbo' + b'otseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn9K9DLXJe0f3LP9' + b'747lbvFvh.Q45RuMaeC07rfXh7OJGm str: @@ -168,60 +177,77 @@ def read_others(self) -> None: for _ in range(n): domain = self.read_name() type_, class_, ttl, length = self.unpack(b'!HHiH') - rec: Optional[DNSRecord] = None - if type_ == _TYPE_A: - rec = DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) - elif type_ in (_TYPE_CNAME, _TYPE_PTR): - rec = DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) - elif type_ == _TYPE_TXT: - rec = DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) - elif type_ == _TYPE_SRV: - rec = DNSService( - domain, - type_, - class_, - ttl, - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_name(), - self.now, - ) - elif type_ == _TYPE_HINFO: - rec = DNSHinfo( + end = self.offset + length + rec = None + try: + rec = self.read_record(domain, type_, class_, ttl, length) + except DECODE_EXCEPTIONS: + # Skip records that fail to decode if we know the length + # If the packet is really corrupt read_name and the unpack + # above would fail and hit the exception catch in read_others + self.offset = end + log.debug( + 'Unable to parse; skipping record for %s with type %s at offset %d while unpacking %r', domain, - type_, - class_, - ttl, - self.read_character_string().decode('utf-8'), - self.read_character_string().decode('utf-8'), - self.now, - ) - elif type_ == _TYPE_AAAA: - rec = DNSAddress( - domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id + _TYPES.get(type_, type_), + self.offset, + self.data, + exc_info=True, ) - elif type_ == _TYPE_NSEC: - name_start = self.offset - name = self.read_name() - rec = DNSNsec( - domain, - type_, - class_, - ttl, - name, - self.read_bitmap(name_start + length), - self.now, - ) - else: - # Try to ignore types we don't know about - # Skip the payload for the resource record so the next - # records can be parsed correctly - self.offset += length - if rec is not None: self.answers.append(rec) + def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: + """Read known records types and skip unknown ones.""" + if type_ == _TYPE_A: + return DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) + if type_ in (_TYPE_CNAME, _TYPE_PTR): + return DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) + if type_ == _TYPE_TXT: + return DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) + if type_ == _TYPE_SRV: + return DNSService( + domain, + type_, + class_, + ttl, + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_name(), + self.now, + ) + if type_ == _TYPE_HINFO: + return DNSHinfo( + domain, + type_, + class_, + ttl, + self.read_character_string().decode('utf-8'), + self.read_character_string().decode('utf-8'), + self.now, + ) + if type_ == _TYPE_AAAA: + return DNSAddress( + domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id + ) + if type_ == _TYPE_NSEC: + name_start = self.offset + return DNSNsec( + domain, + type_, + class_, + ttl, + self.read_name(), + self.read_bitmap(name_start + length), + self.now, + ) + # Try to ignore types we don't know about + # Skip the payload for the resource record so the next + # records can be parsed correctly + self.offset += length + return None + def read_bitmap(self, end: int) -> List[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] @@ -236,39 +262,51 @@ def read_bitmap(self, end: int) -> List[int]: return rdtypes def read_name(self) -> str: - """Reads a domain name from the packet""" - result = '' - off = self.offset - next_ = -1 - first = off - + """Reads a domain name from the packet.""" + labels: List[str] = [] + self.seen_pointers.clear() + self.offset = self._decode_labels_at_offset(self.offset, labels) + labels.append("") + name = ".".join(labels) + if len(name) > MAX_NAME_LENGTH: + raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") + return name + + def _decode_labels_at_offset(self, off: int, labels: List[str]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. - while True: + while off < self.data_len: length = self.data[off] - off += 1 if length == 0: - break - t = length & 0xC0 - if t == 0x00: - # Convert to utf-8 - result += str(self.data[off : off + length], 'utf-8', 'replace') + '.' - off += length - elif t == 0xC0: - if next_ < 0: - next_ = off + 1 - off = ((length & 0x3F) << 8) | self.data[off] - if off >= first: - raise IncomingDecodeError(f"Bad domain name (circular) at {off}") - first = off - else: - raise IncomingDecodeError(f"Bad domain name at {off}") - - if next_ >= 0: - self.offset = next_ - else: - self.offset = off - - return result + return off + DNS_COMPRESSION_HEADER_LEN + + if length < 0x40: + label_idx = off + DNS_COMPRESSION_HEADER_LEN + labels.append(str(self.data[label_idx : label_idx + length], 'utf-8', 'replace')) + off += DNS_COMPRESSION_HEADER_LEN + length + continue + + if length < 0xC0: + raise IncomingDecodeError(f"DNS compression type {length} is unknown at {off}") + + # We have a DNS compression pointer + link = (length & 0x3F) * 256 + self.data[off + 1] + if link > self.data_len: + raise IncomingDecodeError(f"DNS compression pointer at {off} points to {link} beyond packet") + if link == off: + raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") + if link in self.seen_pointers: + raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") + self.seen_pointers.add(link) + linked_labels = self.name_cache.get(link, []) + if not linked_labels: + self._decode_labels_at_offset(link, linked_labels) + self.name_cache[link] = linked_labels + labels.extend(linked_labels) + if len(labels) > MAX_DNS_LABELS: + raise IncomingDecodeError(f"Maximum dns labels reached while processing pointer at {off}") + return off + DNS_COMPRESSION_POINTER_LEN + + raise IncomingDecodeError("Corrupt packet received while decoding name") class DNSOutgoing(DNSMessage): From 6a140cc6b9c7e50e572456662d2f76f6fbc2ed25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 16:22:41 -0500 Subject: [PATCH 0587/1433] Update changelog for 0.33.3 (#936) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index b53656be0..6a1fd287e 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,13 @@ See examples directory for more. Changelog ========= +0.33.3 +====== + +* Added support for forward dns compression pointers (#934) @bdraco + +* Provide sockname when logging a protocol error (#935) @bdraco + 0.33.2 ====== From 206671a1237ee8237d302b04c5a84158fed1d50b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Aug 2021 16:54:55 -0500 Subject: [PATCH 0588/1433] =?UTF-8?q?Bump=20version:=200.33.2=20=E2=86=92?= =?UTF-8?q?=200.33.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 65b769fdb..81cf30775 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.2 +current_version = 0.33.3 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 69166393c..4efdd056d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.33.2' +__version__ = '0.33.3' __license__ = 'LGPL' From 496ac44e99b56485cc9197490e71bb2dd7bec6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Nov=C3=A1k?= Date: Fri, 6 Aug 2021 18:33:00 +0200 Subject: [PATCH 0589/1433] Ensure zeroconf can be loaded when the system disables IPv6 (#933) Co-authored-by: J. Nick Koston --- tests/services/test_info.py | 4 ++++ tests/test_asyncio.py | 6 +++++- tests/test_dns.py | 5 +++++ tests/test_handlers.py | 9 ++++++++- tests/test_protocol.py | 5 +++++ tests/utils/test_net.py | 4 ++++ zeroconf/_utils/net.py | 15 ++++++++++++--- zeroconf/const.py | 4 ---- 8 files changed, 43 insertions(+), 9 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 2060767f7..0464ae7b0 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -192,6 +192,8 @@ def test_service_info_rejects_expired_records(self): assert info.properties[b"ci"] == b"2" zc.close() + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_get_info_partial(self): zc = r.Zeroconf(interfaces=['127.0.0.1']) @@ -576,6 +578,8 @@ async def test_multiple_a_addresses(): await aiozc.async_close() +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_filter_address_by_type_from_service_info(): """Verify dns_addresses can filter by ipversion.""" desc = {'path': '/~paulsm/'} diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 9ec5e496c..355b1b144 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -5,6 +5,7 @@ import asyncio import logging +import os import socket import time import threading @@ -32,7 +33,7 @@ from zeroconf._services.info import ServiceInfo from zeroconf._utils.time import current_time_millis -from . import _clear_cache +from . import _clear_cache, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -349,6 +350,9 @@ async def test_async_wait_unblocks_on_update() -> None: @pytest.mark.asyncio async def test_service_info_async_request() -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" + if not has_working_ipv6() or os.environ.get('SKIP_IPV6'): + pytest.skip('Requires IPv6') + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" diff --git a/tests/test_dns.py b/tests/test_dns.py index 071e1f650..a952b81ec 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -4,6 +4,7 @@ """ Unit tests for zeroconf._dns. """ import logging +import os import socket import time import unittest @@ -18,6 +19,8 @@ ServiceInfo, ) +from . import has_working_ipv6 + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -52,6 +55,8 @@ def test_dns_pointer_repr(self): pointer = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') repr(pointer) + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_dns_address_repr(self): address = r.DNSAddress('irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, b'a') assert repr(address).endswith("b'a'") diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ebe19f41e..3d05032bb 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -5,6 +5,7 @@ import asyncio import logging +import os import pytest import socket import time @@ -19,7 +20,7 @@ from zeroconf.asyncio import AsyncZeroconf -from . import _clear_cache, _inject_response +from . import _clear_cache, _inject_response, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -274,6 +275,8 @@ def test_ptr_optimization(): zc.close() +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_any_query_for_ptr(): """Test that queries for ANY will return PTR records.""" zc = Zeroconf(interfaces=['127.0.0.1']) @@ -301,6 +304,8 @@ def test_any_query_for_ptr(): zc.close() +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_aaaa_query(): """Test that queries for AAAA records work.""" zc = Zeroconf(interfaces=['127.0.0.1']) @@ -326,6 +331,8 @@ def test_aaaa_query(): zc.close() +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_a_and_aaaa_record_fate_sharing(): """Test that queries for AAAA always return A records in the additionals.""" zc = Zeroconf(interfaces=['127.0.0.1']) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 706afdd33..8c2f92c4f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -5,6 +5,7 @@ import copy import logging +import os import socket import struct import unittest @@ -18,6 +19,8 @@ DNSText, ) +from . import has_working_ipv6 + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -468,6 +471,8 @@ def test_incoming_circular_reference(self): ) ).valid + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 238e709c1..41fdb7aa2 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -190,6 +190,10 @@ def test_add_multicast_member(): with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False + # No IPv6 support should return False for IPv6 + with patch("socket.inet_pton", side_effect=OSError()): + assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False + # No error should return True with patch("socket.socket.setsockopt"): assert netutils.add_multicast_member(sock, interface) is True diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 3aafe7681..bfae9db46 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -31,7 +31,7 @@ import ifaddr from .._logger import log -from ..const import _IPPROTO_IPV6, _MDNS_ADDR6_BYTES, _MDNS_ADDR_BYTES, _MDNS_PORT +from ..const import _IPPROTO_IPV6, _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT @enum.unique @@ -259,11 +259,20 @@ def add_multicast_member( log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) try: if is_v6: + try: + mdns_addr6_bytes = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) + except OSError: + log.info( + 'Unable to translate IPv6 address when adding %s to multicast group, ' + 'this can happen if IPv6 is disabled on the system', + interface, + ) + return False iface_bin = struct.pack('@I', cast(int, interface[1])) - _value = _MDNS_ADDR6_BYTES + iface_bin + _value = mdns_addr6_bytes + iface_bin listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) else: - _value = _MDNS_ADDR_BYTES + socket.inet_aton(cast(str, interface)) + _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface)) listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) except socket.error as e: _errno = get_errno(e) diff --git a/zeroconf/const.py b/zeroconf/const.py index 27dc817f8..4c23310cb 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -20,7 +20,6 @@ USA """ -import contextlib import re import socket @@ -44,10 +43,7 @@ # Some DNS constants _MDNS_ADDR = '224.0.0.251' -_MDNS_ADDR_BYTES = socket.inet_aton(_MDNS_ADDR) _MDNS_ADDR6 = 'ff02::fb' -with contextlib.suppress(OSError): # can't use AF_INET6, IPv6 is disabled - _MDNS_ADDR6_BYTES = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 From 858605db52f909d41198df76130597ff93f64cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Aug 2021 11:48:43 -0500 Subject: [PATCH 0590/1433] Update changelog for 0.33.4 (#937) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 6a1fd287e..ade5b8eec 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,11 @@ See examples directory for more. Changelog ========= +0.33.4 +====== + +* Ensure zeroconf can be loaded when the system disables IPv6 (#933) @che0 + 0.33.3 ====== From 7bbacd57a134c12ee1fb61d8318b312dfdae18f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Aug 2021 11:49:11 -0500 Subject: [PATCH 0591/1433] =?UTF-8?q?Bump=20version:=200.33.3=20=E2=86=92?= =?UTF-8?q?=200.33.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 81cf30775..f982b8061 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.3 +current_version = 0.33.4 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4efdd056d..f106525b8 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.33.3' +__version__ = '0.33.4' __license__ = 'LGPL' From 55efb4169b588cef093f3065f3a894878ae8bd95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 16:00:56 -0500 Subject: [PATCH 0592/1433] Implement Multicast Response Aggregation (#940) - Responses are now aggregated when possible per rules in RFC6762 section 6.4 - Responses that trigger the protection against against excessive packet flooding due to software bugs or malicious attack described in RFC6762 section 6 are delayed instead of discarding as it was causing responders that implement Passive Observation Of Failures (POOF) to evict the records. - Probe responses are now always sent immediately as there were cases where they would fail to be answered in time to defend a name. closes #939 --- tests/conftest.py | 13 + tests/services/test_types.py | 16 +- tests/test_asyncio.py | 40 ++- tests/test_core.py | 4 +- tests/test_handlers.py | 516 +++++++++++++++++++++++------------ zeroconf/_core.py | 41 ++- zeroconf/_handlers.py | 220 ++++++++++----- zeroconf/const.py | 2 + 8 files changed, 587 insertions(+), 265 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d4ea1632a..f900e0946 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,10 @@ import pytest +import unittest + +from zeroconf import _core, const + @pytest.fixture(autouse=True) def verify_threads_ended(): @@ -15,3 +19,12 @@ def verify_threads_ended(): yield threads = frozenset(threading.enumerate()) - threads_before assert not threads + + +@pytest.fixture +def run_isolated(): + """Change the mDNS port to run the test in isolation.""" + with unittest.mock.patch.object(_core, "_MDNS_PORT", 5454), unittest.mock.patch.object( + const, "_MDNS_PORT", 5454 + ): + yield diff --git a/tests/services/test_types.py b/tests/services/test_types.py index f4206cf43..b1c312db6 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -57,10 +57,10 @@ def test_integration_with_listener(self): ), patch.object( zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) assert type_ in service_types finally: @@ -94,10 +94,10 @@ def test_integration_with_listener_v6_records(self): ), patch.object( zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) assert type_ in service_types finally: @@ -131,10 +131,10 @@ def test_integration_with_listener_ipv6(self): ), patch.object( zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False ): - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) assert type_ in service_types finally: @@ -167,10 +167,10 @@ def test_integration_with_subtype_and_listener(self): ), patch.object( zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert discovery_type in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) assert discovery_type in service_types finally: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 355b1b144..39cad5b9b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -9,7 +9,7 @@ import socket import time import threading -from unittest.mock import patch +from unittest.mock import ANY, call, patch, MagicMock import pytest @@ -18,6 +18,7 @@ from zeroconf import ( DNSIncoming, DNSOutgoing, + DNSQuestion, DNSPointer, DNSService, DNSAddress, @@ -27,6 +28,7 @@ const, ) from zeroconf.const import _LISTENER_TIME +from zeroconf._core import AsyncListener from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered from zeroconf._services import ServiceListener import zeroconf._services.browser as _services_browser @@ -615,10 +617,10 @@ async def test_async_zeroconf_service_types(): await asyncio.sleep(0.2) _clear_cache(zeroconf_registrar.zeroconf) try: - service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=['127.0.0.1'], timeout=0.5) + service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=['127.0.0.1'], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar.zeroconf) - service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=0.5) + service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=2) assert type_ in service_types finally: @@ -951,3 +953,35 @@ async def test_async_request_timeout(): # 3000ms for the default timeout # 1000ms for loaded systems + schedule overhead assert (end_time - start_time) < 3000 + 1000 + + +@pytest.mark.asyncio +async def test_legacy_unicast_response(run_isolated): + """Verify legacy unicast responses include questions and correct id.""" + type_ = "_mservice._tcp.local." + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + + name = "xxxyyy" + registration_name = f"{name}.{type_}" + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + + aiozc.zeroconf.registry.async_add(info) + query = DNSOutgoing(const._FLAGS_QR_QUERY, multicast=False, id_=888) + question = DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + + with patch.object(aiozc.zeroconf, "async_send") as send_mock: + aiozc.zeroconf.engine.protocols[0].datagram_received(query.packets()[0], ('127.0.0.1', 6503)) + + calls = send_mock.mock_calls + assert calls == [call(ANY, '127.0.0.1', 6503, ())] + outgoing = send_mock.call_args[0][0] + assert isinstance(outgoing, DNSOutgoing) + assert outgoing.questions == [question] + assert outgoing.id == query.id + await aiozc.async_close() diff --git a/tests/test_core.py b/tests/test_core.py index fd45b1ee5..e2420a78b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -696,10 +696,10 @@ def test_guard_against_oversized_packets(): listener = _core.AsyncListener(zc) listener.transport = unittest.mock.MagicMock() - listener.datagram_received(ok_packet, ('127.0.0.1', 5353)) + listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) assert zc.cache.async_get_unique(okpacket_record) is not None - listener.datagram_received(over_sized_packet, ('127.0.0.1', 5353)) + listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) assert ( zc.cache.async_get_unique( r.DNSText( diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 3d05032bb..9049408dc 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -16,7 +16,7 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, current_time_millis from zeroconf import const -from zeroconf._dns import DNSRRSet +from zeroconf._handlers import construct_outgoing_multicast_answers from zeroconf.asyncio import AsyncZeroconf @@ -101,10 +101,10 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT - )[1] - _process_outgoing_packet(multicast_out) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False + ) + _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # The additonals should all be suppresed since they are all in the answers section # @@ -138,11 +138,10 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) - _process_outgoing_packet( - zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT - )[1] + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) + _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 @@ -208,7 +207,7 @@ def test_register_and_lookup_type_by_uppercase_name(self): out = r.DNSOutgoing(const._FLAGS_QR_QUERY) out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN)) zc.send(out) - time.sleep(0.5) + time.sleep(1) info = ServiceInfo(type_, registration_name) info.load_from_cache(zc) assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] @@ -237,11 +236,15 @@ def test_ptr_optimization(): # Verify we won't respond for 1s with the same multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert unicast_out is None - assert multicast_out is None + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + # Since we sent the PTR in the last second, they + # should end up in the delayed at least one second bucket + assert question_answers.mcast_aggregate_last_second # Clear the cache to allow responding again _clear_cache(zc) @@ -249,17 +252,17 @@ def test_ptr_optimization(): # Verify we will now respond query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], None, const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out.id == query.id - assert unicast_out is None - assert multicast_out is not None + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate_last_second has_srv = has_txt = has_a = False nbr_additionals = 0 - nbr_answers = len(multicast_out.answers) - nbr_authorities = len(multicast_out.authorities) - for answer in multicast_out.additionals: + nbr_answers = len(question_answers.mcast_aggregate) + additionals = set().union(*question_answers.mcast_aggregate.values()) + for answer in additionals: nbr_additionals += 1 if answer.type == const._TYPE_SRV: has_srv = True @@ -267,7 +270,7 @@ def test_ptr_optimization(): has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert nbr_answers == 1 and nbr_additionals == 3 assert has_srv and has_txt and has_a # unregister @@ -278,7 +281,7 @@ def test_ptr_optimization(): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_any_query_for_ptr(): - """Test that queries for ANY will return PTR records.""" + """Test that queries for ANY will return PTR records and the response is aggregated.""" zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_anyptr._tcp.local." name = "knownname" @@ -294,11 +297,10 @@ def test_any_query_for_ptr(): question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert multicast_out.answers[0][0].name == type_ - assert multicast_out.answers[0][0].alias == registration_name + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + mcast_answers = list(question_answers.mcast_aggregate) + assert mcast_answers[0].name == type_ + assert mcast_answers[0].alias == registration_name # unregister zc.registry.async_remove(info) zc.close() @@ -307,7 +309,7 @@ def test_any_query_for_ptr(): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_aaaa_query(): - """Test that queries for AAAA records work.""" + """Test that queries for AAAA records work and should respond right away.""" zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_knownaaaservice._tcp.local." name = "knownname" @@ -322,10 +324,9 @@ def test_aaaa_query(): question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert multicast_out.answers[0][0].address == ipv6_address + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + mcast_answers = list(question_answers.mcast_now) + assert mcast_answers[0].address == ipv6_address # unregister zc.registry.async_remove(info) zc.close() @@ -334,7 +335,7 @@ def test_aaaa_query(): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_a_and_aaaa_record_fate_sharing(): - """Test that queries for AAAA always return A records in the additionals.""" + """Test that queries for AAAA always return A records in the additionals and should respond right away.""" zc = Zeroconf(interfaces=['127.0.0.1']) type_ = "_a-and-aaaa-service._tcp.local." name = "knownname" @@ -356,31 +357,25 @@ def test_a_and_aaaa_record_fate_sharing(): question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - answers = DNSRRSet([answer[0] for answer in multicast_out.answers]) - additionals = DNSRRSet(multicast_out.additionals) - assert aaaa_record in answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + additionals = set().union(*question_answers.mcast_now.values()) + assert aaaa_record in question_answers.mcast_now assert a_record in additionals - assert len(multicast_out.answers) == 1 - assert len(multicast_out.additionals) == 1 + assert len(question_answers.mcast_now) == 1 + assert len(additionals) == 1 # Test A query generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - _, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - answers = DNSRRSet([answer[0] for answer in multicast_out.answers]) - additionals = DNSRRSet(multicast_out.additionals) - - assert a_record in answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + additionals = set().union(*question_answers.mcast_now.values()) + assert a_record in question_answers.mcast_now assert aaaa_record in additionals - assert len(multicast_out.answers) == 1 - assert len(multicast_out.additionals) == 1 + assert len(question_answers.mcast_now) == 1 + assert len(additionals) == 1 + # unregister zc.registry.async_remove(info) zc.close() @@ -406,16 +401,15 @@ def test_unicast_response(): # query query = r.DNSOutgoing(const._FLAGS_QR_QUERY) query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", 1234 + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], True ) - for out in (unicast_out, multicast_out): - assert out.id == query.id + for answers in (question_answers.ucast, question_answers.mcast_aggregate): has_srv = has_txt = has_a = False nbr_additionals = 0 - nbr_answers = len(out.answers) - nbr_authorities = len(out.authorities) - for answer in out.additionals: + nbr_answers = len(answers) + additionals = set().union(*answers.values()) + for answer in additionals: nbr_additionals += 1 if answer.type == const._TYPE_SRV: has_srv = True @@ -423,7 +417,7 @@ def test_unicast_response(): has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert nbr_answers == 1 and nbr_additionals == 3 assert has_srv and has_txt and has_a # unregister @@ -431,6 +425,48 @@ def test_unicast_response(): zc.close() +@pytest.mark.asyncio +async def test_probe_answered_immediately(): + """Verify probes are responded to immediately.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.async_add(info) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + query.add_authorative_answer(info.dns_pointer()) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False + ) + assert not question_answers.ucast + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + assert question_answers.mcast_now + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unicast = True + query.add_question(question) + query.add_authorative_answer(info.dns_pointer()) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False + ) + assert question_answers.ucast + assert question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + zc.close() + + def test_qu_response(): """Handle multicast incoming with the QU bit set.""" # instantiate a zeroconf instance @@ -459,21 +495,20 @@ def test_qu_response(): # register zc.register_service(info) - def _validate_complete_response(query, out): - assert out.id == query.id + def _validate_complete_response(answers): has_srv = has_txt = has_a = False - nbr_additionals = 0 - nbr_answers = len(out.answers) - nbr_authorities = len(out.authorities) - for answer in out.additionals: - nbr_additionals += 1 + nbr_answers = len(answers.keys()) + additionals = set().union(*answers.values()) + nbr_additionals = len(additionals) + + for answer in additionals: if answer.type == const._TYPE_SRV: has_srv = True elif answer.type == const._TYPE_TXT: has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 and nbr_authorities == 0 + assert nbr_answers == 1 and nbr_additionals == 3 assert has_srv and has_txt and has_a # With QU should respond to only unicast when the answer has been recently multicast @@ -483,11 +518,13 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out is None - _validate_complete_response(query, unicast_out) + _validate_complete_response(question_answers.ucast) + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second _clear_cache(zc) # With QU should respond to only multicast since the response hasn't been seen since 75% of the ttl @@ -496,11 +533,13 @@ def _validate_complete_response(query, out): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert unicast_out is None - _validate_complete_response(query, multicast_out) + assert not question_answers.ucast + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate + _validate_complete_response(question_answers.mcast_now) # With QU set and an authorative answer (probe) should respond to both unitcast and multicast since the response hasn't been seen since 75% of the ttl query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -509,24 +548,28 @@ def _validate_complete_response(query, out): assert question.unicast is True query.add_question(question) query.add_authorative_answer(info2.dns_pointer()) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - _validate_complete_response(query, unicast_out) - _validate_complete_response(query, multicast_out) + _validate_complete_response(question_answers.ucast) + _validate_complete_response(question_answers.mcast_now) - _inject_response(zc, r.DNSIncoming(multicast_out.packets()[0])) + _inject_response( + zc, r.DNSIncoming(construct_outgoing_multicast_answers(question_answers.mcast_now).packets()[0]) + ) # With the cache repopulated; should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out is None - _validate_complete_response(query, unicast_out) + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + _validate_complete_response(question_answers.ucast) # unregister zc.unregister_service(info) zc.close() @@ -551,34 +594,33 @@ def test_known_answer_supression(): question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is not None and multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - # If the answer is suppressed, the additional should be suppresed as well - assert not multicast_out or not multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # Test A supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is not None and multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) @@ -586,56 +628,55 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert not multicast_out or not multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # Test SRV supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is not None and multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - # If the answer is suppressed, the additional should be suppresed as well - assert not multicast_out or not multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # Test TXT supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is not None and multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert not multicast_out or not multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # unregister zc.registry.async_remove(info) @@ -684,11 +725,11 @@ def test_multi_packet_known_answer_supression(): generated.add_answer_at_time(info3.dns_pointer(), now) packets = generated.packets() assert len(packets) > 1 - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is None + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # unregister zc.registry.async_remove(info) zc.registry.async_remove(info2) @@ -725,11 +766,11 @@ def test_known_answer_supression_service_type_enumeration_query(): question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is not None and multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) @@ -755,11 +796,11 @@ def test_known_answer_supression_service_type_enumeration_query(): now, ) packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert not multicast_out or not multicast_out.answers + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second # unregister zc.registry.async_remove(info) @@ -815,12 +856,16 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out is None - assert a_record in unicast_out.additionals - assert unicast_out.answers[0][0] == ptr_record + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + + additionals = set().union(*question_answers.ucast.values()) + assert a_record in additionals + assert ptr_record in question_answers.ucast # Remove the 50% A record and add a 100% A record zc.cache.async_remove_records([a_record]) @@ -835,12 +880,15 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out is None - assert a_record in unicast_out.additionals - assert unicast_out.answers[0][0] == ptr_record + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + additionals = set().union(*question_answers.ucast.values()) + assert a_record in additionals + assert ptr_record in question_answers.ucast # Remove the 100% PTR record and add a 50% PTR record zc.cache.async_remove_records([ptr_record]) @@ -855,15 +903,17 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): assert question.unicast is True query.add_question(question) - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out.answers[0][0] == ptr_record - assert a_record in multicast_out.additionals - assert info.dns_text() in multicast_out.additionals - assert info.dns_service() in multicast_out.additionals - - assert unicast_out is None + assert not question_answers.ucast + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + additionals = set().union(*question_answers.mcast_now.values()) + assert a_record in additionals + assert info.dns_text() in additionals + assert info.dns_service() in additionals + assert ptr_record in question_answers.mcast_now # Ask 2 QU questions, with info the PTR is at 50%, with info2 the PTR is at 100% # We should get back a unicast reply for info2, but info should be multicasted since its within 75% of its TTL @@ -881,18 +931,23 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): query.add_question(question) zc.cache.async_add_records([info2.dns_pointer()]) # Add 100% TTL for info2 to the cache - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in query.packets()], "1.2.3.4", const._MDNS_PORT + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False ) - assert multicast_out.answers[0][0] == info.dns_pointer() - assert info.dns_addresses()[0] in multicast_out.additionals - assert info.dns_text() in multicast_out.additionals - assert info.dns_service() in multicast_out.additionals + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + + mcast_now_additionals = set().union(*question_answers.mcast_now.values()) + assert a_record in mcast_now_additionals + assert info.dns_text() in mcast_now_additionals + assert info.dns_addresses()[0] in mcast_now_additionals + assert info.dns_pointer() in question_answers.mcast_now - assert unicast_out.answers[0][0] == info2.dns_pointer() - assert info2.dns_addresses()[0] in unicast_out.additionals - assert info2.dns_text() in unicast_out.additionals - assert info2.dns_service() in unicast_out.additionals + ucast_additionals = set().union(*question_answers.ucast.values()) + assert info2.dns_pointer() in question_answers.ucast + assert info2.dns_text() in ucast_additionals + assert info2.dns_service() in ucast_additionals + assert info2.dns_addresses()[0] in ucast_additionals # unregister zc.registry.async_remove(info) @@ -1045,11 +1100,11 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is None + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second assert zc.question_history.suppresses(question, now, {known_answer}) await aiozc.async_close() @@ -1072,11 +1127,11 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - unicast_out, multicast_out = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT - ) - assert unicast_out is None - assert multicast_out is None + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second assert not zc.question_history.suppresses(question, now, {known_answer}) await aiozc.async_close() @@ -1161,3 +1216,104 @@ async def test_duplicate_goodbye_answers_in_packet(): incoming = r.DNSIncoming(response.packets()[0]) zc.record_manager.async_updates_from_response(incoming) await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_response_aggregation_timings(run_isolated): + """Verify multicast respones are aggregated.""" + type_ = "_mservice._tcp.local." + type_2 = "_mservice2._tcp.local." + type_3 = "_mservice3._tcp.local." + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + + name = "xxxyyy" + registration_name = f"{name}.{type_}" + registration_name2 = f"{name}.{type_2}" + registration_name3 = f"{name}.{type_3}" + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + ) + info3 = ServiceInfo( + type_3, registration_name3, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + ) + aiozc.zeroconf.registry.async_add(info) + aiozc.zeroconf.registry.async_add(info2) + aiozc.zeroconf.registry.async_add(info3) + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + + query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question2 = r.DNSQuestion(info2.type, const._TYPE_PTR, const._CLASS_IN) + query2.add_question(question2) + + query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question3 = r.DNSQuestion(info3.type, const._TYPE_PTR, const._CLASS_IN) + query3.add_question(question3) + + query4 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + query4.add_question(question) + query4.add_question(question2) + + zc = aiozc.zeroconf + protocol = zc.engine.protocols[0] + + with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock: + protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + await asyncio.sleep(0.7) + + # Should aggregate into a single answer with up to a 500ms + 120ms delay + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + zc.handle_response(incoming) + assert info.dns_pointer() in incoming.answers + assert info2.dns_pointer() in incoming.answers + send_mock.reset_mock() + + protocol.datagram_received(query3.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + await asyncio.sleep(0.3) + + # Should send within 120ms since there are no other + # answers to aggregate with + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + zc.handle_response(incoming) + assert info3.dns_pointer() in incoming.answers + send_mock.reset_mock() + + # Because the response was sent in the last second we need to make + # sure the next answer is delayed at least a second + aiozc.zeroconf.engine.protocols[0].datagram_received( + query4.packets()[0], ('127.0.0.1', const._MDNS_PORT) + ) + await asyncio.sleep(0.5) + + # After 0.5 seconds it should not have been sent + # Protect the network against excessive packet flooding + # https://datatracker.ietf.org/doc/html/rfc6762#section-14 + calls = send_mock.mock_calls + assert len(calls) == 0 + send_mock.reset_mock() + + await asyncio.sleep(1.2) + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + assert info.dns_pointer() in incoming.answers + + await aiozc.async_close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index b23206014..20251f0bb 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -32,7 +32,13 @@ from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType from ._exceptions import NonUniqueNameException -from ._handlers import QueryHandler, RecordManager +from ._handlers import ( + MulticastOutgoingQueue, + QueryHandler, + RecordManager, + construct_outgoing_multicast_answers, + construct_outgoing_unicast_answers, +) from ._history import QuestionHistory from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing @@ -70,12 +76,15 @@ _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, + _ONE_SECOND, _REGISTER_TIME, _TYPE_PTR, _UNREGISTER_TIME, ) _TC_DELAY_RANDOM_INTERVAL = (400, 500) + + _CLOSE_TIMEOUT = 3000 # ms _REGISTER_BROADCASTS = 3 @@ -394,6 +403,8 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None + self._out_queue = MulticastOutgoingQueue(self) + self.start() def start(self) -> None: @@ -717,11 +728,24 @@ def handle_assembled_query( or the timer expires. If the TC bit is not set, a single packet will be in packets. """ - unicast_out, multicast_out = self.query_handler.async_response(packets, addr, port) - if unicast_out: - self.async_send(unicast_out, addr, port, v6_flow_scope) - if multicast_out: - self.async_send(multicast_out, None, _MDNS_PORT) + now = packets[0].now + ucast_source = port != _MDNS_PORT + question_answers = self.query_handler.async_response(packets, ucast_source) + if question_answers.ucast: + questions = packets[0].questions + id_ = packets[0].id + out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) + self.async_send(out, addr, port, v6_flow_scope) + if question_answers.mcast_now: + out = construct_outgoing_multicast_answers(question_answers.mcast_aggregate) + self.async_send(out) + if question_answers.mcast_aggregate: + self._out_queue.async_add(now, question_answers.mcast_aggregate, 0) + if question_answers.mcast_aggregate_last_second: + # https://datatracker.ietf.org/doc/html/rfc6762#section-14 + # If we broadcast it in the last second, we have to delay + # at least a second before we send it again + self._out_queue.async_add(now, question_answers.mcast_aggregate_last_second, _ONE_SECOND) def send( self, @@ -742,6 +766,9 @@ def async_send( v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Sends an outgoing packet.""" + if self._GLOBAL_DONE: + return + for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) @@ -756,8 +783,6 @@ def async_send( packet, ) for transport in self.engine.senders: - if self._GLOBAL_DONE: - return s = transport.get_extra_info('socket') if addr is None: real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 29ea0b6be..76d5efcd7 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -21,7 +21,9 @@ """ import itertools -from typing import Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +import random +from collections import deque +from typing import Dict, Iterable, List, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from ._cache import DNSCache, _UniqueRecordsType from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord @@ -30,14 +32,14 @@ from ._protocol import DNSIncoming, DNSOutgoing from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.time import current_time_millis +from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _CLASS_IN, _DNS_OTHER_TTL, _DNS_PTR_MIN_TTL, _FLAGS_AA, _FLAGS_QR_RESPONSE, - _MDNS_PORT, + _ONE_SECOND, _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_A, _TYPE_AAAA, @@ -53,6 +55,59 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] +_MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) +_MAX_MULTICAST_DELAY = 500 # ms +_RESPOND_IMMEDIATE_TYPES = {_TYPE_SRV, _TYPE_A, _TYPE_AAAA} + + +class QuestionAnswers(NamedTuple): + ucast: _AnswerWithAdditionalsType + mcast_now: _AnswerWithAdditionalsType + mcast_aggregate: _AnswerWithAdditionalsType + mcast_aggregate_last_second: _AnswerWithAdditionalsType + + +class AnswerGroup(NamedTuple): + """A group of answers scheduled to be sent at the same time.""" + + send_after: float # Must be sent after this time + send_before: float # Must be sent before this time + answers: _AnswerWithAdditionalsType + + +def _message_is_probe(msg: DNSIncoming) -> bool: + return msg.num_authorities > 0 + + +def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: + """Add answers and additionals to a DNSOutgoing.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) + _add_answers_additionals(out, answers) + return out + + +def construct_outgoing_unicast_answers( + answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int +) -> DNSOutgoing: + """Add answers and additionals to a DNSOutgoing.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=id_) + # Adding the questions back when the source is legacy unicast behavior + if ucast_source: + for question in questions: + out.add_question(question) + _add_answers_additionals(out, answers) + return out + + +def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: + # Find additionals and suppress any additionals that are already in answers + additionals: Set[DNSRecord] = set().union(*answers.values()) # type: ignore + additionals -= answers.keys() + for answer in answers: + out.add_answer_at_time(answer, 0) + for additional in additionals: + out.add_additional_answer(additional) + def sanitize_incoming_record(record: DNSRecord) -> None: """Protect zeroconf from records that can cause denial of service. @@ -74,16 +129,17 @@ def sanitize_incoming_record(record: DNSRecord) -> None: class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" - def __init__(self, cache: DNSCache, msg: DNSIncoming, ucast_source: bool) -> None: + def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: """Build a query response.""" - self._msg = msg - self._is_probe = msg.num_authorities > 0 - self._ucast_source = ucast_source - self._now = current_time_millis() + self._is_probe = any(_message_is_probe(msg) for msg in msgs) + self._msg = msgs[0] + self._now = self._msg.now self._cache = cache self._additionals: _AnswerWithAdditionalsType = {} self._ucast: Set[DNSRecord] = set() - self._mcast: Set[DNSRecord] = set() + self._mcast_now: Set[DNSRecord] = set() + self._mcast_aggregate: Set[DNSRecord] = set() + self._mcast_aggregate_last_second: Set[DNSRecord] = set() def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast QU query.""" @@ -92,7 +148,7 @@ def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: if self._is_probe: self._ucast.add(record) if not self._has_mcast_within_one_quarter_ttl(record): - self._mcast.add(record) + self._mcast_now.add(record) elif not self._is_probe: self._ucast.add(record) @@ -104,46 +160,32 @@ def add_ucast_question_response(self, answers: _AnswerWithAdditionalsType) -> No def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast query.""" self._additionals.update(answers) - self._mcast.update(answers.keys()) - - def outgoing_unicast(self) -> Optional[DNSOutgoing]: - """Build the outgoing unicast response.""" - ucastout = self._construct_outgoing_from_record_set(self._ucast, False) - # Adding the questions back when the source is legacy unicast behavior - if ucastout and self._ucast_source: - for question in self._msg.questions: - ucastout.add_question(question) - return ucastout - - def outgoing_multicast(self) -> Optional[DNSOutgoing]: - """Build the outgoing multicast response.""" - if not self._is_probe: - self._suppress_mcasts_from_last_second(self._mcast) - return self._construct_outgoing_from_record_set(self._mcast, True) - - def _construct_outgoing_from_record_set( - self, answers_rrset: Set[DNSRecord], multicast: bool - ) -> Optional[DNSOutgoing]: - """Add answers and additionals to a DNSOutgoing.""" - # Find additionals and suppress any additionals that are already in answers - additionals_rrset = self._additionals_from_answers_rrset(answers_rrset) - answers_rrset - if not answers_rrset: - return None - - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=multicast, id_=self._msg.id) - for answer in answers_rrset: - out.add_answer_at_time(answer, 0) - for additional in additionals_rrset: - out.add_additional_answer(additional) - return out - - def _additionals_from_answers_rrset(self, rrset: Set[DNSRecord]) -> Set[DNSRecord]: - additionals: Set[DNSRecord] = set() - return additionals.union(*(self._additionals[record] for record in rrset)) - - def _suppress_mcasts_from_last_second(self, rrset: Set[DNSRecord]) -> None: - """Remove any records that were already sent in the last second.""" - rrset -= {record for record in rrset if self._has_mcast_record_in_last_second(record)} + for answer in answers: + if self._is_probe: + self._mcast_now.add(answer) + continue + + if self._has_mcast_record_in_last_second(answer): + self._mcast_aggregate_last_second.add(answer) + elif len(self._msg.questions) == 1 and self._msg.questions[0].type in _RESPOND_IMMEDIATE_TYPES: + self._mcast_now.add(answer) + else: + self._mcast_aggregate.add(answer) + + def _generate_answers_with_additionals(self, rrset: Set[DNSRecord]) -> _AnswerWithAdditionalsType: + """Create answers with additionals from an rrset.""" + return {record: self._additionals[record] for record in rrset} + + def answers( + self, + ) -> QuestionAnswers: + """Return answer sets that will be queued.""" + return QuestionAnswers( + self._generate_answers_with_additionals(self._ucast), + self._generate_answers_with_additionals(self._mcast_now), + self._generate_answers_with_additionals(self._mcast_aggregate), + self._generate_answers_with_additionals(self._mcast_aggregate_last_second), + ) def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -160,12 +202,12 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: return bool(maybe_entry and maybe_entry.is_recent(self._now)) def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: - """Remove answers that were just broadcast + """Check if an answer was seen in the last second. Protect the network against excessive packet flooding https://datatracker.ietf.org/doc/html/rfc6762#section-14 """ maybe_entry = self._cache.async_get_unique(cast(_UniqueRecordsType, record)) - return bool(maybe_entry and self._now - maybe_entry.created < 1000) + return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) class QueryHandler: @@ -229,13 +271,14 @@ def _add_address_answers( def _answer_question( self, question: DNSQuestion, - answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float, - ) -> None: + ) -> _AnswerWithAdditionalsType: + answer_set: _AnswerWithAdditionalsType = {} + if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) - return + return answer_set type_ = question.type @@ -259,24 +302,26 @@ def _answer_question( if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() + return answer_set + def async_response( # pylint: disable=unused-argument - self, msgs: List[DNSIncoming], addr: Optional[str], port: int - ) -> Tuple[Optional[DNSOutgoing], Optional[DNSOutgoing]]: + self, msgs: List[DNSIncoming], ucast_source: bool + ) -> QuestionAnswers: """Deal with incoming query packets. Provides a response if possible. This function must be run in the event loop as it is not threadsafe. """ - ucast_source = port != _MDNS_PORT - known_answers = DNSRRSet(itertools.chain(*(msg.answers for msg in msgs))) - query_res = _QueryResponse(self.cache, msgs[0], ucast_source) + known_answers = DNSRRSet( + itertools.chain(*(msg.answers for msg in msgs if not _message_is_probe(msg))) + ) + query_res = _QueryResponse(self.cache, msgs) for msg in msgs: for question in msg.questions: if not question.unicast: self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) - answer_set: _AnswerWithAdditionalsType = {} - self._answer_question(question, answer_set, known_answers, msg.now) + answer_set = self._answer_question(question, known_answers, msg.now) if not ucast_source and question.unicast: query_res.add_qu_question_response(answer_set) continue @@ -286,7 +331,7 @@ def async_response( # pylint: disable=unused-argument # source as long as we haven't done it recently (75% of ttl) query_res.add_mcast_question_response(answer_set) - return query_res.outgoing_unicast(), query_res.outgoing_multicast() + return query_res.answers() class RecordManager: @@ -397,7 +442,7 @@ def _async_mark_unique_cached_records_older_than_1s_to_expire( answers_rrset = DNSRRSet(answers) for name, type_, class_ in unique_types: for entry in self.cache.async_all_by_details(name, type_, class_): - if (now - entry.created > 1000) and entry not in answers_rrset: + if (now - entry.created > _ONE_SECOND) and entry not in answers_rrset: # Expire in 1s entry.set_created_ttl(now, 1) @@ -449,3 +494,50 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: self.zc.async_notify_all() except ValueError as e: log.exception('Failed to remove listener: %r', e) + + +class MulticastOutgoingQueue: + """An outgoing queue used to aggregate multicast responses.""" + + def __init__(self, zeroconf: 'Zeroconf') -> None: + self.zc = zeroconf + self.queue: deque = deque() + + def async_add(self, now: float, answers: _AnswerWithAdditionalsType, additional_delay: int) -> None: + """Add a group of answers with additionals to the outgoing queue.""" + assert self.zc.loop is not None + random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + additional_delay + send_after = now + random_delay + send_before = now + _MAX_MULTICAST_DELAY + additional_delay + if not len(self.queue): + self.zc.loop.call_later(millis_to_seconds(random_delay), self._async_ready) + self.queue.append(AnswerGroup(send_after, send_before, answers)) + + def _async_ready(self) -> None: + """Process anything in the queue that is ready.""" + assert self.zc.loop is not None + now = current_time_millis() + + if len(self.queue) > 1 and self.queue[0].send_before > now: + # There is more than one answer in the queue, + # delay until we have to send it (first answer group reaches send_before) + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self._async_ready) + return + + answers: _AnswerWithAdditionalsType = {} + # Add all groups that can be sent now + while len(self.queue) and self.queue[0].send_after <= now: + answers.update(self.queue.popleft().answers) + + if len(self.queue): + # If there are still groups in the queue that are not ready to send + # be sure we schedule them to go out later + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self._async_ready) + + if answers: + # If we have the same answer scheduled to go out, remove it + for pending in self.queue: + for record in answers: + pending.answers.pop(record, None) + + self.zc.async_send(construct_outgoing_multicast_answers(answers)) diff --git a/zeroconf/const.py b/zeroconf/const.py index 4c23310cb..ff5cc3a26 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -34,6 +34,8 @@ _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10000 # ms _LOADED_SYSTEM_TIMEOUT = 10 # s +_ONE_SECOND = 1000 # ms + # If the system is loaded or the event # loop was blocked by another task that was doing I/O in the loop # (shouldn't happen but it does in practice) we need to give From 342532e1d13ac24673735dc467a79edebdfb9362 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 16:19:07 -0500 Subject: [PATCH 0593/1433] Update changelog for 0.34.0 (#941) --- README.rst | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ade5b8eec..36bcc7b32 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,23 @@ See examples directory for more. Changelog ========= +0.34.0 +====== + +* Implemented Multicast Response Aggregation (#940) @bdraco + + Responses are now aggregated when possible per rules in RFC6762 + section 6.4 + + Responses that trigger the protection against against excessive + packet flooding due to software bugs or malicious attack described + in RFC6762 section 6 are delayed instead of discarding as it was + causing responders that implement Passive Observation Of Failures + (POOF) to evict the records. + + Probe responses are now always sent immediately as there were cases + where they would fail to be answered in time to defend a name. + 0.33.4 ====== @@ -149,7 +166,6 @@ Changelog ====== * Added support for forward dns compression pointers (#934) @bdraco - * Provide sockname when logging a protocol error (#935) @bdraco 0.33.2 @@ -161,7 +177,6 @@ Changelog from the cache when the second goodbye answer in the same packet was processed Fixed #926 - * Skip ipv6 interfaces that return ENODEV (#930) @bdraco 0.33.1 From 549ac3de27eb3924cc7967088c3d316184722b9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 16:28:47 -0500 Subject: [PATCH 0594/1433] =?UTF-8?q?Bump=20version:=200.33.4=20=E2=86=92?= =?UTF-8?q?=200.34.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index f982b8061..f1d01ab4b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.4 +current_version = 0.34.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f106525b8..bec241924 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.33.4' +__version__ = '0.34.0' __license__ = 'LGPL' From de96e2bf01af68d754bb7c71da949e30de88a77b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 20:56:46 -0500 Subject: [PATCH 0595/1433] Ensure multicast aggregation sends responses within 620ms (#942) --- tests/test_handlers.py | 66 ++++++++++++++++++++++++++++++++++++++++++ zeroconf/_core.py | 7 +++-- zeroconf/_handlers.py | 9 +++--- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 9049408dc..c573c411c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1317,3 +1317,69 @@ async def test_response_aggregation_timings(run_isolated): assert info.dns_pointer() in incoming.answers await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_response_aggregation_timings_multiple(run_isolated): + """Verify multicast responses that are aggregated do not take longer than 620ms to send. + + 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" + type_2 = "_mservice2._tcp.local." + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + + name = "xxxyyy" + registration_name2 = f"{name}.{type_2}" + + desc = {'path': '/~paulsm/'} + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + ) + aiozc.zeroconf.registry.async_add(info2) + + query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question2 = r.DNSQuestion(info2.type, const._TYPE_PTR, const._CLASS_IN) + query2.add_question(question2) + + zc = aiozc.zeroconf + protocol = zc.engine.protocols[0] + + with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock, unittest.mock.patch.object( + protocol, "suppress_duplicate_packet", return_value=False + ): + send_mock.reset_mock() + protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + await asyncio.sleep(0.2) + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + zc.handle_response(incoming) + assert info2.dns_pointer() in incoming.answers + + send_mock.reset_mock() + protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + await asyncio.sleep(1.2) + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + zc.handle_response(incoming) + assert info2.dns_pointer() in incoming.answers + + send_mock.reset_mock() + protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + # The delay should increase with two packets + await asyncio.sleep(1.2) + calls = send_mock.mock_calls + assert len(calls) == 0 + + await asyncio.sleep(0.63) # 620ms + 10ms for execution time + calls = send_mock.mock_calls + assert len(calls) == 1 + outgoing = send_mock.call_args[0][0] + incoming = r.DNSIncoming(outgoing.packets()[0]) + zc.handle_response(incoming) + assert info2.dns_pointer() in incoming.answers diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 20251f0bb..c0e8a885f 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -403,7 +403,8 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None - self._out_queue = MulticastOutgoingQueue(self) + self._out_queue = MulticastOutgoingQueue(self, 0) + self._out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND) self.start() @@ -740,12 +741,12 @@ def handle_assembled_query( out = construct_outgoing_multicast_answers(question_answers.mcast_aggregate) self.async_send(out) if question_answers.mcast_aggregate: - self._out_queue.async_add(now, question_answers.mcast_aggregate, 0) + self._out_queue.async_add(now, question_answers.mcast_aggregate) if question_answers.mcast_aggregate_last_second: # https://datatracker.ietf.org/doc/html/rfc6762#section-14 # If we broadcast it in the last second, we have to delay # at least a second before we send it again - self._out_queue.async_add(now, question_answers.mcast_aggregate_last_second, _ONE_SECOND) + self._out_delay_queue.async_add(now, question_answers.mcast_aggregate_last_second) def send( self, diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 76d5efcd7..d2160c1a4 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -499,16 +499,17 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" - def __init__(self, zeroconf: 'Zeroconf') -> None: + def __init__(self, zeroconf: 'Zeroconf', additional_delay: int) -> None: self.zc = zeroconf self.queue: deque = deque() + self.additional_delay = additional_delay - def async_add(self, now: float, answers: _AnswerWithAdditionalsType, additional_delay: int) -> None: + def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: """Add a group of answers with additionals to the outgoing queue.""" assert self.zc.loop is not None - random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + additional_delay + random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay send_after = now + random_delay - send_before = now + _MAX_MULTICAST_DELAY + additional_delay + send_before = now + _MAX_MULTICAST_DELAY + self.additional_delay if not len(self.queue): self.zc.loop.call_later(millis_to_seconds(random_delay), self._async_ready) self.queue.append(AnswerGroup(send_after, send_before, answers)) From 9942484172d7a79fe84c47924538c2c02fde7264 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 21:02:44 -0500 Subject: [PATCH 0596/1433] Update changelog for 0.34.1 (#943) --- README.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.rst b/README.rst index 36bcc7b32..6bc34d452 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,19 @@ See examples directory for more. Changelog ========= +0.34.1 +====== + +* Ensure multicast aggregation sends responses within 620ms (#942) @bdraco + + Responses that trigger the protection against against excessive + packet flooding due to software bugs or malicious attack described + in RFC6762 section 6 could cause the multicast aggregation response + to be delayed longer than 620ms (The maximum random delay of 120ms + and 500ms additional for aggregation). + + Only responses that trigger the protection are delayed longer than 620ms + 0.34.0 ====== From 7878a9eed93a8ec2396d8450389a08bf54bd5693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 21:03:12 -0500 Subject: [PATCH 0597/1433] =?UTF-8?q?Bump=20version:=200.34.0=20=E2=86=92?= =?UTF-8?q?=200.34.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index f1d01ab4b..ff4524641 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.34.0 +current_version = 0.34.1 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index bec241924..1d1fca045 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.34.0' +__version__ = '0.34.1' __license__ = 'LGPL' From 9a5164a7a3231903537231bfb56479e617355f92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 23:23:07 -0500 Subject: [PATCH 0598/1433] Coalesce aggregated multicast answers when the random delay is shorter than the last scheduled response (#945) - Reduces traffic when we already know we will be sending a group of answers inside the random delay window described in https://datatracker.ietf.org/doc/html/rfc6762#section-6.3 closes #944 --- tests/test_handlers.py | 87 +++++++++++++++++++++++++++++++++++++++--- zeroconf/_handlers.py | 11 +++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c573c411c..86a190b74 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -14,9 +14,10 @@ from typing import List import zeroconf as r -from zeroconf import ServiceInfo, Zeroconf, current_time_millis +from zeroconf import _handlers, ServiceInfo, Zeroconf, current_time_millis from zeroconf import const -from zeroconf._handlers import construct_outgoing_multicast_answers +from zeroconf._handlers import construct_outgoing_multicast_answers, MulticastOutgoingQueue +from zeroconf._utils.time import millis_to_seconds from zeroconf.asyncio import AsyncZeroconf @@ -1371,15 +1372,91 @@ async def test_response_aggregation_timings_multiple(run_isolated): send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - # The delay should increase with two packets - await asyncio.sleep(1.2) + # The delay should increase with two packets and + # 900ms is beyond the maximum aggregation delay + # when there is no network protection delay + await asyncio.sleep(0.9) calls = send_mock.mock_calls assert len(calls) == 0 - await asyncio.sleep(0.63) # 620ms + 10ms for execution time + # 1000ms (1s network protection delays) + # - 900ms (already slept) + # + 120ms (maximum random delay) + # + 500ms (maximum aggregation delay) + # + 20ms (execution time) + await asyncio.sleep(millis_to_seconds(1000 - 900 + 120 + 500 + 20)) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.handle_response(incoming) assert info2.dns_pointer() in incoming.answers + + +@pytest.mark.asyncio +async def test_response_aggregation_random_delay(): + """Verify the random delay for outgoing multicast will coalesce into a single group + + When the random delay is shorter than the last outgoing group, + the groups should be combined. + """ + type_ = "_mservice._tcp.local." + type_2 = "_mservice2._tcp.local." + type_3 = "_mservice3._tcp.local." + type_4 = "_mservice4._tcp.local." + type_5 = "_mservice5._tcp.local." + + name = "xxxyyy" + registration_name = f"{name}.{type_}" + registration_name2 = f"{name}.{type_2}" + registration_name3 = f"{name}.{type_3}" + registration_name4 = f"{name}.{type_4}" + registration_name5 = f"{name}.{type_5}" + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-1.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.3")] + ) + info3 = ServiceInfo( + type_3, registration_name3, 80, 0, 0, desc, "ash-3.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info4 = ServiceInfo( + type_4, registration_name4, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info5 = ServiceInfo( + type_5, registration_name5, 80, 0, 0, desc, "ash-5.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + mocked_zc = unittest.mock.MagicMock() + outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0) + + now = current_time_millis() + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (500, 600)): + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + + # The second group should always be coalesced into first group since it will always come before + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (300, 400)): + outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) + + # The third group should always be coalesced into first group since it will always come before + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (100, 200)): + outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) + + assert len(outgoing_queue.queue) == 1 + assert info.dns_pointer() in outgoing_queue.queue[0].answers + assert info2.dns_pointer() in outgoing_queue.queue[0].answers + assert info3.dns_pointer() in outgoing_queue.queue[0].answers + assert info4.dns_pointer() in outgoing_queue.queue[0].answers + + # The forth group should not be coalesced because its scheduled after the last group in the queue + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (700, 800)): + outgoing_queue.async_add(now, {info5.dns_pointer(): set()}) + + assert len(outgoing_queue.queue) == 2 + assert info.dns_pointer() not in outgoing_queue.queue[1].answers + assert info2.dns_pointer() not in outgoing_queue.queue[1].answers + assert info3.dns_pointer() not in outgoing_queue.queue[1].answers + assert info4.dns_pointer() not in outgoing_queue.queue[1].answers + assert info5.dns_pointer() in outgoing_queue.queue[1].answers diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index d2160c1a4..73812c6f4 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -510,7 +510,16 @@ def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay send_after = now + random_delay send_before = now + _MAX_MULTICAST_DELAY + self.additional_delay - if not len(self.queue): + if len(self.queue): + # If we calculate a random delay for the send after time + # that is less than the last group scheduled to go out, + # we instead add the answers to the last group as this + # allows aggregating additonal responses + last_group = self.queue[-1] + if send_after <= last_group.send_after: + last_group.answers.update(answers) + return + else: self.zc.loop.call_later(millis_to_seconds(random_delay), self._async_ready) self.queue.append(AnswerGroup(send_after, send_before, answers)) From 6d7266d0e1e6dcb950456da0354b4c43fd5c0ecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Aug 2021 23:54:13 -0500 Subject: [PATCH 0599/1433] Ensure ServiceInfo requests can be answered with the default timeout with network protection (#946) - Adjust the time windows to ensure responses that have triggered the protection against against excessive packet flooding due to software bugs or malicious attack described in RFC6762 section 6 can respond in under 1350ms to ensure ServiceInfo can ask two questions within the default timeout of 3000ms --- tests/test_handlers.py | 6 +++--- zeroconf/_core.py | 19 ++++++++++++++++--- zeroconf/_handlers.py | 9 ++++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 86a190b74..1c1805088 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1382,9 +1382,9 @@ async def test_response_aggregation_timings_multiple(run_isolated): # 1000ms (1s network protection delays) # - 900ms (already slept) # + 120ms (maximum random delay) - # + 500ms (maximum aggregation delay) + # + 200ms (maximum protected aggregation delay) # + 20ms (execution time) - await asyncio.sleep(millis_to_seconds(1000 - 900 + 120 + 500 + 20)) + await asyncio.sleep(millis_to_seconds(1000 - 900 + 120 + 200 + 20)) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1430,7 +1430,7 @@ async def test_response_aggregation_random_delay(): type_5, registration_name5, 80, 0, 0, desc, "ash-5.local.", addresses=[socket.inet_aton("10.0.1.2")] ) mocked_zc = unittest.mock.MagicMock() - outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0) + outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 500) now = current_time_millis() with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (500, 600)): diff --git a/zeroconf/_core.py b/zeroconf/_core.py index c0e8a885f..400e15a59 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -83,7 +83,20 @@ ) _TC_DELAY_RANDOM_INTERVAL = (400, 500) - +# The maximum amont of time to delay a multicast +# response in order to aggregate answers +_AGGREGATION_DELAY = 500 # ms +# The maximum amont of time to delay a multicast +# response in order to aggregate answers after +# it has already been delayed to protect the network +# from excessive traffic. We use a shorter time +# window here as we want to _try_ to answer all +# queries in under 1350ms while protecting +# the network from excessive traffic to ensure +# a service info request with two questions +# can be answered in the default timeout of +# 3000ms +_PROTECTED_AGGREGATION_DELAY = 200 # ms _CLOSE_TIMEOUT = 3000 # ms _REGISTER_BROADCASTS = 3 @@ -403,8 +416,8 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None - self._out_queue = MulticastOutgoingQueue(self, 0) - self._out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND) + self._out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) + self._out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) self.start() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 73812c6f4..2310b8240 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -56,7 +56,6 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] _MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) -_MAX_MULTICAST_DELAY = 500 # ms _RESPOND_IMMEDIATE_TYPES = {_TYPE_SRV, _TYPE_A, _TYPE_AAAA} @@ -499,17 +498,21 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" - def __init__(self, zeroconf: 'Zeroconf', additional_delay: int) -> None: + def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: self.zc = zeroconf self.queue: deque = deque() + # Additional delay is used to implement + # Protect the network against excessive packet flooding + # https://datatracker.ietf.org/doc/html/rfc6762#section-14 self.additional_delay = additional_delay + self.aggregation_delay = max_aggregation_delay def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: """Add a group of answers with additionals to the outgoing queue.""" assert self.zc.loop is not None random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay send_after = now + random_delay - send_before = now + _MAX_MULTICAST_DELAY + self.additional_delay + send_before = now + self.aggregation_delay + self.additional_delay if len(self.queue): # If we calculate a random delay for the send after time # that is less than the last group scheduled to go out, From b87f4934b39af02f26bbbfd6f372c7154fe95906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 00:05:51 -0500 Subject: [PATCH 0600/1433] Update changelog for 0.34.2 (#947) --- README.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.rst b/README.rst index 6bc34d452..721a7e87c 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,25 @@ See examples directory for more. Changelog ========= +0.34.2 +====== + +* Coalesce aggregated multicast answers (#945) @bdraco + + When the random delay is shorter than the last scheduled response, + answers are now added to the same outgoing time group. + + This reduces traffic when we already know we will be sending a group of answers + inside the random delay window described in + datatracker.ietf.org/doc/html/rfc6762#section-6.3 +* Ensure ServiceInfo requests can be answered inside the default timeout with network protection (#946) @bdraco + + Adjust the time windows to ensure responses that have triggered the + protection against against excessive packet flooding due to + software bugs or malicious attack described in RFC6762 section 6 + can respond in under 1350ms to ensure ServiceInfo can ask two + questions within the default timeout of 3000ms + 0.34.1 ====== From 6c21f6802b58d949038e9c8501ea204eeda57a16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 00:14:39 -0500 Subject: [PATCH 0601/1433] =?UTF-8?q?Bump=20version:=200.34.1=20=E2=86=92?= =?UTF-8?q?=200.34.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index ff4524641..ed8435b70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.34.1 +current_version = 0.34.2 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1d1fca045..3b9530477 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.34.1' +__version__ = '0.34.2' __license__ = 'LGPL' From 02af7f78d2e5eabcc5cce8238546ee5170951b28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 00:55:48 -0500 Subject: [PATCH 0602/1433] Fix sending immediate multicast responses (#949) - Fixes a typo in handle_assembled_query that prevented immediate responses from being sent. --- zeroconf/_core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 400e15a59..dc3a060fc 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -751,8 +751,7 @@ def handle_assembled_query( out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) self.async_send(out, addr, port, v6_flow_scope) if question_answers.mcast_now: - out = construct_outgoing_multicast_answers(question_answers.mcast_aggregate) - self.async_send(out) + self.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) if question_answers.mcast_aggregate: self._out_queue.async_add(now, question_answers.mcast_aggregate) if question_answers.mcast_aggregate_last_second: From 23b00e983b2e8335431dcc074935f379fd399d46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 00:57:54 -0500 Subject: [PATCH 0603/1433] Update changelog for 0.34.3 (#950) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 721a7e87c..277d790c9 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,11 @@ See examples directory for more. Changelog ========= +0.34.3 +====== + +* Fix sending immediate multicast responses (#949) @bdraco + 0.34.2 ====== From 9d69d18713bdfab53762a6b8c3aff7fd72ebd025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Aug 2021 00:58:09 -0500 Subject: [PATCH 0604/1433] =?UTF-8?q?Bump=20version:=200.34.2=20=E2=86=92?= =?UTF-8?q?=200.34.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index ed8435b70..14216383a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.34.2 +current_version = 0.34.3 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 3b9530477..2f17b31fa 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.34.2' +__version__ = '0.34.3' __license__ = 'LGPL' From ebc23ee5e9592dd7f0235cd57f9b3ad727ec8bff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Aug 2021 21:42:50 -0500 Subject: [PATCH 0605/1433] Sort responses to increase chance of name compression (#954) - When building an outgoing response, sort the names together to increase the likelihood of name compression. In testing this reduced the number of packets for large responses (from 7 packets to 6) --- zeroconf/_handlers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 2310b8240..cff128704 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -100,12 +100,16 @@ def construct_outgoing_unicast_answers( def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: # Find additionals and suppress any additionals that are already in answers - additionals: Set[DNSRecord] = set().union(*answers.values()) # type: ignore - additionals -= answers.keys() - for answer in answers: + sending: Set[DNSRecord] = set(answers.keys()) + # Answers are sorted to group names together to increase the chance + # that similar names will end up in the same packet and can reduce the + # overall size of the outgoing response via name compression + for answer, additionals in sorted(answers.items(), key=lambda kv: kv[0].name): out.add_answer_at_time(answer, 0) - for additional in additionals: - out.add_additional_answer(additional) + for additional in additionals: + if additional not in sending: + out.add_additional_answer(additional) + sending.add(additional) def sanitize_incoming_record(record: DNSRecord) -> None: From 5fb3e202c06e3a0d30e3c7824397d8e8a9f52555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Aug 2021 08:25:59 -0500 Subject: [PATCH 0606/1433] Send unicast replies on the same socket the query was received (#952) When replying to a QU question, we do not know if the sending host is reachable from all of the sending sockets. We now avoid this problem by replying via the receiving socket. This was the existing behavior when `InterfaceChoice.Default` is set. This change extends the unicast relay behavior to used with `InterfaceChoice.Default` to apply when `InterfaceChoice.All` or interfaces are explicitly passed when instantiating a `Zeroconf` instance. Fixes #951 --- tests/test_asyncio.py | 6 ++- tests/test_core.py | 18 ++++----- zeroconf/_core.py | 91 +++++++++++++++++++++++++++++-------------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 39cad5b9b..7b8953868 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -974,12 +974,14 @@ async def test_legacy_unicast_response(run_isolated): query = DNSOutgoing(const._FLAGS_QR_QUERY, multicast=False, id_=888) question = DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) query.add_question(question) + protocol = aiozc.zeroconf.engine.protocols[0] with patch.object(aiozc.zeroconf, "async_send") as send_mock: - aiozc.zeroconf.engine.protocols[0].datagram_received(query.packets()[0], ('127.0.0.1', 6503)) + protocol.datagram_received(query.packets()[0], ('127.0.0.1', 6503)) calls = send_mock.mock_calls - assert calls == [call(ANY, '127.0.0.1', 6503, ())] + # Verify the response is sent back on the socket it was recieved from + assert calls == [call(ANY, '127.0.0.1', 6503, (), protocol.transport)] outgoing = send_mock.call_args[0][0] assert isinstance(outgoing, DNSOutgoing) assert outgoing.questions == [question] diff --git a/tests/test_core.py b/tests/test_core.py index e2420a78b..ba1effaca 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -480,28 +480,28 @@ def test_tc_bit_defers(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert source_ip not in protocol._deferred assert source_ip not in protocol._timers @@ -559,13 +559,13 @@ def test_tc_bit_defers_last_response_missing(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred timer1 = protocol._timers[source_ip] next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred timer2 = protocol._timers[source_ip] if sys.version_info >= (3, 7): @@ -573,7 +573,7 @@ def test_tc_bit_defers_last_response_missing(): assert timer2 != timer1 # Send the same packet again to similar multi interfaces - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer3 = protocol._timers[source_ip] @@ -583,7 +583,7 @@ def test_tc_bit_defers_last_response_missing(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer4 = protocol._timers[source_ip] diff --git a/zeroconf/_core.py b/zeroconf/_core.py index dc3a060fc..72c6e4ceb 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -215,7 +215,8 @@ def __init__(self, zc: 'Zeroconf') -> None: self.data: Optional[bytes] = None self.last_time: float = 0 self.transport: Optional[asyncio.DatagramTransport] = None - + self.sock_name: Optional[str] = None + self.sock_fileno: Optional[int] = None self._deferred: Dict[str, List[DNSIncoming]] = {} self._timers: Dict[str, asyncio.TimerHandle] = {} @@ -294,15 +295,20 @@ def datagram_received( self.zc.handle_response(msg) return - self.handle_query_or_defer(msg, addr, port, v6_flow_scope) + self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) def handle_query_or_defer( - self, msg: DNSIncoming, addr: str, port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: asyncio.DatagramTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Deal with incoming query packets. Provides a response if possible.""" if not msg.truncated: - self._respond_query(msg, addr, port, v6_flow_scope) + self._respond_query(msg, addr, port, transport, v6_flow_scope) return deferred = self._deferred.setdefault(addr, []) @@ -315,7 +321,7 @@ def handle_query_or_defer( assert self.zc.loop is not None self._cancel_any_timers_for_addr(addr) self._timers[addr] = self.zc.loop.call_later( - delay, self._respond_query, None, addr, port, v6_flow_scope + delay, self._respond_query, None, addr, port, transport, v6_flow_scope ) def _cancel_any_timers_for_addr(self, addr: str) -> None: @@ -328,6 +334,7 @@ def _respond_query( msg: Optional[DNSIncoming], addr: str, port: int, + transport: asyncio.DatagramTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" @@ -336,15 +343,12 @@ def _respond_query( if msg: packets.append(msg) - self.zc.handle_assembled_query(packets, addr, port, v6_flow_scope) + self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) @property def _socket_description(self) -> str: """A human readable description of the socket.""" - assert self.transport is not None - fileno = self.transport.get_extra_info('socket').fileno() - sockname = self.transport.get_extra_info('sockname') - return f"{fileno} ({sockname})" + return f"{self.sock_fileno} ({self.sock_name})" def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" @@ -357,6 +361,8 @@ def error_received(self, exc: Exception) -> None: def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) + self.sock_name = self.transport.get_extra_info('sockname') + self.sock_fileno = self.transport.get_extra_info('socket').fileno() def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" @@ -400,6 +406,7 @@ def __init__( if apple_p2p and sys.platform != 'darwin': raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') + self.unicast = unicast listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) log.debug('Listen socket %s, respond sockets %s', listen_socket, respond_sockets) @@ -732,6 +739,7 @@ def handle_assembled_query( packets: List[DNSIncoming], addr: str, port: int, + transport: asyncio.DatagramTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Respond to a (re)assembled query. @@ -749,7 +757,10 @@ def handle_assembled_query( questions = packets[0].questions id_ = packets[0].id out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) - self.async_send(out, addr, port, v6_flow_scope) + # When sending unicast, only send back the reply + # via the same socket that it was recieved from + # as we know its reachable from that socket + self.async_send(out, addr, port, v6_flow_scope, transport) if question_answers.mcast_now: self.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) if question_answers.mcast_aggregate: @@ -766,10 +777,11 @@ def send( addr: Optional[str] = None, port: int = _MDNS_PORT, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + transport: Optional[asyncio.DatagramTransport] = None, ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None - self.loop.call_soon_threadsafe(self.async_send, out, addr, port, v6_flow_scope) + self.loop.call_soon_threadsafe(self.async_send, out, addr, port, v6_flow_scope, transport) def async_send( self, @@ -777,33 +789,52 @@ def async_send( addr: Optional[str] = None, port: int = _MDNS_PORT, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + transport: Optional[asyncio.DatagramTransport] = None, ) -> None: """Sends an outgoing packet.""" if self._GLOBAL_DONE: return + # If no transport is specified, we send to all the ones + # with the same address family + transports = [transport] if transport else self.engine.senders + for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return - log.debug( - 'Sending to (%s, %d) (%d bytes #%d) %r as %r...', - addr, - port, - len(packet), - packet_num + 1, - out, - packet, - ) - for transport in self.engine.senders: - s = transport.get_extra_info('socket') - if addr is None: - real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR - elif not can_send_to(s, addr): - continue - else: - real_addr = addr - transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) + for send_transport in transports: + self._async_send_transport(send_transport, packet, packet_num, out, addr, port, v6_flow_scope) + + def _async_send_transport( + self, + transport: asyncio.DatagramTransport, + packet: bytes, + packet_num: int, + out: DNSOutgoing, + addr: Optional[str], + port: int, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + s = transport.get_extra_info('socket') + if addr is None: + real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + else: + real_addr = addr + if not can_send_to(s, real_addr): + return + log.debug( + 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', + real_addr, + port or _MDNS_PORT, + s.fileno(), + transport.get_extra_info('sockname'), + len(packet), + packet_num + 1, + out, + packet, + ) + transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) def _close(self) -> None: """Set global done and remove all service listeners.""" From c77293692062ea701037e06c1cf5497f019ae2f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Aug 2021 09:11:51 -0500 Subject: [PATCH 0607/1433] Reduce chance of accidental synchronization of ServiceInfo requests (#955) --- tests/services/test_info.py | 22 ++++++++++++++++++++++ zeroconf/_services/info.py | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 0464ae7b0..2143b5fe0 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -754,3 +754,25 @@ def test_request_timeout(): # 3000ms for the default timeout # 1000ms for loaded systems + schedule overhead assert (end_time - start_time) < 3000 + 1000 + + +@pytest.mark.asyncio +async def test_we_try_four_times_with_random_delay(): + """Verify we try four times even with the random delay.""" + type_ = "_typethatisnothere._tcp.local." + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + request_count = 0 + def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal request_count + request_count += 1 + + # patch the zeroconf send + with patch.object(aiozc.zeroconf, "async_send", async_send): + await aiozc.async_get_service_info(f"willnotbefound.{type_}", type_) + + await aiozc.async_close() + + assert request_count == 4 diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index cede3877d..33c0488ac 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -21,6 +21,7 @@ """ import ipaddress +import random import socket from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, cast @@ -52,6 +53,16 @@ ) +# https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 +# The most common case for calling ServiceInfo is from a +# ServiceBrowser. After the first request we add a few random +# milliseconds to the delay between requests to reduce the chance +# that there are multiple ServiceBrowser callbacks running on +# the network that are firing at the same time when they +# see the same multicast response and decide to refresh +# the A/AAAA/SRV records for a host. +_AVOID_SYNC_DELAY_RANDOM_INTERVAL = (20, 120) + if TYPE_CHECKING: from .._core import Zeroconf @@ -455,6 +466,7 @@ async def async_request( zc.async_send(out) next_ = now + delay delay *= 2 + next_ += random.randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) await zc.async_wait(min(next_, last) - now) now = current_time_millis() From dd40437f4328f4ee36c43239ecf5f484b6ac261e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Aug 2021 13:58:11 -0500 Subject: [PATCH 0608/1433] Update changelog for 0.35.0 (#957) --- README.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.rst b/README.rst index 277d790c9..e3f99f0de 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,27 @@ See examples directory for more. Changelog ========= +0.35.0 +====== + +* Reduced chance of accidental synchronization of ServiceInfo requests (#955) @bdraco +* Sort aggregated responses to increase chance of name compression (#954) @bdraco + +Technically backwards incompatible: + +* Send unicast replies on the same socket the query was received (#952) @bdraco + + When replying to a QU question, we do not know if the sending host is reachable + from all of the sending sockets. We now avoid this problem by replying via + the receiving socket. This was the existing behavior when `InterfaceChoice.Default` + is set. + + This change extends the unicast relay behavior to used with `InterfaceChoice.Default` + to apply when `InterfaceChoice.All` or interfaces are explicitly passed when + instantiating a `Zeroconf` instance. + + Fixes #951 + 0.34.3 ====== From 1e60e13ae15a5b533a48cc955b98951eedd04dbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Aug 2021 14:11:22 -0500 Subject: [PATCH 0609/1433] =?UTF-8?q?Bump=20version:=200.34.3=20=E2=86=92?= =?UTF-8?q?=200.35.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 14216383a..cb6377b94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.34.3 +current_version = 0.35.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 2f17b31fa..d71537f67 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.34.3' +__version__ = '0.35.0' __license__ = 'LGPL' From 7b125a1a0a109ef29d0a4e736a27645a7e9b4207 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 09:27:48 -0500 Subject: [PATCH 0610/1433] Only reschedule types if the send next time changes (#958) - When the PTR response was seen again, the timer was being canceled and rescheduled even if the timer was for the same time. While this did not cause any breakage, it is quite inefficient. --- tests/services/test_browser.py | 2 +- zeroconf/_services/browser.py | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 292dee259..e22ebfe33 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -496,7 +496,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): else: assert not got_query.is_set() time_offset += initial_query_interval - zeroconf_browser.loop.call_soon_threadsafe(browser.schedule_changed) + zeroconf_browser.loop.call_soon_threadsafe(browser._async_send_ready_queries_schedule_next) finally: browser.cancel() diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 51f2c8d59..aadbd7ac6 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -224,11 +224,12 @@ def millis_to_wait(self, now: float) -> float: next_time = min(self._next_time.values()) return 0 if next_time <= now else next_time - now - def reschedule_type(self, type_: str, next_time: float) -> None: + def reschedule_type(self, type_: str, next_time: float) -> bool: """Reschedule the query for a type to happen sooner.""" if next_time >= self._next_time[type_]: - return + return False self._next_time[type_] = next_time + return True def process_ready_types(self, now: float) -> List[str]: """Generate a list of ready types that is due and schedule the next time.""" @@ -449,7 +450,8 @@ def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: async def _async_start_query_sender(self) -> None: """Start scheduling queries.""" await self.zc.async_wait_for_start() - self._async_send_ready_queries_schedule_next() + self._async_send_ready_queries() + self._async_schedule_next() def _cancel_send_timer(self) -> None: """Cancel the next send.""" @@ -458,16 +460,13 @@ def _cancel_send_timer(self) -> None: def reschedule_type(self, type_: str, next_time: float) -> None: """Reschedule a type to be refreshed in the future.""" - self.query_scheduler.reschedule_type(type_, next_time) - self.schedule_changed() - - def schedule_changed(self) -> None: - """Called when the schedule has changed.""" - self._cancel_send_timer() - self._async_send_ready_queries_schedule_next() + if self.query_scheduler.reschedule_type(type_, next_time): + self._cancel_send_timer() + self._async_schedule_next() + self._async_send_ready_queries() - def _async_send_ready_queries_schedule_next(self) -> None: - """Send any ready queries and scheule the next time.""" + def _async_send_ready_queries(self) -> None: + """Send any ready queries.""" if self.done or self.zc.done: return @@ -477,6 +476,13 @@ def _async_send_ready_queries_schedule_next(self) -> None: for out in outs: self.zc.async_send(out, addr=self.addr, port=self.port) + def _async_send_ready_queries_schedule_next(self) -> None: + """Send ready queries and schedule next one.""" + self._async_send_ready_queries() + self._async_schedule_next() + + def _async_schedule_next(self) -> None: + """Scheule the next time.""" assert self.zc.loop is not None delay = millis_to_seconds(self.query_scheduler.millis_to_wait(current_time_millis())) self._next_send_timer = self.zc.loop.call_later(delay, self._async_send_ready_queries_schedule_next) From 2d1b8329ad39b94f9f4aa5f53caf3bb2813879ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 10:15:54 -0500 Subject: [PATCH 0611/1433] Add coverage for sending answers removes future queued answers (#961) - If we send an answer that is queued to be sent out in the future we should remove it from the queue as the question has already been answered and we do not want to generate additional traffic. --- tests/services/test_info.py | 1 + tests/test_handlers.py | 39 +++++++++++++++++++++++++++++++++++++ zeroconf/_handlers.py | 21 +++++++++++--------- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 2143b5fe0..a72d82f98 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -764,6 +764,7 @@ async def test_we_try_four_times_with_random_delay(): # we are going to patch the zeroconf send to check query transmission request_count = 0 + def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" nonlocal request_count diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 1c1805088..11ea03f95 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1460,3 +1460,42 @@ async def test_response_aggregation_random_delay(): assert info3.dns_pointer() not in outgoing_queue.queue[1].answers assert info4.dns_pointer() not in outgoing_queue.queue[1].answers assert info5.dns_pointer() in outgoing_queue.queue[1].answers + + +@pytest.mark.asyncio +async def test_future_answers_are_removed_on_send(): + """Verify any future answers scheduled to be sent are removed when we send.""" + type_ = "_mservice._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-1.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + mocked_zc = unittest.mock.MagicMock() + outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) + + now = current_time_millis() + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (10, 10)): + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + + assert len(outgoing_queue.queue) == 1 + + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (20, 20)): + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + + assert len(outgoing_queue.queue) == 2 + + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (200, 200)): + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + + assert len(outgoing_queue.queue) == 3 + + await asyncio.sleep(0.1) + outgoing_queue.async_ready() + + assert len(outgoing_queue.queue) == 1 + # The answers should all get removed because we just sent them + assert len(outgoing_queue.queue[0].answers) == 0 diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index cff128704..06ed54cd1 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -527,10 +527,16 @@ def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: last_group.answers.update(answers) return else: - self.zc.loop.call_later(millis_to_seconds(random_delay), self._async_ready) + self.zc.loop.call_later(millis_to_seconds(random_delay), self.async_ready) self.queue.append(AnswerGroup(send_after, send_before, answers)) - def _async_ready(self) -> None: + def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: + """Remove a set of answers from the outgoing queue.""" + for pending in self.queue: + for record in answers: + pending.answers.pop(record, None) + + def async_ready(self) -> None: """Process anything in the queue that is ready.""" assert self.zc.loop is not None now = current_time_millis() @@ -538,7 +544,7 @@ def _async_ready(self) -> None: if len(self.queue) > 1 and self.queue[0].send_before > now: # There is more than one answer in the queue, # delay until we have to send it (first answer group reaches send_before) - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self._async_ready) + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self.async_ready) return answers: _AnswerWithAdditionalsType = {} @@ -549,12 +555,9 @@ def _async_ready(self) -> None: if len(self.queue): # If there are still groups in the queue that are not ready to send # be sure we schedule them to go out later - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self._async_ready) + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self.async_ready) if answers: - # If we have the same answer scheduled to go out, remove it - for pending in self.queue: - for record in answers: - pending.answers.pop(record, None) - + # If we have the same answer scheduled to go out, remove them + self._remove_answers_from_queue(answers) self.zc.async_send(construct_outgoing_multicast_answers(answers)) From 3b482e229d37b85e59765e023ddbca77aa513731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 10:42:14 -0500 Subject: [PATCH 0612/1433] Fix flakey test: test_future_answers_are_removed_on_send (#962) --- tests/test_handlers.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 11ea03f95..a621f0378 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1466,29 +1466,34 @@ async def test_response_aggregation_random_delay(): async def test_future_answers_are_removed_on_send(): """Verify any future answers scheduled to be sent are removed when we send.""" type_ = "_mservice._tcp.local." + type_2 = "_mservice2._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" + registration_name2 = f"{name}.{type_2}" desc = {'path': '/~paulsm/'} info = ServiceInfo( type_, registration_name, 80, 0, 0, desc, "ash-1.local.", addresses=[socket.inet_aton("10.0.1.2")] ) + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.3")] + ) mocked_zc = unittest.mock.MagicMock() outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) now = current_time_millis() - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (10, 10)): + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (1, 1)): outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (20, 20)): + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (2, 2)): outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 2 - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (200, 200)): - outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (1000, 1000)): + outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 3 @@ -1497,5 +1502,8 @@ async def test_future_answers_are_removed_on_send(): outgoing_queue.async_ready() assert len(outgoing_queue.queue) == 1 - # The answers should all get removed because we just sent them - assert len(outgoing_queue.queue[0].answers) == 0 + # The answer should get removed because we just sent it + assert info.dns_pointer() not in outgoing_queue.queue[0].answers + + # But the one we have not sent yet shoudl still go out later + assert info2.dns_pointer() in outgoing_queue.queue[0].answers From d4c109c3abffcba2331a7f9e7bf45c6477a8d4e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 10:42:22 -0500 Subject: [PATCH 0613/1433] Cache DNS record and question hashes (#960) --- tests/test_asyncio.py | 1 + tests/test_dns.py | 27 ++++++++++++++++++++++++ zeroconf/_dns.py | 48 +++++++++++++++++++++++++++---------------- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 7b8953868..e6da20a61 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -579,6 +579,7 @@ async def test_async_unregister_all_services() -> None: assert results[1] is not None await aiozc.async_unregister_all_services() + _clear_cache(aiozc.zeroconf) tasks = [] tasks.append(aiozc.async_get_service_info(type_, registration_name)) diff --git a/tests/test_dns.py b/tests/test_dns.py index a952b81ec..fe3efda88 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -163,6 +163,33 @@ def test_dns_record_is_recent(self): assert record.is_recent(now + (8 * 1000)) is False +def test_dns_question_hashablity(): + """Test DNSQuestions are hashable.""" + + record1 = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) + record2 = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) + + record_set = {record1, record2} + assert len(record_set) == 1 + + record_set.add(record1) + assert len(record_set) == 1 + + record3_dupe = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) + assert record2 == record3_dupe + assert record2.__hash__() == record3_dupe.__hash__() + + record_set.add(record3_dupe) + assert len(record_set) == 1 + + record4_dupe = r.DNSQuestion('notsame', const._TYPE_A, const._CLASS_IN) + assert record2 != record4_dupe + assert record2.__hash__() != record4_dupe.__hash__() + + record_set.add(record4_dupe) + assert len(record_set) == 2 + + def test_dns_record_hashablity_does_not_consider_ttl(): """Test DNSRecord are hashable.""" diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 5b211060a..a9bc7d770 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -22,7 +22,7 @@ import enum import socket -from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -81,10 +81,6 @@ def __init__(self, name: str, type_: int, class_: int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 - def _entry_tuple(self) -> Tuple[str, int, int]: - """Entry Tuple for DNSEntry.""" - return (self.key, self.type, self.class_) - def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" return dns_entry_matches(other, self.key, self.type, self.class_) and isinstance(other, DNSEntry) @@ -115,12 +111,22 @@ class DNSQuestion(DNSEntry): """A DNS question entry""" + __slots__ = ('_hash',) + + def __init__(self, name: str, type_: int, class_: int) -> None: + super().__init__(name, type_, class_) + self._hash = hash((self.key, type_, class_)) + def answered_by(self, rec: 'DNSRecord') -> bool: """Returns true if the question is answered by the record""" return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name def __hash__(self) -> int: - return hash((self.name, self.class_, self.type)) + return self._hash + + def __eq__(self, other: Any) -> bool: + """Tests equality on dns question.""" + return isinstance(other, DNSQuestion) and DNSEntry.__eq__(self, other) @property def max_size(self) -> int: @@ -225,7 +231,7 @@ class DNSAddress(DNSRecord): """A DNS address record""" - __slots__ = ('address', 'scope_id') + __slots__ = ('_hash', 'address', 'scope_id') def __init__( self, @@ -241,6 +247,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.address = address self.scope_id = scope_id + self._hash = hash((self.key, type_, class_, address, scope_id)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -257,7 +264,7 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: """Hash to compare like DNSAddresses.""" - return hash((*self._entry_tuple(), self.address, self.scope_id)) + return self._hash def __repr__(self) -> str: """String representation""" @@ -275,7 +282,7 @@ class DNSHinfo(DNSRecord): """A DNS host information record""" - __slots__ = ('cpu', 'os') + __slots__ = ('_hash', 'cpu', 'os') def __init__( self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str, created: Optional[float] = None @@ -283,6 +290,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.cpu = cpu self.os = os + self._hash = hash((self.key, type_, class_, cpu, os)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -300,7 +308,7 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: """Hash to compare like DNSHinfo.""" - return hash((*self._entry_tuple(), self.cpu, self.os)) + return self._hash def __repr__(self) -> str: """String representation""" @@ -311,13 +319,14 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" - __slots__ = ('alias',) + __slots__ = ('_hash', 'alias') def __init__( self, name: str, type_: int, class_: int, ttl: int, alias: str, created: Optional[float] = None ) -> None: super().__init__(name, type_, class_, ttl, created) self.alias = alias + self._hash = hash((self.key, type_, class_, alias)) @property def max_size_compressed(self) -> int: @@ -339,7 +348,7 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: """Hash to compare like DNSPointer.""" - return hash((*self._entry_tuple(), self.alias)) + return self._hash def __repr__(self) -> str: """String representation""" @@ -350,7 +359,7 @@ class DNSText(DNSRecord): """A DNS text record""" - __slots__ = ('text',) + __slots__ = ('_hash', 'text') def __init__( self, name: str, type_: int, class_: int, ttl: int, text: bytes, created: Optional[float] = None @@ -358,6 +367,7 @@ def __init__( assert isinstance(text, (bytes, type(None))) super().__init__(name, type_, class_, ttl, created) self.text = text + self._hash = hash((self.key, type_, class_, text)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -365,7 +375,7 @@ def write(self, out: 'DNSOutgoing') -> None: def __hash__(self) -> int: """Hash to compare like DNSText.""" - return hash((*self._entry_tuple(), self.text)) + return self._hash def __eq__(self, other: Any) -> bool: """Tests equality on text""" @@ -382,7 +392,7 @@ class DNSService(DNSRecord): """A DNS service record""" - __slots__ = ('priority', 'weight', 'port', 'server') + __slots__ = ('_hash', 'priority', 'weight', 'port', 'server') def __init__( self, @@ -401,6 +411,7 @@ def __init__( self.weight = weight self.port = port self.server = server + self._hash = hash((self.key, type_, class_, priority, weight, port, server)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -422,7 +433,7 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: """Hash to compare like DNSService.""" - return hash((*self._entry_tuple(), self.priority, self.weight, self.port, self.server)) + return self._hash def __repr__(self) -> str: """String representation""" @@ -433,7 +444,7 @@ class DNSNsec(DNSRecord): """A DNS NSEC record""" - __slots__ = ('next_name', 'rdtypes') + __slots__ = ('_hash', 'next_name', 'rdtypes') def __init__( self, @@ -448,6 +459,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.next_name = next_name self.rdtypes = rdtypes + self._hash = hash((self.key, type_, class_, next_name, *self.rdtypes)) def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" @@ -460,7 +472,7 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: """Hash to compare like DNSNSec.""" - return hash((*self._entry_tuple(), self.next_name, *self.rdtypes)) + return self._hash def __repr__(self) -> str: """String representation""" From f7bebfe09aeb9bb973dbe6ba147b682472b64246 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 14:11:54 -0500 Subject: [PATCH 0614/1433] Update changelog for 0.35.1 (#963) --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index e3f99f0de..fbf2e4fe0 100644 --- a/README.rst +++ b/README.rst @@ -140,6 +140,18 @@ See examples directory for more. Changelog ========= +0.35.1 +====== + +* Only reschedule types if the send next time changes (#958) @bdraco + When the PTR response was seen again, the timer was being canceled and + rescheduled even if the timer was for the same time. While this did + not cause any breakage, it is quite inefficient. +* Cache DNS record and question hashes (#960) @bdraco + The hash was being recalculated every time the object + was being used in a set or dict. Since the hashes are + effectively immutable, we only calculate them once now. + 0.35.0 ====== From c7c7d4778e9962af5180616af73977d8503e4762 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 14:13:41 -0500 Subject: [PATCH 0615/1433] Fix formatting in 0.35.1 changelog entry (#964) --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index fbf2e4fe0..4b14550d9 100644 --- a/README.rst +++ b/README.rst @@ -144,10 +144,12 @@ Changelog ====== * Only reschedule types if the send next time changes (#958) @bdraco + When the PTR response was seen again, the timer was being canceled and rescheduled even if the timer was for the same time. While this did not cause any breakage, it is quite inefficient. * Cache DNS record and question hashes (#960) @bdraco + The hash was being recalculated every time the object was being used in a set or dict. Since the hashes are effectively immutable, we only calculate them once now. From 4281221b668123b770c6d6b0835dd876d1d2f22d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Aug 2021 14:14:10 -0500 Subject: [PATCH 0616/1433] =?UTF-8?q?Bump=20version:=200.35.0=20=E2=86=92?= =?UTF-8?q?=200.35.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index cb6377b94..95f97b88a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.35.0 +current_version = 0.35.1 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d71537f67..b90d2a998 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.35.0' +__version__ = '0.35.1' __license__ = 'LGPL' From 733eb3a31ed40c976f5fa4b7b3baf055589ef36b Mon Sep 17 00:00:00 2001 From: Lokesh Date: Mon, 16 Aug 2021 20:26:26 +0100 Subject: [PATCH 0617/1433] Create full IPv6 address tuple to enable service discovery on Windows (#965) --- README.rst | 2 -- zeroconf/_core.py | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 4b14550d9..2b176e29c 100644 --- a/README.rst +++ b/README.rst @@ -75,8 +75,6 @@ IPv6 support is relatively new and currently limited, specifically: * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX systems. -* On Windows specific interfaces can only be requested as interface indexes, - not as IP addresses. * Dual-stack IPv6 sockets are used, which may not be supported everywhere (some BSD variants do not have them). * Listening on localhost (`::1`) does not work. Help with understanding why is diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 72c6e4ceb..96b1a790e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -834,6 +834,11 @@ def _async_send_transport( out, packet, ) + # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 + # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families + if s.family == socket.AF_INET6 and not v6_flow_scope: + _, _, sock_flowinfo, sock_scopeid = s.getsockname() + v6_flow_scope = (sock_flowinfo, sock_scopeid) transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) def _close(self) -> None: From bc50bce04b650756fef3f8b1cce6defbc5dccee5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Aug 2021 14:44:00 -0500 Subject: [PATCH 0618/1433] Update changelog for 0.36.0 (#966) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index 2b176e29c..a766e58af 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,15 @@ See examples directory for more. Changelog ========= +0.36.0 +====== + +Technically backwards incompatible: + +* Fill incomplete IPv6 tuples to avoid WinError on windows (#965) @lokesh2019 + + Fixed #932 + 0.35.1 ====== From e4985c7dd2088d4da9fc2be25f67beb65f548e95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Aug 2021 14:44:39 -0500 Subject: [PATCH 0619/1433] =?UTF-8?q?Bump=20version:=200.35.1=20=E2=86=92?= =?UTF-8?q?=200.36.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 95f97b88a..716f46614 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.35.1 +current_version = 0.36.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b90d2a998..22e0af996 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.35.1' +__version__ = '0.36.0' __license__ = 'LGPL' From 574e24125a536dc4fb9a1784797efd495ceb1fdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 13:08:58 -0500 Subject: [PATCH 0620/1433] Fix equality and hash for dns records with the unique bit (#969) --- tests/test_dns.py | 15 +++++++++++++++ zeroconf/_dns.py | 14 +++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index fe3efda88..c26692058 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -211,6 +211,21 @@ def test_dns_record_hashablity_does_not_consider_ttl(): assert len(record_set) == 1 +def test_dns_record_hashablity_does_not_consider_unique(): + """Test DNSRecord are hashable and unique is ignored.""" + + # Verify the unique value is not considered in the hash + record1 = r.DNSAddress( + 'irrelevant', const._TYPE_A, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, b'same' + ) + record2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b'same') + + assert record1.class_ == record2.class_ + assert record1.__hash__() == record2.__hash__() + record_set = {record1, record2} + assert len(record_set) == 1 + + def test_dns_address_record_hashablity(): """Test DNSAddress are hashable.""" address1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'a') diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index a9bc7d770..0d09a4218 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -115,7 +115,7 @@ class DNSQuestion(DNSEntry): def __init__(self, name: str, type_: int, class_: int) -> None: super().__init__(name, type_, class_) - self._hash = hash((self.key, type_, class_)) + self._hash = hash((self.key, type_, self.class_)) def answered_by(self, rec: 'DNSRecord') -> bool: """Returns true if the question is answered by the record""" @@ -247,7 +247,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.address = address self.scope_id = scope_id - self._hash = hash((self.key, type_, class_, address, scope_id)) + self._hash = hash((self.key, type_, self.class_, address, scope_id)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -290,7 +290,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.cpu = cpu self.os = os - self._hash = hash((self.key, type_, class_, cpu, os)) + self._hash = hash((self.key, type_, self.class_, cpu, os)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -326,7 +326,7 @@ def __init__( ) -> None: super().__init__(name, type_, class_, ttl, created) self.alias = alias - self._hash = hash((self.key, type_, class_, alias)) + self._hash = hash((self.key, type_, self.class_, alias)) @property def max_size_compressed(self) -> int: @@ -367,7 +367,7 @@ def __init__( assert isinstance(text, (bytes, type(None))) super().__init__(name, type_, class_, ttl, created) self.text = text - self._hash = hash((self.key, type_, class_, text)) + self._hash = hash((self.key, type_, self.class_, text)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -411,7 +411,7 @@ def __init__( self.weight = weight self.port = port self.server = server - self._hash = hash((self.key, type_, class_, priority, weight, port, server)) + self._hash = hash((self.key, type_, self.class_, priority, weight, port, server)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -459,7 +459,7 @@ def __init__( super().__init__(name, type_, class_, ttl, created) self.next_name = next_name self.rdtypes = rdtypes - self._hash = hash((self.key, type_, class_, next_name, *self.rdtypes)) + self._hash = hash((self.key, type_, self.class_, next_name, *self.rdtypes)) def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" From d9d3208eed84b71b61c458f2992b08b5db259da1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 13:19:13 -0500 Subject: [PATCH 0621/1433] Skip goodbye packets for addresses when there is another service registered with the same name (#968) --- tests/services/test_registry.py | 23 ++++++ tests/test_asyncio.py | 134 ++++++++++++++++++++++++++++++++ zeroconf/_core.py | 39 ++++++++-- 3 files changed, 188 insertions(+), 8 deletions(-) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 87c048d5c..3c105cbb3 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -28,6 +28,29 @@ def test_only_register_once(self): registry.async_remove(info) registry.async_add(info) + def test_register_same_server(self): + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "xxxyyy2" + registration_name = "%s.%s" % (name, type_) + registration_name2 = "%s.%s" % (name2, type_) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "same.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + info2 = ServiceInfo( + type_, registration_name2, 80, 0, 0, desc, "same.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + registry = r.ServiceRegistry() + registry.async_add(info) + registry.async_add(info2) + assert registry.async_get_infos_server("same.local.") == [info, info2] + registry.async_remove(info) + assert registry.async_get_infos_server("same.local.") == [info2] + registry.async_remove(info2) + assert registry.async_get_infos_server("same.local.") == [] + def test_unregister_multiple_times(self): """Verify we can unregister a service multiple times. diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e6da20a61..ea80d6f5f 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -173,6 +173,140 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] +@pytest.mark.asyncio +async def test_async_service_registration_same_server_different_ports() -> None: + """Test registering services with the same server with different srv records.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "xxxyyy2" + + registration_name = f"{name}.{type_}" + registration_name2 = f"{name2}.{type_}" + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info2 = ServiceInfo( + type_, + registration_name2, + 81, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + tasks = [] + tasks.append(await aiozc.async_register_service(info)) + tasks.append(await aiozc.async_register_service(info2)) + await asyncio.gather(*tasks) + + task = await aiozc.async_unregister_service(info) + await task + entries = aiozc.zeroconf.cache.async_entries_with_server("ash-2.local.") + assert len(entries) == 1 + assert info2.dns_service() in entries + await aiozc.async_close() + assert calls == [ + ('add', type_, registration_name), + ('add', type_, registration_name2), + ('remove', type_, registration_name), + ('remove', type_, registration_name2), + ] + + +@pytest.mark.asyncio +async def test_async_service_registration_same_server_same_ports() -> None: + """Test registering services with the same server with the exact same srv record.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + name2 = "xxxyyy2" + + registration_name = f"{name}.{type_}" + registration_name2 = f"{name2}.{type_}" + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + info2 = ServiceInfo( + type_, + registration_name2, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + tasks = [] + tasks.append(await aiozc.async_register_service(info)) + tasks.append(await aiozc.async_register_service(info2)) + await asyncio.gather(*tasks) + + task = await aiozc.async_unregister_service(info) + await task + entries = aiozc.zeroconf.cache.async_entries_with_server("ash-2.local.") + assert len(entries) == 1 + assert info2.dns_service() in entries + await aiozc.async_close() + assert calls == [ + ('add', type_, registration_name), + ('add', type_, registration_name2), + ('remove', type_, registration_name), + ('remove', type_, registration_name2), + ] + + @pytest.mark.asyncio async def test_async_service_registration_name_conflict() -> None: """Test registering services throws on name conflict.""" diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 96b1a790e..c9c4c5ec9 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -571,17 +571,28 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: self.registry.async_update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) - async def _async_broadcast_service(self, info: ServiceInfo, interval: int, ttl: Optional[int]) -> None: + async def _async_broadcast_service( + self, + info: ServiceInfo, + interval: int, + ttl: Optional[int], + broadcast_addresses: bool = True, + ) -> None: """Send a broadcasts to announce a service at intervals.""" for i in range(_REGISTER_BROADCASTS): if i != 0: await asyncio.sleep(millis_to_seconds(interval)) - self.async_send(self.generate_service_broadcast(info, ttl)) + self.async_send(self.generate_service_broadcast(info, ttl, broadcast_addresses)) - def generate_service_broadcast(self, info: ServiceInfo, ttl: Optional[int]) -> DNSOutgoing: + def generate_service_broadcast( + self, + info: ServiceInfo, + ttl: Optional[int], + broadcast_addresses: bool = True, + ) -> DNSOutgoing: """Generate a broadcast to announce a service.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA) - self._add_broadcast_answer(out, info, ttl) + self._add_broadcast_answer(out, info, ttl, broadcast_addresses) return out def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: disable=no-self-use @@ -600,7 +611,11 @@ def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: d return out def _add_broadcast_answer( # pylint: disable=no-self-use - self, out: DNSOutgoing, info: ServiceInfo, override_ttl: Optional[int] + self, + out: DNSOutgoing, + info: ServiceInfo, + override_ttl: Optional[int], + broadcast_addresses: bool = True, ) -> None: """Add answers to broadcast a service.""" now = current_time_millis() @@ -609,8 +624,9 @@ def _add_broadcast_answer( # pylint: disable=no-self-use out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl, created=now), 0) out.add_answer_at_time(info.dns_service(override_ttl=host_ttl, created=now), 0) out.add_answer_at_time(info.dns_text(override_ttl=other_ttl, created=now), 0) - for dns_address in info.dns_addresses(override_ttl=host_ttl, created=now): - out.add_answer_at_time(dns_address, 0) + if broadcast_addresses: + for dns_address in info.dns_addresses(override_ttl=host_ttl, created=now): + out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service.""" @@ -622,7 +638,14 @@ def unregister_service(self, info: ServiceInfo) -> None: async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service.""" self.registry.async_remove(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _UNREGISTER_TIME, 0)) + # If another server uses the same addresses, we do not want to send + # goodbye packets for the address records + + entries = self.registry.async_get_infos_server(info.server) + broadcast_addresses = not bool(entries) + return asyncio.ensure_future( + self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) + ) def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: """Generate a DNSOutgoing goodbye for all services and remove them from the registry.""" From d5043337de39a11b2b241e9247a34c41c0c7c2bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 13:29:19 -0500 Subject: [PATCH 0622/1433] Update changelog for 0.36.1 (#970) --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index a766e58af..f2c156a20 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,20 @@ See examples directory for more. Changelog ========= +0.36.1 +====== + +* Skip goodbye packets for addresses when there is another service registered with the same name (#968) @bdraco + + If a ServiceInfo that used the same server name as another ServiceInfo + was unregistered, goodbye packets would be sent for the addresses and + would cause the other service to be seen as offline. +* Fixed equality and hash for dns records with the unique bit (#969) @bdraco + + These records should have the same hash and equality since + the unique bit (cache flush bit) is not considered when adding or removing + the records from the cache. + 0.36.0 ====== From e8d84017b750ab5f159abc7225f9922d84a8f9fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 13:41:54 -0500 Subject: [PATCH 0623/1433] =?UTF-8?q?Bump=20version:=200.36.0=20=E2=86=92?= =?UTF-8?q?=200.36.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 716f46614..92cc1627e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.0 +current_version = 0.36.1 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 22e0af996..5fe885f79 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.0' +__version__ = '0.36.1' __license__ = 'LGPL' From 768a23c656e3f091ecbecbb6b380b5becbbf9674 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Aug 2021 17:40:12 -0500 Subject: [PATCH 0624/1433] Add support for writing NSEC records (#971) --- tests/test_protocol.py | 29 +++++++++++++++++++++++++++++ zeroconf/_dns.py | 18 +++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8c2f92c4f..6ad3303bb 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -54,6 +54,35 @@ def test_parse_own_packet_question(self): generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) r.DNSIncoming(generated.packets()[0]) + def test_parse_own_packet_nsec(self): + answer = r.DNSNsec( + 'eufy HomeBase2-2464._hap._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'eufy HomeBase2-2464._hap._tcp.local.', + [const._TYPE_TXT, const._TYPE_SRV], + ) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time(answer, 0) + parsed = r.DNSIncoming(generated.packets()[0]) + assert answer in parsed.answers + + # Types > 255 should be ignored + answer_invalid_types = r.DNSNsec( + 'eufy HomeBase2-2464._hap._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'eufy HomeBase2-2464._hap._tcp.local.', + [const._TYPE_TXT, const._TYPE_SRV, 1000], + ) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time(answer_invalid_types, 0) + parsed = r.DNSIncoming(generated.packets()[0]) + assert answer in parsed.answers + def test_parse_own_packet_response(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 0d09a4218..bb447b2f4 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -458,9 +458,25 @@ def __init__( ) -> None: super().__init__(name, type_, class_, ttl, created) self.next_name = next_name - self.rdtypes = rdtypes + self.rdtypes = sorted(rdtypes) self._hash = hash((self.key, type_, self.class_, next_name, *self.rdtypes)) + def write(self, out: 'DNSOutgoing') -> None: + """Used in constructing an outgoing packet.""" + bitmap = bytearray(b'\0' * 32) + for rdtype in self.rdtypes: + if rdtype > 255: # mDNS only supports window 0 + continue + offset = rdtype % 256 + byte = offset // 8 + total_octets = byte + 1 + bitmap[byte] |= 0x80 >> (offset % 8) + out_bytes = bytes(bitmap[0:total_octets]) + out.write_name(self.next_name) + out.write_short(0) + out.write_short(len(out_bytes)) + out.write_string(out_bytes) + def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os""" return ( From 7a20fd3bc8dc0a703619ca9413faf674b3d7a111 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 09:50:16 -0500 Subject: [PATCH 0625/1433] Include NSEC records for non-existant types when responding with addresses (#972) Implements datatracker.ietf.org/doc/html/rfc6762#section-6.2 --- tests/test_handlers.py | 49 +++++++++++++++++++++++++++++++------- zeroconf/_handlers.py | 54 +++++++++++++++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index a621f0378..44ee1d5af 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -108,8 +108,9 @@ def _process_outgoing_packet(out): _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # The additonals should all be suppresed since they are all in the answers section + # There will be one NSEC additional to indicate the lack of AAAA record # - assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 + assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -143,7 +144,9 @@ def _process_outgoing_packet(out): [r.DNSIncoming(packet) for packet in query.packets()], False ) _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) - assert nbr_answers == 4 and nbr_additionals == 0 and nbr_authorities == 0 + + # There will be one NSEC additional to indicate the lack of AAAA record + assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 # unregister @@ -271,7 +274,9 @@ def test_ptr_optimization(): has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 + assert nbr_answers == 1 and nbr_additionals == 4 + # There will be one NSEC additional to indicate the lack of AAAA record + assert has_srv and has_txt and has_a # unregister @@ -406,7 +411,7 @@ def test_unicast_response(): [r.DNSIncoming(packet) for packet in query.packets()], True ) for answers in (question_answers.ucast, question_answers.mcast_aggregate): - has_srv = has_txt = has_a = False + has_srv = has_txt = has_a = has_aaaa = has_nsec = False nbr_additionals = 0 nbr_answers = len(answers) additionals = set().union(*answers.values()) @@ -418,8 +423,14 @@ def test_unicast_response(): has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 - assert has_srv and has_txt and has_a + elif answer.type == const._TYPE_AAAA: + has_aaaa = True + elif answer.type == const._TYPE_NSEC: + has_nsec = True + # There will be one NSEC additional to indicate the lack of AAAA record + assert nbr_answers == 1 and nbr_additionals == 4 + assert has_srv and has_txt and has_a and has_nsec + assert not has_aaaa # unregister zc.registry.async_remove(info) @@ -497,7 +508,7 @@ def test_qu_response(): zc.register_service(info) def _validate_complete_response(answers): - has_srv = has_txt = has_a = False + has_srv = has_txt = has_a = has_aaaa = has_nsec = False nbr_answers = len(answers.keys()) additionals = set().union(*answers.values()) nbr_additionals = len(additionals) @@ -509,8 +520,13 @@ def _validate_complete_response(answers): has_txt = True elif answer.type == const._TYPE_A: has_a = True - assert nbr_answers == 1 and nbr_additionals == 3 - assert has_srv and has_txt and has_a + elif answer.type == const._TYPE_AAAA: + has_aaaa = True + elif answer.type == const._TYPE_NSEC: + has_nsec = True + assert nbr_answers == 1 and nbr_additionals == 4 + assert has_srv and has_txt and has_a and has_nsec + assert not has_aaaa # With QU should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -635,6 +651,21 @@ def test_known_answer_supression(): assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second + # Test NSEC record returned when there is no AAAA record and we expectly ask + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) + generated.add_question(question) + for dns_address in info.dns_addresses(): + generated.add_answer_at_time(dns_address, now) + packets = generated.packets() + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + expected_nsec_record: r.DNSNsec = list(question_answers.mcast_now)[0] + assert const._TYPE_A not in expected_nsec_record.rdtypes + assert const._TYPE_AAAA in expected_nsec_record.rdtypes + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + # Test SRV supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 06ed54cd1..76ba6cc3a 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -26,15 +26,17 @@ from typing import Dict, Iterable, List, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from ._cache import DNSCache, _UniqueRecordsType -from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord +from ._dns import DNSAddress, DNSNsec, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord from ._history import QuestionHistory from ._logger import log from ._protocol import DNSIncoming, DNSOutgoing +from ._services.info import ServiceInfo from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _CLASS_IN, + _CLASS_UNIQUE, _DNS_OTHER_TTL, _DNS_PTR_MIN_TTL, _FLAGS_AA, @@ -44,6 +46,7 @@ _TYPE_A, _TYPE_AAAA, _TYPE_ANY, + _TYPE_NSEC, _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, @@ -56,7 +59,8 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] _MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) -_RESPOND_IMMEDIATE_TYPES = {_TYPE_SRV, _TYPE_A, _TYPE_AAAA} +_ADDRESS_RECORD_TYPES = {_TYPE_A, _TYPE_AAAA} +_RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} class QuestionAnswers(NamedTuple): @@ -78,6 +82,15 @@ def _message_is_probe(msg: DNSIncoming) -> bool: return msg.num_authorities > 0 +def construct_nsec_record(name: str, types: List[int], now: float) -> DNSNsec: + """Construct an NSEC record for name and a list of dns types. + + This function should only be used for SRV/A/AAAA records + which have a TTL of _DNS_OTHER_TTL + """ + return DNSNsec(name, _TYPE_NSEC, _CLASS_IN | _CLASS_UNIQUE, _DNS_OTHER_TTL, name, types, created=now) + + def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) @@ -244,12 +257,23 @@ def _add_pointer_answers( # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer(created=now) - if not known_answers.suppresses(dns_pointer): - answer_set[dns_pointer] = { - service.dns_service(created=now), - service.dns_text(created=now), - *service.dns_addresses(created=now), - } + if known_answers.suppresses(dns_pointer): + continue + additionals: Set[DNSRecord] = {service.dns_service(created=now), service.dns_text(created=now)} + additionals |= self._get_address_and_nsec_records(service, now) + answer_set[dns_pointer] = additionals + + def _get_address_and_nsec_records(self, service: ServiceInfo, now: float) -> Set[DNSRecord]: + """Build a set of address records and NSEC records for non-present record types.""" + seen_types: Set[int] = set() + records: Set[DNSRecord] = set() + for dns_address in service.dns_addresses(created=now): + seen_types.add(dns_address.type) + records.add(dns_address) + missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + if missing_types: + records.add(construct_nsec_record(service.server, list(missing_types), now)) + return records def _add_address_answers( self, @@ -263,13 +287,21 @@ def _add_address_answers( for service in self.registry.async_get_infos_server(name): answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() + seen_types: Set[int] = set() for dns_address in service.dns_addresses(created=now): + seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) elif not known_answers.suppresses(dns_address): answers.append(dns_address) - for answer in answers: - answer_set[answer] = additionals + missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + if answers: + if missing_types: + additionals.add(construct_nsec_record(service.server, list(missing_types), now)) + for answer in answers: + answer_set[answer] = additionals + elif type_ in missing_types: + answer_set[construct_nsec_record(service.server, list(missing_types), now)] = set() def _answer_question( self, @@ -299,7 +331,7 @@ def _answer_question( # https://tools.ietf.org/html/rfc6763#section-12.2. dns_service = service.dns_service(created=now) if not known_answers.suppresses(dns_service): - answer_set[dns_service] = set(service.dns_addresses(created=now)) + answer_set[dns_service] = self._get_address_and_nsec_records(service, now) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service.dns_text(created=now) if not known_answers.suppresses(dns_text): From b4efa33b4ef6d5292d8d477da4258d99d22c4e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 10:04:09 -0500 Subject: [PATCH 0626/1433] Update changelog for 0.36.2 (#973) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index f2c156a20..2b7cbd5e0 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,12 @@ See examples directory for more. Changelog ========= +0.36.2 +====== + +* Include NSEC records for non-existent types when responding with addresses (#972) (#971) @bdraco + Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2) + 0.36.1 ====== From 5f52438f4c0851bb1a3b78575c0c28e0b6ce561d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Aug 2021 10:04:19 -0500 Subject: [PATCH 0627/1433] =?UTF-8?q?Bump=20version:=200.36.1=20=E2=86=92?= =?UTF-8?q?=200.36.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 92cc1627e..2d2433b8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.1 +current_version = 0.36.2 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5fe885f79..f0fce54a8 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.1' +__version__ = '0.36.2' __license__ = 'LGPL' From 78f9cd5123d0e3c582aba05bd61388419d4dc01e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Sep 2021 10:11:49 -1000 Subject: [PATCH 0628/1433] Reduce DNSIncoming parsing overhead (#975) - Parsing incoming packets is the most expensive operation zeroconf performs on networks with high mDNS volume --- zeroconf/__init__.py | 1 - zeroconf/_protocol.py | 26 +++++++++++--------------- zeroconf/_services/browser.py | 6 ++---- zeroconf/_services/info.py | 3 +-- zeroconf/_utils/struct.py | 25 ------------------------- 5 files changed, 14 insertions(+), 47 deletions(-) delete mode 100644 zeroconf/_utils/struct.py diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index f0fce54a8..347bbdd3c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -75,7 +75,6 @@ IPVersion, get_all_addresses, ) -from ._utils.struct import int2byte # noqa # import needed for backwards compat from ._utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat __author__ = 'Paul Scott-Murphy, William McBrine' diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index b87e67d87..15c7533f0 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -28,7 +28,6 @@ from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText from ._exceptions import IncomingDecodeError, NamePartTooLongException from ._logger import QuietLogger, log -from ._utils.struct import int2byte from ._utils.time import current_time_millis from .const import ( _CLASS_UNIQUE, @@ -64,6 +63,8 @@ class DNSMessage: """A base class for DNS messages.""" + __slots__ = ('flags',) + def __init__(self, flags: int) -> None: """Construct a DNS message.""" self.flags = flags @@ -128,11 +129,9 @@ def __repr__(self) -> str: ] ) - def unpack(self, format_: bytes) -> tuple: - length = struct.calcsize(format_) - info = struct.unpack(format_, self.data[self.offset : self.offset + length]) + def unpack(self, format_: bytes, length: int) -> tuple: self.offset += length - return info + return struct.unpack(format_, self.data[self.offset - length : self.offset]) def read_header(self) -> None: """Reads header portion of packet""" @@ -143,16 +142,14 @@ def read_header(self) -> None: self.num_answers, self.num_authorities, self.num_additionals, - ) = self.unpack(b'!6H') + ) = self.unpack(b'!6H', 12) def read_questions(self) -> None: """Reads questions section of packet""" for _ in range(self.num_questions): name = self.read_name() - type_, class_ = self.unpack(b'!HH') - - question = DNSQuestion(name, type_, class_) - self.questions.append(question) + type_, class_ = self.unpack(b'!HH', 4) + self.questions.append(DNSQuestion(name, type_, class_)) def read_character_string(self) -> bytes: """Reads a character string from the packet""" @@ -168,7 +165,7 @@ def read_string(self, length: int) -> bytes: def read_unsigned_short(self) -> int: """Reads an unsigned short from the packet""" - return cast(int, self.unpack(b'!H')[0]) + return cast(int, self.unpack(b'!H', 2)[0]) def read_others(self) -> None: """Reads the answers, authorities and additionals section of the @@ -176,7 +173,7 @@ def read_others(self) -> None: n = self.num_answers + self.num_authorities + self.num_additionals for _ in range(n): domain = self.read_name() - type_, class_, ttl, length = self.unpack(b'!HHiH') + type_, class_, ttl, length = self.unpack(b'!HHiH', 10) end = self.offset + length rec = None try: @@ -266,8 +263,7 @@ def read_name(self) -> str: labels: List[str] = [] self.seen_pointers.clear() self.offset = self._decode_labels_at_offset(self.offset, labels) - labels.append("") - name = ".".join(labels) + name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") return name @@ -440,7 +436,7 @@ def _pack(self, format_: Union[bytes, str], value: Any) -> None: def _write_byte(self, value: int) -> None: """Writes a single byte to the packet""" - self._pack(b'!c', int2byte(value)) + self._pack(b'!c', bytes((value,))) def _insert_short_at_start(self, value: int) -> None: """Inserts an unsigned short at the start of the packet""" diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index aadbd7ac6..d47e42e92 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -352,21 +352,19 @@ def _async_process_record_update( self, now: float, record: DNSRecord, old_record: Optional[DNSRecord] ) -> None: """Process a single record update from a batch of updates.""" - expired = record.is_expired(now) - if isinstance(record, DNSPointer): if record.name not in self.types: return if old_record is None: self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) - elif expired: + elif record.is_expired(now): self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) else: self.reschedule_type(record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) return # If its expired or already exists in the cache it cannot be updated. - if expired or old_record: + if old_record or record.is_expired(now): return if isinstance(record, DNSAddress): diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 33c0488ac..7aaea1b6a 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -36,7 +36,6 @@ _encode_address, _is_v6_address, ) -from .._utils.struct import int2byte from .._utils.time import current_time_millis from ..const import ( _CLASS_IN, @@ -239,7 +238,7 @@ def _set_properties(self, properties: Dict) -> None: record += b'=' + value list_.append(record) for item in list_: - result = b''.join((result, int2byte(len(item)), item)) + result = b''.join((result, bytes((len(item),)), item)) self.text = result def _set_text(self, text: bytes) -> None: diff --git a/zeroconf/_utils/struct.py b/zeroconf/_utils/struct.py deleted file mode 100644 index 6ec999882..000000000 --- a/zeroconf/_utils/struct.py +++ /dev/null @@ -1,25 +0,0 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA -""" - -import struct - -int2byte = struct.Struct(">B").pack From 84f16bff6df41f1907e060e7bd4ce24d173d51c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Sep 2021 10:19:44 -1000 Subject: [PATCH 0629/1433] Update changelog for 0.36.3 (#977) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 2b7cbd5e0..7c1c2863a 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,11 @@ See examples directory for more. Changelog ========= +0.36.3 +====== + +* Improved performance of parsing incoming packets (#975) @bdraco + 0.36.2 ====== From 769b3973835ebc6f5a34e236a01cb2cd935e81de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Sep 2021 15:20:09 -0500 Subject: [PATCH 0630/1433] =?UTF-8?q?Bump=20version:=200.36.2=20=E2=86=92?= =?UTF-8?q?=200.36.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 2d2433b8f..7727170ec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.2 +current_version = 0.36.3 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 347bbdd3c..c037c420d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.2' +__version__ = '0.36.3' __license__ = 'LGPL' From f1d6fc3f60e685ff63b1a1cb820cfc3ca5268fcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 10:25:54 -1000 Subject: [PATCH 0631/1433] Reduce name compression overhead and complexity (#978) --- zeroconf/_protocol.py | 65 +++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 15c7533f0..713d5d918 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -430,13 +430,13 @@ def add_question_or_all_cache( for cached_entry in cached_entries: self.add_answer_at_time(cached_entry, now) - def _pack(self, format_: Union[bytes, str], value: Any) -> None: + def _pack(self, format_: Union[bytes, str], size: int, value: Any) -> None: self.data.append(struct.pack(format_, value)) - self.size += struct.calcsize(format_) + self.size += size def _write_byte(self, value: int) -> None: """Writes a single byte to the packet""" - self._pack(b'!c', bytes((value,))) + self._pack(b'!c', 1, bytes((value,))) def _insert_short_at_start(self, value: int) -> None: """Inserts an unsigned short at the start of the packet""" @@ -448,11 +448,11 @@ def _replace_short(self, index: int, value: int) -> None: def write_short(self, value: int) -> None: """Writes an unsigned short to the packet""" - self._pack(b'!H', value) + self._pack(b'!H', 2, value) def _write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" - self._pack(b'!I', int(value)) + self._pack(b'!I', 4, int(value)) def write_string(self, value: bytes) -> None: """Writes a string to the packet""" @@ -491,38 +491,29 @@ def write_name(self, name: str) -> None: """ # split name into each label - parts = name.split('.') - if not parts[-1]: - parts.pop() - - # construct each suffix - name_suffices = ['.'.join(parts[i:]) for i in range(len(parts))] - - # look for an existing name or suffix - for count, sub_name in enumerate(name_suffices): - if sub_name in self.names: - break - else: - count = len(name_suffices) - - # note the new names we are saving into the packet - name_length = len(name.encode('utf-8')) - for suffix in name_suffices[:count]: - self.names[suffix] = self.size + name_length - len(suffix.encode('utf-8')) - 1 - - # write the new names out. - for part in parts[:count]: - self._write_utf(part) - - # if we wrote part of the name, create a pointer to the rest - if count != len(name_suffices): - # Found substring in packet, create pointer - index = self.names[name_suffices[count]] - self._write_byte((index >> 8) | 0xC0) - self._write_byte(index & 0xFF) - else: - # this is the end of a name - self._write_byte(0) + name_length = None + if name.endswith('.'): + name = name[: len(name) - 1] + labels = name.split('.') + # Write each new label or a pointer to the existing + # on in the packet + start_size = self.size + for count in range(len(labels)): + label = name if count == 0 else '.'.join(labels[count:]) + index = self.names.get(label) + if index: + # If part of the name already exists in the packet, + # create a pointer to it + self._write_byte((index >> 8) | 0xC0) + self._write_byte(index & 0xFF) + return + if name_length is None: + name_length = len(name.encode('utf-8')) + self.names[label] = start_size + name_length - len(label.encode('utf-8')) + self._write_utf(labels[count]) + + # this is the end of a name + self._write_byte(0) def _write_question(self, question: DNSQuestion) -> bool: """Writes a question to the packet""" From d9ea9189def07531d126e01c7397b2596d9a8695 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 10:36:18 -1000 Subject: [PATCH 0632/1433] Force CI cache clear (#982) --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 378970c4a..3686d6174 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +# version: 1.0 + .PHONY: all virtualenv MAX_LINE_LENGTH=110 PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") From acf6457b3c6742c92e9112b0a39a387b33cea4db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 10:50:46 -1000 Subject: [PATCH 0633/1433] Reduce duplicate code to write records (#979) --- zeroconf/_protocol.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 713d5d918..43af3991c 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -22,7 +22,7 @@ import enum import struct -from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union, cast from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText @@ -586,21 +586,13 @@ def _write_answers_from_offset(self, answer_offset: int) -> int: answers_written += 1 return answers_written - def _write_authorities_from_offset(self, authority_offset: int) -> int: - authorities_written = 0 - for authority in self.authorities[authority_offset:]: - if not self._write_record(authority, 0): + def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int) -> int: + records_written = 0 + for record in records[offset:]: + if not self._write_record(record, 0): break - authorities_written += 1 - return authorities_written - - def _write_additionals_from_offset(self, additional_offset: int) -> int: - additionals_written = 0 - for additional in self.additionals[additional_offset:]: - if not self._write_record(additional, 0): - break - additionals_written += 1 - return additionals_written + records_written += 1 + return records_written def _has_more_to_add( self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int @@ -654,8 +646,8 @@ def packets(self) -> List[bytes]: questions_written = self._write_questions_from_offset(questions_offset) answers_written = self._write_answers_from_offset(answer_offset) - authorities_written = self._write_authorities_from_offset(authority_offset) - additionals_written = self._write_additionals_from_offset(additional_offset) + authorities_written = self._write_records_from_offset(self.authorities, authority_offset) + additionals_written = self._write_records_from_offset(self.additionals, additional_offset) self._insert_short_at_start(additionals_written) self._insert_short_at_start(authorities_written) From bc64d63ef73e643e71634957fd79e6f6597373d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 11:02:58 -1000 Subject: [PATCH 0634/1433] Remove flake8 requirement restriction as its no longer needed (#981) --- requirements-dev.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3035d59d6..e74836667 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,8 +3,7 @@ black;implementation_name=="cpython" bump2version coveralls coverage -# Version restricted because of https://github.com/PyCQA/pycodestyle/issues/741 - is fixed -flake8>=3.6.0 +flake8 flake8-import-order ifaddr mypy;implementation_name=="cpython" From 05c4329d7647c381783ead086c2ed4f3b6b44262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 13:22:13 -1000 Subject: [PATCH 0635/1433] Collapse _GLOBAL_DONE into done (#984) --- zeroconf/_core.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index c9c4c5ec9..f4161877c 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -400,8 +400,7 @@ def __init__( if ip_version is None: ip_version = autodetect_ip_version(interfaces) - # hook for threads - self._GLOBAL_DONE = False + self.done = False if apple_p2p and sys.platform != 'darwin': raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') @@ -456,10 +455,6 @@ async def async_wait_for_start(self) -> None: """Wait for start up.""" await self.engine.async_wait_for_start() - @property - def done(self) -> bool: - return self._GLOBAL_DONE - @property def listeners(self) -> List[RecordUpdateListener]: return self.record_manager.listeners @@ -815,7 +810,7 @@ def async_send( transport: Optional[asyncio.DatagramTransport] = None, ) -> None: """Sends an outgoing packet.""" - if self._GLOBAL_DONE: + if self.done: return # If no transport is specified, we send to all the ones @@ -866,10 +861,10 @@ def _async_send_transport( def _close(self) -> None: """Set global done and remove all service listeners.""" - if self._GLOBAL_DONE: + if self.done: return self.remove_all_service_listeners() - self._GLOBAL_DONE = True + self.done = True def _shutdown_threads(self) -> None: """Shutdown any threads.""" From 88b987551cb98757c2df2540ba390f320d46fa7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 13:23:43 -1000 Subject: [PATCH 0636/1433] Defer decoding known answers until needed (#983) --- zeroconf/_handlers.py | 2 +- zeroconf/_protocol.py | 33 +++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 76ba6cc3a..228484094 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -348,7 +348,7 @@ def async_response( # pylint: disable=unused-argument threadsafe. """ known_answers = DNSRRSet( - itertools.chain(*(msg.answers for msg in msgs if not _message_is_probe(msg))) + itertools.chain.from_iterable(msg.answers for msg in msgs if not _message_is_probe(msg)) ) query_res = _QueryResponse(self.cache, msgs) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index 43af3991c..daff1ca05 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -22,7 +22,7 @@ import enum import struct -from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union, cast from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText @@ -96,23 +96,39 @@ def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[fl self.name_cache: Dict[int, List[str]] = {} self.seen_pointers: Set[int] = set() self.questions: List[DNSQuestion] = [] - self.answers: List[DNSRecord] = [] + self._answers: List[DNSRecord] = [] self.id = 0 self.num_questions = 0 self.num_answers = 0 self.num_authorities = 0 self.num_additionals = 0 self.valid = False + self._read_others = False self.now = now or current_time_millis() self.scope_id = scope_id + self._parse_data(self._initial_parse) - try: - self.read_header() - self.read_questions() + def _initial_parse(self) -> None: + """Parse the data needed to initalize the packet object.""" + self.read_header() + self.read_questions() + if not self.num_questions: self.read_others() - self.valid = True + self.valid = True + + def _parse_data(self, parser_call: Callable) -> None: + """Parse part of the packet and catch exceptions.""" + try: + parser_call() except DECODE_EXCEPTIONS: - self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, data) + self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, self.data) + + @property + def answers(self) -> List[DNSRecord]: + """Answers in the packet.""" + if not self._read_others: + self._parse_data(self.read_others) + return self._answers def __repr__(self) -> str: return '' % ', '.join( @@ -170,6 +186,7 @@ def read_unsigned_short(self) -> int: def read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" + self._read_others = True n = self.num_answers + self.num_authorities + self.num_additionals for _ in range(n): domain = self.read_name() @@ -192,7 +209,7 @@ def read_others(self) -> None: exc_info=True, ) if rec is not None: - self.answers.append(rec) + self._answers.append(rec) def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" From f4d4164989931adbac0e5907b7bf276da1d0d7d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 13:50:07 -1000 Subject: [PATCH 0637/1433] Update changelog for 0.36.4 (#985) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 7c1c2863a..91192344f 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,12 @@ See examples directory for more. Changelog ========= +0.36.4 +====== + +* Improved performance of constructing outgoing packets (#978) (#979) @bdraco +* Defered parsing of incoming packets when it can be avoided (#983) @bdraco + 0.36.3 ====== From a23f6d2cc40ea696410c3c31b73760065c36f0bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 18:50:24 -0500 Subject: [PATCH 0638/1433] =?UTF-8?q?Bump=20version:=200.36.3=20=E2=86=92?= =?UTF-8?q?=200.36.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7727170ec..b467a89c5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.3 +current_version = 0.36.4 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index c037c420d..e8c03c648 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.3' +__version__ = '0.36.4' __license__ = 'LGPL' From 43985380b9e995d9790d71486aed258326ad86e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Sep 2021 13:53:13 -1000 Subject: [PATCH 0639/1433] Fix typo in changelog (#986) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 91192344f..3c6f36c28 100644 --- a/README.rst +++ b/README.rst @@ -142,7 +142,7 @@ Changelog ====== * Improved performance of constructing outgoing packets (#978) (#979) @bdraco -* Defered parsing of incoming packets when it can be avoided (#983) @bdraco +* Deferred parsing of incoming packets when it can be avoided (#983) @bdraco 0.36.3 ====== From f4665fc67cd762c4ab66271a550d75640d3bffca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Sep 2021 16:02:52 -1000 Subject: [PATCH 0640/1433] Reduce dns protocol attributes and add slots (#987) --- zeroconf/_protocol.py | 53 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol.py index daff1ca05..b6ef7bcbb 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol.py @@ -87,6 +87,23 @@ class DNSIncoming(DNSMessage, QuietLogger): """Object representation of an incoming DNS packet""" + __slots__ = ( + 'offset', + 'data', + 'data_len', + 'name_cache', + 'questions', + '_answers', + 'id', + 'num_questions', + 'num_answers', + 'num_authorities', + 'num_additionals', + 'valid', + 'now', + 'scope_id', + ) + def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[float] = None) -> None: """Constructor from string holding bytes of packet""" super().__init__(0) @@ -94,7 +111,6 @@ def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[fl self.data = data self.data_len = len(data) self.name_cache: Dict[int, List[str]] = {} - self.seen_pointers: Set[int] = set() self.questions: List[DNSQuestion] = [] self._answers: List[DNSRecord] = [] self.id = 0 @@ -162,10 +178,9 @@ def read_header(self) -> None: def read_questions(self) -> None: """Reads questions section of packet""" - for _ in range(self.num_questions): - name = self.read_name() - type_, class_ = self.unpack(b'!HH', 4) - self.questions.append(DNSQuestion(name, type_, class_)) + self.questions = [ + DNSQuestion(self.read_name(), *self.unpack(b'!HH', 4)) for _ in range(self.num_questions) + ] def read_character_string(self) -> bytes: """Reads a character string from the packet""" @@ -278,14 +293,14 @@ def read_bitmap(self, end: int) -> List[int]: def read_name(self) -> str: """Reads a domain name from the packet.""" labels: List[str] = [] - self.seen_pointers.clear() - self.offset = self._decode_labels_at_offset(self.offset, labels) + seen_pointers: Set[int] = set() + self.offset = self._decode_labels_at_offset(self.offset, labels, seen_pointers) name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") return name - def _decode_labels_at_offset(self, off: int, labels: List[str]) -> int: + def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. while off < self.data_len: length = self.data[off] @@ -307,12 +322,12 @@ def _decode_labels_at_offset(self, off: int, labels: List[str]) -> int: raise IncomingDecodeError(f"DNS compression pointer at {off} points to {link} beyond packet") if link == off: raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") - if link in self.seen_pointers: + if link in seen_pointers: raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") - self.seen_pointers.add(link) + seen_pointers.add(link) linked_labels = self.name_cache.get(link, []) if not linked_labels: - self._decode_labels_at_offset(link, linked_labels) + self._decode_labels_at_offset(link, linked_labels, seen_pointers) self.name_cache[link] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: @@ -326,6 +341,22 @@ class DNSOutgoing(DNSMessage): """Object representation of an outgoing packet""" + __slots__ = ( + 'finished', + 'id', + 'multicast', + 'packets_data', + 'names', + 'data', + 'size', + 'allow_long', + 'state', + 'questions', + 'answers', + 'authorities', + 'additionals', + ) + def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: super().__init__(flags) self.finished = False From 87b6a32fb77d9bdcea9d2d7ffba189abc5371b50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Sep 2021 16:39:30 -1000 Subject: [PATCH 0641/1433] Seperate zeroconf._protocol into an incoming and outgoing modules (#988) --- setup.py | 2 +- tests/test_core.py | 7 +- zeroconf/__init__.py | 3 +- zeroconf/_core.py | 3 +- zeroconf/_dns.py | 3 +- zeroconf/_handlers.py | 3 +- zeroconf/_protocol/__init__.py | 51 +++ zeroconf/_protocol/incoming.py | 302 +++++++++++++++++ .../{_protocol.py => _protocol/outgoing.py} | 316 +----------------- zeroconf/_services/browser.py | 2 +- zeroconf/_services/info.py | 2 +- 11 files changed, 377 insertions(+), 317 deletions(-) create mode 100644 zeroconf/_protocol/__init__.py create mode 100644 zeroconf/_protocol/incoming.py rename zeroconf/{_protocol.py => _protocol/outgoing.py} (60%) diff --git a/setup.py b/setup.py index 0ad299fb9..41e74842c 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', url='https://github.com/jstasiak/python-zeroconf', package_data={"zeroconf": ["py.typed"]}, - packages=["zeroconf", "zeroconf._services", "zeroconf._utils"], + packages=["zeroconf", "zeroconf._protocol", "zeroconf._services", "zeroconf._utils"], platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, diff --git a/tests/test_core.py b/tests/test_core.py index ba1effaca..a5c220657 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -18,8 +18,9 @@ from unittest.mock import patch import zeroconf as r -from zeroconf import _core, _protocol, const, Zeroconf, current_time_millis +from zeroconf import _core, const, Zeroconf, current_time_millis from zeroconf.asyncio import AsyncZeroconf +from zeroconf._protocol import outgoing from . import has_working_ipv6, _clear_cache, _inject_response, _wait_for_start @@ -670,8 +671,8 @@ def test_guard_against_oversized_packets(): ) # We are patching to generate an oversized packet - with patch.object(_protocol, "_MAX_MSG_ABSOLUTE", 100000), patch.object( - _protocol, "_MAX_MSG_TYPICAL", 100000 + with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( + outgoing, "_MAX_MSG_TYPICAL", 100000 ): over_sized_packet = generated.packets()[0] assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e8c03c648..e6f92d2ae 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -46,7 +46,8 @@ NonUniqueNameException, ServiceNameAlreadyRegistered, ) -from ._protocol import DNSIncoming, DNSOutgoing # noqa # import needed for backwards compat +from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat +from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat from ._services import ( # noqa # import needed for backwards compat Signal, SignalRegistrationInterface, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index f4161877c..15852ffb2 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -41,7 +41,8 @@ ) from ._history import QuestionHistory from ._logger import QuietLogger, log -from ._protocol import DNSIncoming, DNSOutgoing +from ._protocol.incoming import DNSIncoming +from ._protocol.outgoing import DNSOutgoing from ._services import ServiceListener from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index bb447b2f4..7ef6b6a9e 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -48,7 +48,8 @@ if TYPE_CHECKING: - from ._protocol import DNSIncoming, DNSOutgoing + from ._protocol.incoming import DNSIncoming + from ._protocol.outgoing import DNSOutgoing @enum.unique diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 228484094..5215f2026 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -29,7 +29,8 @@ from ._dns import DNSAddress, DNSNsec, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord from ._history import QuestionHistory from ._logger import log -from ._protocol import DNSIncoming, DNSOutgoing +from ._protocol.incoming import DNSIncoming +from ._protocol.outgoing import DNSOutgoing from ._services.info import ServiceInfo from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener diff --git a/zeroconf/_protocol/__init__.py b/zeroconf/_protocol/__init__.py new file mode 100644 index 000000000..360b599db --- /dev/null +++ b/zeroconf/_protocol/__init__.py @@ -0,0 +1,51 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from ..const import ( + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _FLAGS_TC, +) + + +class DNSMessage: + """A base class for DNS messages.""" + + __slots__ = ('flags',) + + def __init__(self, flags: int) -> None: + """Construct a DNS message.""" + self.flags = flags + + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + @property + def truncated(self) -> bool: + """Returns true if this is a truncated.""" + return (self.flags & _FLAGS_TC) == _FLAGS_TC diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py new file mode 100644 index 000000000..bffbb4bc7 --- /dev/null +++ b/zeroconf/_protocol/incoming.py @@ -0,0 +1,302 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import struct +from typing import Callable, Dict, List, Optional, Set, cast + +from . import DNSMessage +from .._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from .._exceptions import IncomingDecodeError +from .._logger import QuietLogger, log +from .._utils.time import current_time_millis +from ..const import ( + _TYPES, + _TYPE_A, + _TYPE_AAAA, + _TYPE_CNAME, + _TYPE_HINFO, + _TYPE_NSEC, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) + +DNS_COMPRESSION_HEADER_LEN = 1 +DNS_COMPRESSION_POINTER_LEN = 2 +MAX_DNS_LABELS = 128 +MAX_NAME_LENGTH = 253 + +DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) + + +class DNSIncoming(DNSMessage, QuietLogger): + + """Object representation of an incoming DNS packet""" + + __slots__ = ( + 'offset', + 'data', + 'data_len', + 'name_cache', + 'questions', + '_answers', + 'id', + 'num_questions', + 'num_answers', + 'num_authorities', + 'num_additionals', + 'valid', + 'now', + 'scope_id', + ) + + def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[float] = None) -> None: + """Constructor from string holding bytes of packet""" + super().__init__(0) + self.offset = 0 + self.data = data + self.data_len = len(data) + self.name_cache: Dict[int, List[str]] = {} + self.questions: List[DNSQuestion] = [] + self._answers: List[DNSRecord] = [] + self.id = 0 + self.num_questions = 0 + self.num_answers = 0 + self.num_authorities = 0 + self.num_additionals = 0 + self.valid = False + self._read_others = False + self.now = now or current_time_millis() + self.scope_id = scope_id + self._parse_data(self._initial_parse) + + def _initial_parse(self) -> None: + """Parse the data needed to initalize the packet object.""" + self.read_header() + self.read_questions() + if not self.num_questions: + self.read_others() + self.valid = True + + def _parse_data(self, parser_call: Callable) -> None: + """Parse part of the packet and catch exceptions.""" + try: + parser_call() + except DECODE_EXCEPTIONS: + self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, self.data) + + @property + def answers(self) -> List[DNSRecord]: + """Answers in the packet.""" + if not self._read_others: + self._parse_data(self.read_others) + return self._answers + + def __repr__(self) -> str: + return '' % ', '.join( + [ + 'id=%s' % self.id, + 'flags=%s' % self.flags, + 'truncated=%s' % self.truncated, + 'n_q=%s' % self.num_questions, + 'n_ans=%s' % self.num_answers, + 'n_auth=%s' % self.num_authorities, + 'n_add=%s' % self.num_additionals, + 'questions=%s' % self.questions, + 'answers=%s' % self.answers, + ] + ) + + def unpack(self, format_: bytes, length: int) -> tuple: + self.offset += length + return struct.unpack(format_, self.data[self.offset - length : self.offset]) + + def read_header(self) -> None: + """Reads header portion of packet""" + ( + self.id, + self.flags, + self.num_questions, + self.num_answers, + self.num_authorities, + self.num_additionals, + ) = self.unpack(b'!6H', 12) + + def read_questions(self) -> None: + """Reads questions section of packet""" + self.questions = [ + DNSQuestion(self.read_name(), *self.unpack(b'!HH', 4)) for _ in range(self.num_questions) + ] + + def read_character_string(self) -> bytes: + """Reads a character string from the packet""" + length = self.data[self.offset] + self.offset += 1 + return self.read_string(length) + + def read_string(self, length: int) -> bytes: + """Reads a string of a given length from the packet""" + info = self.data[self.offset : self.offset + length] + self.offset += length + return info + + def read_unsigned_short(self) -> int: + """Reads an unsigned short from the packet""" + return cast(int, self.unpack(b'!H', 2)[0]) + + def read_others(self) -> None: + """Reads the answers, authorities and additionals section of the + packet""" + self._read_others = True + n = self.num_answers + self.num_authorities + self.num_additionals + for _ in range(n): + domain = self.read_name() + type_, class_, ttl, length = self.unpack(b'!HHiH', 10) + end = self.offset + length + rec = None + try: + rec = self.read_record(domain, type_, class_, ttl, length) + except DECODE_EXCEPTIONS: + # Skip records that fail to decode if we know the length + # If the packet is really corrupt read_name and the unpack + # above would fail and hit the exception catch in read_others + self.offset = end + log.debug( + 'Unable to parse; skipping record for %s with type %s at offset %d while unpacking %r', + domain, + _TYPES.get(type_, type_), + self.offset, + self.data, + exc_info=True, + ) + if rec is not None: + self._answers.append(rec) + + def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: + """Read known records types and skip unknown ones.""" + if type_ == _TYPE_A: + return DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) + if type_ in (_TYPE_CNAME, _TYPE_PTR): + return DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) + if type_ == _TYPE_TXT: + return DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) + if type_ == _TYPE_SRV: + return DNSService( + domain, + type_, + class_, + ttl, + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_unsigned_short(), + self.read_name(), + self.now, + ) + if type_ == _TYPE_HINFO: + return DNSHinfo( + domain, + type_, + class_, + ttl, + self.read_character_string().decode('utf-8'), + self.read_character_string().decode('utf-8'), + self.now, + ) + if type_ == _TYPE_AAAA: + return DNSAddress( + domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id + ) + if type_ == _TYPE_NSEC: + name_start = self.offset + return DNSNsec( + domain, + type_, + class_, + ttl, + self.read_name(), + self.read_bitmap(name_start + length), + self.now, + ) + # Try to ignore types we don't know about + # Skip the payload for the resource record so the next + # records can be parsed correctly + self.offset += length + return None + + def read_bitmap(self, end: int) -> List[int]: + """Reads an NSEC bitmap from the packet.""" + rdtypes = [] + while self.offset < end: + window = self.data[self.offset] + bitmap_length = self.data[self.offset + 1] + for i, byte in enumerate(self.data[self.offset + 2 : self.offset + 2 + bitmap_length]): + for bit in range(0, 8): + if byte & (0x80 >> bit): + rdtypes.append(bit + window * 256 + i * 8) + self.offset += 2 + bitmap_length + return rdtypes + + def read_name(self) -> str: + """Reads a domain name from the packet.""" + labels: List[str] = [] + seen_pointers: Set[int] = set() + self.offset = self._decode_labels_at_offset(self.offset, labels, seen_pointers) + name = ".".join(labels) + "." + if len(name) > MAX_NAME_LENGTH: + raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") + return name + + def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: + # This is a tight loop that is called frequently, small optimizations can make a difference. + while off < self.data_len: + length = self.data[off] + if length == 0: + return off + DNS_COMPRESSION_HEADER_LEN + + if length < 0x40: + label_idx = off + DNS_COMPRESSION_HEADER_LEN + labels.append(str(self.data[label_idx : label_idx + length], 'utf-8', 'replace')) + off += DNS_COMPRESSION_HEADER_LEN + length + continue + + if length < 0xC0: + raise IncomingDecodeError(f"DNS compression type {length} is unknown at {off}") + + # We have a DNS compression pointer + link = (length & 0x3F) * 256 + self.data[off + 1] + if link > self.data_len: + raise IncomingDecodeError(f"DNS compression pointer at {off} points to {link} beyond packet") + if link == off: + raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") + if link in seen_pointers: + raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") + seen_pointers.add(link) + linked_labels = self.name_cache.get(link, []) + if not linked_labels: + self._decode_labels_at_offset(link, linked_labels, seen_pointers) + self.name_cache[link] = linked_labels + labels.extend(linked_labels) + if len(labels) > MAX_DNS_LABELS: + raise IncomingDecodeError(f"Maximum dns labels reached while processing pointer at {off}") + return off + DNS_COMPRESSION_POINTER_LEN + + raise IncomingDecodeError("Corrupt packet received while decoding name") diff --git a/zeroconf/_protocol.py b/zeroconf/_protocol/outgoing.py similarity index 60% rename from zeroconf/_protocol.py rename to zeroconf/_protocol/outgoing.py index b6ef7bcbb..21ff4b640 100644 --- a/zeroconf/_protocol.py +++ b/zeroconf/_protocol/outgoing.py @@ -22,320 +22,22 @@ import enum import struct -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union, cast - - -from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText -from ._exceptions import IncomingDecodeError, NamePartTooLongException -from ._logger import QuietLogger, log -from ._utils.time import current_time_millis -from .const import ( +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +from . import DNSMessage +from .incoming import DNSIncoming +from .._cache import DNSCache +from .._dns import DNSPointer, DNSQuestion, DNSRecord +from .._exceptions import NamePartTooLongException +from .._logger import log +from ..const import ( _CLASS_UNIQUE, _DNS_PACKET_HEADER_LEN, - _FLAGS_QR_MASK, - _FLAGS_QR_QUERY, - _FLAGS_QR_RESPONSE, _FLAGS_TC, _MAX_MSG_ABSOLUTE, _MAX_MSG_TYPICAL, - _TYPES, - _TYPE_A, - _TYPE_AAAA, - _TYPE_CNAME, - _TYPE_HINFO, - _TYPE_NSEC, - _TYPE_PTR, - _TYPE_SRV, - _TYPE_TXT, ) -DNS_COMPRESSION_HEADER_LEN = 1 -DNS_COMPRESSION_POINTER_LEN = 2 -MAX_DNS_LABELS = 128 -MAX_NAME_LENGTH = 253 - -DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) - -if TYPE_CHECKING: - from ._cache import DNSCache - - -class DNSMessage: - """A base class for DNS messages.""" - - __slots__ = ('flags',) - - def __init__(self, flags: int) -> None: - """Construct a DNS message.""" - self.flags = flags - - def is_query(self) -> bool: - """Returns true if this is a query.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def is_response(self) -> bool: - """Returns true if this is a response.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - - @property - def truncated(self) -> bool: - """Returns true if this is a truncated.""" - return (self.flags & _FLAGS_TC) == _FLAGS_TC - - -class DNSIncoming(DNSMessage, QuietLogger): - - """Object representation of an incoming DNS packet""" - - __slots__ = ( - 'offset', - 'data', - 'data_len', - 'name_cache', - 'questions', - '_answers', - 'id', - 'num_questions', - 'num_answers', - 'num_authorities', - 'num_additionals', - 'valid', - 'now', - 'scope_id', - ) - - def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[float] = None) -> None: - """Constructor from string holding bytes of packet""" - super().__init__(0) - self.offset = 0 - self.data = data - self.data_len = len(data) - self.name_cache: Dict[int, List[str]] = {} - self.questions: List[DNSQuestion] = [] - self._answers: List[DNSRecord] = [] - self.id = 0 - self.num_questions = 0 - self.num_answers = 0 - self.num_authorities = 0 - self.num_additionals = 0 - self.valid = False - self._read_others = False - self.now = now or current_time_millis() - self.scope_id = scope_id - self._parse_data(self._initial_parse) - - def _initial_parse(self) -> None: - """Parse the data needed to initalize the packet object.""" - self.read_header() - self.read_questions() - if not self.num_questions: - self.read_others() - self.valid = True - - def _parse_data(self, parser_call: Callable) -> None: - """Parse part of the packet and catch exceptions.""" - try: - parser_call() - except DECODE_EXCEPTIONS: - self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, self.data) - - @property - def answers(self) -> List[DNSRecord]: - """Answers in the packet.""" - if not self._read_others: - self._parse_data(self.read_others) - return self._answers - - def __repr__(self) -> str: - return '' % ', '.join( - [ - 'id=%s' % self.id, - 'flags=%s' % self.flags, - 'truncated=%s' % self.truncated, - 'n_q=%s' % self.num_questions, - 'n_ans=%s' % self.num_answers, - 'n_auth=%s' % self.num_authorities, - 'n_add=%s' % self.num_additionals, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - ] - ) - - def unpack(self, format_: bytes, length: int) -> tuple: - self.offset += length - return struct.unpack(format_, self.data[self.offset - length : self.offset]) - - def read_header(self) -> None: - """Reads header portion of packet""" - ( - self.id, - self.flags, - self.num_questions, - self.num_answers, - self.num_authorities, - self.num_additionals, - ) = self.unpack(b'!6H', 12) - - def read_questions(self) -> None: - """Reads questions section of packet""" - self.questions = [ - DNSQuestion(self.read_name(), *self.unpack(b'!HH', 4)) for _ in range(self.num_questions) - ] - - def read_character_string(self) -> bytes: - """Reads a character string from the packet""" - length = self.data[self.offset] - self.offset += 1 - return self.read_string(length) - - def read_string(self, length: int) -> bytes: - """Reads a string of a given length from the packet""" - info = self.data[self.offset : self.offset + length] - self.offset += length - return info - - def read_unsigned_short(self) -> int: - """Reads an unsigned short from the packet""" - return cast(int, self.unpack(b'!H', 2)[0]) - - def read_others(self) -> None: - """Reads the answers, authorities and additionals section of the - packet""" - self._read_others = True - n = self.num_answers + self.num_authorities + self.num_additionals - for _ in range(n): - domain = self.read_name() - type_, class_, ttl, length = self.unpack(b'!HHiH', 10) - end = self.offset + length - rec = None - try: - rec = self.read_record(domain, type_, class_, ttl, length) - except DECODE_EXCEPTIONS: - # Skip records that fail to decode if we know the length - # If the packet is really corrupt read_name and the unpack - # above would fail and hit the exception catch in read_others - self.offset = end - log.debug( - 'Unable to parse; skipping record for %s with type %s at offset %d while unpacking %r', - domain, - _TYPES.get(type_, type_), - self.offset, - self.data, - exc_info=True, - ) - if rec is not None: - self._answers.append(rec) - - def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: - """Read known records types and skip unknown ones.""" - if type_ == _TYPE_A: - return DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) - if type_ in (_TYPE_CNAME, _TYPE_PTR): - return DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) - if type_ == _TYPE_TXT: - return DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) - if type_ == _TYPE_SRV: - return DNSService( - domain, - type_, - class_, - ttl, - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_name(), - self.now, - ) - if type_ == _TYPE_HINFO: - return DNSHinfo( - domain, - type_, - class_, - ttl, - self.read_character_string().decode('utf-8'), - self.read_character_string().decode('utf-8'), - self.now, - ) - if type_ == _TYPE_AAAA: - return DNSAddress( - domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id - ) - if type_ == _TYPE_NSEC: - name_start = self.offset - return DNSNsec( - domain, - type_, - class_, - ttl, - self.read_name(), - self.read_bitmap(name_start + length), - self.now, - ) - # Try to ignore types we don't know about - # Skip the payload for the resource record so the next - # records can be parsed correctly - self.offset += length - return None - - def read_bitmap(self, end: int) -> List[int]: - """Reads an NSEC bitmap from the packet.""" - rdtypes = [] - while self.offset < end: - window = self.data[self.offset] - bitmap_length = self.data[self.offset + 1] - for i, byte in enumerate(self.data[self.offset + 2 : self.offset + 2 + bitmap_length]): - for bit in range(0, 8): - if byte & (0x80 >> bit): - rdtypes.append(bit + window * 256 + i * 8) - self.offset += 2 + bitmap_length - return rdtypes - - def read_name(self) -> str: - """Reads a domain name from the packet.""" - labels: List[str] = [] - seen_pointers: Set[int] = set() - self.offset = self._decode_labels_at_offset(self.offset, labels, seen_pointers) - name = ".".join(labels) + "." - if len(name) > MAX_NAME_LENGTH: - raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") - return name - - def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: - # This is a tight loop that is called frequently, small optimizations can make a difference. - while off < self.data_len: - length = self.data[off] - if length == 0: - return off + DNS_COMPRESSION_HEADER_LEN - - if length < 0x40: - label_idx = off + DNS_COMPRESSION_HEADER_LEN - labels.append(str(self.data[label_idx : label_idx + length], 'utf-8', 'replace')) - off += DNS_COMPRESSION_HEADER_LEN + length - continue - - if length < 0xC0: - raise IncomingDecodeError(f"DNS compression type {length} is unknown at {off}") - - # We have a DNS compression pointer - link = (length & 0x3F) * 256 + self.data[off + 1] - if link > self.data_len: - raise IncomingDecodeError(f"DNS compression pointer at {off} points to {link} beyond packet") - if link == off: - raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") - if link in seen_pointers: - raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") - seen_pointers.add(link) - linked_labels = self.name_cache.get(link, []) - if not linked_labels: - self._decode_labels_at_offset(link, linked_labels, seen_pointers) - self.name_cache[link] = linked_labels - labels.extend(linked_labels) - if len(labels) > MAX_DNS_LABELS: - raise IncomingDecodeError(f"Maximum dns labels reached while processing pointer at {off}") - return off + DNS_COMPRESSION_POINTER_LEN - - raise IncomingDecodeError("Corrupt packet received while decoding name") - class DNSOutgoing(DNSMessage): diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index d47e42e92..f6448fd27 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -30,7 +30,7 @@ from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log -from .._protocol import DNSOutgoing +from .._protocol.outgoing import DNSOutgoing from .._services import ( ServiceListener, ServiceStateChange, diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 7aaea1b6a..beaf0678d 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -27,7 +27,7 @@ from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException -from .._protocol import DNSOutgoing +from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener from .._utils.asyncio import get_running_loop, run_coro_with_timeout from .._utils.name import service_type_name From aebabe95c59e34f703307340e087b3eab5339a06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Sep 2021 17:04:45 -1000 Subject: [PATCH 0642/1433] Update changelog for 0.36.5 (#989) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 3c6f36c28..6b4ee99c3 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,11 @@ See examples directory for more. Changelog ========= +0.36.5 +====== + +* Reduced memory usage for incoming and outgoing packets (#987) @bdraco + 0.36.4 ====== From 34f4a26c9254d6002bdccb1a003d9822a8798c04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Sep 2021 22:05:46 -0500 Subject: [PATCH 0643/1433] =?UTF-8?q?Bump=20version:=200.36.4=20=E2=86=92?= =?UTF-8?q?=200.36.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index b467a89c5..36703f958 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.4 +current_version = 0.36.5 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e6f92d2ae..055d0439e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -80,7 +80,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.4' +__version__ = '0.36.5' __license__ = 'LGPL' From 1887c554b3f9d0b90a1c01798d7f06a7e4de6900 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 12:49:47 -1000 Subject: [PATCH 0644/1433] Simplify the can_send_to check (#990) --- zeroconf/__init__.py | 1 - zeroconf/_core.py | 9 +++++---- zeroconf/_utils/net.py | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 055d0439e..5c04eb26e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -67,7 +67,6 @@ from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, - can_send_to, autodetect_ip_version, create_sockets, get_all_addresses_v6, diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 15852ffb2..74b1828f0 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -836,12 +836,13 @@ def _async_send_transport( v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: s = transport.get_extra_info('socket') + ipv6_socket = s.family == socket.AF_INET6 if addr is None: - real_addr = _MDNS_ADDR6 if s.family == socket.AF_INET6 else _MDNS_ADDR + real_addr = _MDNS_ADDR6 if ipv6_socket else _MDNS_ADDR else: real_addr = addr - if not can_send_to(s, real_addr): - return + if not can_send_to(ipv6_socket, real_addr): + return log.debug( 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', real_addr, @@ -855,7 +856,7 @@ def _async_send_transport( ) # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families - if s.family == socket.AF_INET6 and not v6_flow_scope: + if ipv6_socket and not v6_flow_scope: _, _, sock_flowinfo, sock_scopeid = s.getsockname() v6_flow_scope = (sock_flowinfo, sock_scopeid) transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index bfae9db46..c53ec9786 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -379,9 +379,13 @@ def get_errno(e: Exception) -> int: return cast(int, e.args[0]) -def can_send_to(sock: socket.socket, address: str) -> bool: - addr = ipaddress.ip_address(address) - return cast(bool, addr.version == 6 if sock.family == socket.AF_INET6 else addr.version == 4) +def can_send_to(ipv6_socket: bool, address: str) -> bool: + """Check if the address type matches the socket type. + + This function does not validate if the address is a valid + ipv6 or ipv4 address. + """ + return ":" in address if ipv6_socket else ":" not in address def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: From 92f5f4a80b8a8e50df5ca06e3cc45480dc39b504 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 12:51:38 -1000 Subject: [PATCH 0645/1433] Update changelog for 0.36.6 (#991) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 6b4ee99c3..f936c3fe0 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,11 @@ See examples directory for more. Changelog ========= +0.36.6 +====== + +* Improve performance of sending outgoing packets (#990) @bdraco + 0.36.5 ====== From 29f995fd3c09604f37980e74f2785b1a451da089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 12:52:45 -1000 Subject: [PATCH 0646/1433] Fix tense of 0.36.6 changelog (#992) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f936c3fe0..01a81c206 100644 --- a/README.rst +++ b/README.rst @@ -141,7 +141,7 @@ Changelog 0.36.6 ====== -* Improve performance of sending outgoing packets (#990) @bdraco +* Improved performance of sending outgoing packets (#990) @bdraco 0.36.5 ====== From 0327a068250c85f3ff84d3f0b809b51f83321c47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Sep 2021 17:52:59 -0500 Subject: [PATCH 0647/1433] =?UTF-8?q?Bump=20version:=200.36.5=20=E2=86=92?= =?UTF-8?q?=200.36.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 36703f958..e8151de26 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.5 +current_version = 0.36.6 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5c04eb26e..9a8ff7c7e 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.5' +__version__ = '0.36.6' __license__ = 'LGPL' From 93ddf7cf9b47d7ff1e341b6c2875254b6f00eef1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 18:51:23 -0500 Subject: [PATCH 0648/1433] Flush CI cache (#995) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3686d6174..88980ff21 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# version: 1.0 +# version: 1.1 .PHONY: all virtualenv MAX_LINE_LENGTH=110 From 762236547d4838f2b6a94cfa20221dfdd03e9b94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 22:27:48 -0500 Subject: [PATCH 0649/1433] Refactor service registry to avoid use of getattr (#996) --- zeroconf/_services/registry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zeroconf/_services/registry.py b/zeroconf/_services/registry.py index 4e64c8d7b..203b3b396 100644 --- a/zeroconf/_services/registry.py +++ b/zeroconf/_services/registry.py @@ -69,15 +69,15 @@ def async_get_types(self) -> List[str]: def async_get_infos_type(self, type_: str) -> List[ServiceInfo]: """Return all ServiceInfo matching type.""" - return self._async_get_by_index("types", type_) + return self._async_get_by_index(self.types, type_) def async_get_infos_server(self, server: str) -> List[ServiceInfo]: """Return all ServiceInfo matching server.""" - return self._async_get_by_index("servers", server) + return self._async_get_by_index(self.servers, server) - def _async_get_by_index(self, attr: str, key: str) -> List[ServiceInfo]: + def _async_get_by_index(self, records: Dict[str, List], key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" - return [self._services[name] for name in getattr(self, attr).get(key.lower(), [])] + return [self._services[name] for name in records.get(key.lower(), [])] def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" From 7fa51de5b71d03470643a83004b9f6f8d4017214 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 22:35:29 -0500 Subject: [PATCH 0650/1433] Reduce overhead to compare dns records (#997) --- zeroconf/_dns.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 7ef6b6a9e..35594b37e 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -127,7 +127,7 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Tests equality on dns question.""" - return isinstance(other, DNSQuestion) and DNSEntry.__eq__(self, other) + return isinstance(other, DNSQuestion) and dns_entry_matches(other, self.key, self.type, self.class_) @property def max_size(self) -> int: @@ -260,7 +260,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSAddress) and self.address == other.address and self.scope_id == other.scope_id - and DNSEntry.__eq__(self, other) + and dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -304,7 +304,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSHinfo) and self.cpu == other.cpu and self.os == other.os - and DNSEntry.__eq__(self, other) + and dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -345,7 +345,11 @@ def write(self, out: 'DNSOutgoing') -> None: def __eq__(self, other: Any) -> bool: """Tests equality on alias""" - return isinstance(other, DNSPointer) and self.alias == other.alias and DNSEntry.__eq__(self, other) + return ( + isinstance(other, DNSPointer) + and self.alias == other.alias + and dns_entry_matches(other, self.key, self.type, self.class_) + ) def __hash__(self) -> int: """Hash to compare like DNSPointer.""" @@ -380,7 +384,11 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Tests equality on text""" - return isinstance(other, DNSText) and self.text == other.text and DNSEntry.__eq__(self, other) + return ( + isinstance(other, DNSText) + and self.text == other.text + and dns_entry_matches(other, self.key, self.type, self.class_) + ) def __repr__(self) -> str: """String representation""" @@ -429,7 +437,7 @@ def __eq__(self, other: Any) -> bool: and self.weight == other.weight and self.port == other.port and self.server == other.server - and DNSEntry.__eq__(self, other) + and dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -484,7 +492,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSNsec) and self.next_name == other.next_name and self.rdtypes == other.rdtypes - and DNSEntry.__eq__(self, other) + and dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: From 7df7e4a68e33c3e3a5bddf0168e248a4542a788f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 22:45:48 -0500 Subject: [PATCH 0651/1433] Reduce logging overhead (#994) --- tests/test_core.py | 34 +++++++++++++++++++++++++++ zeroconf/_core.py | 57 +++++++++++++++++++++++++--------------------- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index a5c220657..eab769be8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -398,6 +398,40 @@ def test_register_service_with_custom_ttl(): zc.close() +def test_logging_packets(caplog): + """Test packets are only logged with debug logging.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # start a browser + type_ = "_logging._tcp.local." + name = "TLD" + info_service = r.ServiceInfo( + type_, + f'{name}.{type_}', + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-90.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + caplog.clear() + zc.register_service(info_service, ttl=3000) + assert "Sending to" in caplog.text + assert zc.cache.get(info_service.dns_pointer()).ttl == 3000 + logging.getLogger('zeroconf').setLevel(logging.INFO) + caplog.clear() + zc.unregister_service(info_service) + assert "Sending to" not in caplog.text + logging.getLogger('zeroconf').setLevel(logging.DEBUG) + + zc.close() + + def test_get_service_info_failure_path(): """Verify get_service_info return None when the underlying call returns False.""" zc = Zeroconf(interfaces=['127.0.0.1']) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 74b1828f0..0d59b3d7d 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -22,6 +22,7 @@ import asyncio import itertools +import logging import random import socket import sys @@ -217,6 +218,7 @@ def __init__(self, zc: 'Zeroconf') -> None: self.last_time: float = 0 self.transport: Optional[asyncio.DatagramTransport] = None self.sock_name: Optional[str] = None + self.sock_description: Optional[str] = None self.sock_fileno: Optional[int] = None self._deferred: Dict[str, List[DNSIncoming]] = {} self._timers: Dict[str, asyncio.TimerHandle] = {} @@ -236,6 +238,8 @@ def datagram_received( ) -> None: assert self.transport is not None v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + data_len = len(data) + if len(addrs) == 2: # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore @@ -253,19 +257,19 @@ def datagram_received( 'Ignoring duplicate message received from %r:%r [socket %s] (%d bytes) as [%r]', addr, port, - self._socket_description, - len(data), + self.sock_description, + data_len, data, ) return - if len(data) > _MAX_MSG_ABSOLUTE: + if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm # the system. log.debug( "Discarding incoming packet with length %s, which is larger " "than the absolute maximum size of %s", - len(data), + data_len, _MAX_MSG_ABSOLUTE, ) return @@ -276,9 +280,9 @@ def datagram_received( 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', addr, port, - self._socket_description, + self.sock_description, msg, - len(data), + data_len, data, ) else: @@ -286,8 +290,8 @@ def datagram_received( 'Received from %r:%r [socket %s]: (%d bytes) [%r]', addr, port, - self._socket_description, - len(data), + self.sock_description, + data_len, data, ) return @@ -346,24 +350,20 @@ def _respond_query( self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) - @property - def _socket_description(self) -> str: - """A human readable description of the socket.""" - return f"{self.sock_fileno} ({self.sock_name})" - def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" # We preformat the message string with the socket as we want # log_exception_once to log a warrning message once PER EACH # different socket in case there are problems with multiple # sockets - msg_str = f"Error with socket {self._socket_description}): %s" + msg_str = f"Error with socket {self.sock_description}): %s" self.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) self.sock_name = self.transport.get_extra_info('sockname') self.sock_fileno = self.transport.get_extra_info('socket').fileno() + self.sock_description = f"{self.sock_fileno} ({self.sock_name})" def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" @@ -817,16 +817,20 @@ def async_send( # If no transport is specified, we send to all the ones # with the same address family transports = [transport] if transport else self.engine.senders + log_debug = log.isEnabledFor(logging.DEBUG) for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return for send_transport in transports: - self._async_send_transport(send_transport, packet, packet_num, out, addr, port, v6_flow_scope) + self._async_send_transport( + log_debug, send_transport, packet, packet_num, out, addr, port, v6_flow_scope + ) def _async_send_transport( self, + log_debug: bool, transport: asyncio.DatagramTransport, packet: bytes, packet_num: int, @@ -843,17 +847,18 @@ def _async_send_transport( real_addr = addr if not can_send_to(ipv6_socket, real_addr): return - log.debug( - 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', - real_addr, - port or _MDNS_PORT, - s.fileno(), - transport.get_extra_info('sockname'), - len(packet), - packet_num + 1, - out, - packet, - ) + if log_debug: + log.debug( + 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', + real_addr, + port or _MDNS_PORT, + s.fileno(), + transport.get_extra_info('sockname'), + len(packet), + packet_num + 1, + out, + packet, + ) # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families if ipv6_socket and not v6_flow_scope: From b637846e7df3292d6dcdd38a8eb77b6fa3287c51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 23:17:50 -0500 Subject: [PATCH 0652/1433] Improve log message when receiving an invalid or corrupt packet (#998) --- tests/test_protocol.py | 11 +++++++---- zeroconf/_core.py | 2 +- zeroconf/_protocol/incoming.py | 19 ++++++++++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6ad3303bb..55dbbe4d7 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -822,16 +822,18 @@ def test_dns_compression_invalid_skips_bad_name_compress_in_question(): assert len(parsed.questions) == 4 -def test_dns_compression_all_invalid(): +def test_dns_compression_all_invalid(caplog): """Test our wire parser can skip all invalid data.""" packet = ( b'\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00!roborock-vacuum-s5e_miio416' b'112328\x00\x00/\x80\x01\x00\x00\x00x\x00\t\xc0P\x00\x05@\x00\x00\x00\x00' ) - parsed = r.DNSIncoming(packet) + parsed = r.DNSIncoming(packet, ("2.4.5.4", 5353)) assert len(parsed.questions) == 0 assert len(parsed.answers) == 0 + assert " Unable to parse; skipping record" in caplog.text + def test_invalid_next_name_ignored(): """Test our wire parser does not throw an an invalid next name. @@ -918,15 +920,16 @@ def test_dns_compression_points_beyond_packet(): assert len(parsed.answers) == 1 -def test_dns_compression_generic_failure(): +def test_dns_compression_generic_failure(caplog): """Test our wire parser does not loop forever when dns compression is corrupt.""" packet = ( b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01' b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00' b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' ) - parsed = r.DNSIncoming(packet) + parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353)) assert len(parsed.answers) == 1 + assert "Received invalid packet from ('1.2.3.4', 5353)" in caplog.text def test_label_length_attack(): diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 0d59b3d7d..e609e4ad4 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -274,7 +274,7 @@ def datagram_received( ) return - msg = DNSIncoming(data, scope, now) + msg = DNSIncoming(data, (addr, port), scope, now) if msg.valid: log.debug( 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index bffbb4bc7..6d7a61531 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -21,7 +21,7 @@ """ import struct -from typing import Callable, Dict, List, Optional, Set, cast +from typing import Callable, Dict, List, Optional, Set, Tuple, cast from . import DNSMessage from .._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText @@ -67,9 +67,16 @@ class DNSIncoming(DNSMessage, QuietLogger): 'valid', 'now', 'scope_id', + 'source', ) - def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[float] = None) -> None: + def __init__( + self, + data: bytes, + source: Optional[Tuple[str, int]] = None, + scope_id: Optional[int] = None, + now: Optional[float] = None, + ) -> None: """Constructor from string holding bytes of packet""" super().__init__(0) self.offset = 0 @@ -86,6 +93,7 @@ def __init__(self, data: bytes, scope_id: Optional[int] = None, now: Optional[fl self.valid = False self._read_others = False self.now = now or current_time_millis() + self.source = source self.scope_id = scope_id self._parse_data(self._initial_parse) @@ -102,7 +110,12 @@ def _parse_data(self, parser_call: Callable) -> None: try: parser_call() except DECODE_EXCEPTIONS: - self.log_exception_warning('Choked at offset %d while unpacking %r', self.offset, self.data) + self.log_exception_warning( + 'Received invalid packet from %s at offset %d while unpacking %r', + self.source, + self.offset, + self.data, + ) @property def answers(self) -> List[DNSRecord]: From d2853c31db9ece28fb258c4146ba61cf0e6a6592 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 23:21:42 -0500 Subject: [PATCH 0653/1433] Update changelog for 0.36.7 (#999) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 01a81c206..ea7c3d1c8 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,12 @@ See examples directory for more. Changelog ========= +0.36.7 +====== + +* Improved performance of responding to queries (#994) (#996) (#997) @bdraco +* Improved log message when receiving an invalid or corrupt packet (#998) @bdraco + 0.36.6 ====== From f44b40e26ea8872151ea9ee4762b95ca25790089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Sep 2021 23:22:00 -0500 Subject: [PATCH 0654/1433] =?UTF-8?q?Bump=20version:=200.36.6=20=E2=86=92?= =?UTF-8?q?=200.36.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index e8151de26..67e50408c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.6 +current_version = 0.36.7 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9a8ff7c7e..4821cbb8c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.6' +__version__ = '0.36.7' __license__ = 'LGPL' From 8e45ea943be6490b2217f0eb01501e12a5221c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 08:43:42 -0500 Subject: [PATCH 0655/1433] Remove unused code in zeroconf._core (#1001) - Breakout functions without self-use --- zeroconf/_core.py | 89 +++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index e609e4ad4..1575eba29 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -212,17 +212,16 @@ class AsyncListener(asyncio.Protocol, QuietLogger): It requires registration with an Engine object in order to have the read() method called when a socket is available for reading.""" + __slots__ = ('zc', 'data', 'last_time', 'transport', 'sock_description', '_deferred', '_timers') + def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data: Optional[bytes] = None self.last_time: float = 0 self.transport: Optional[asyncio.DatagramTransport] = None - self.sock_name: Optional[str] = None self.sock_description: Optional[str] = None - self.sock_fileno: Optional[int] = None self._deferred: Dict[str, List[DNSIncoming]] = {} self._timers: Dict[str, asyncio.TimerHandle] = {} - super().__init__() def suppress_duplicate_packet(self, data: bytes, now: float) -> bool: @@ -361,14 +360,52 @@ def error_received(self, exc: Exception) -> None: def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) - self.sock_name = self.transport.get_extra_info('sockname') - self.sock_fileno = self.transport.get_extra_info('socket').fileno() - self.sock_description = f"{self.sock_fileno} ({self.sock_name})" + sock_name = self.transport.get_extra_info('sockname') + sock_fileno = self.transport.get_extra_info('socket').fileno() + self.sock_description = f"{sock_fileno} ({sock_name})" def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" +def async_send_with_transport( + log_debug: bool, + transport: asyncio.DatagramTransport, + packet: bytes, + packet_num: int, + out: DNSOutgoing, + addr: Optional[str], + port: int, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), +) -> None: + s = transport.get_extra_info('socket') + ipv6_socket = s.family == socket.AF_INET6 + if addr is None: + real_addr = _MDNS_ADDR6 if ipv6_socket else _MDNS_ADDR + else: + real_addr = addr + if not can_send_to(ipv6_socket, real_addr): + return + if log_debug: + log.debug( + 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', + real_addr, + port or _MDNS_PORT, + s.fileno(), + transport.get_extra_info('sockname'), + len(packet), + packet_num + 1, + out, + packet, + ) + # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 + # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families + if ipv6_socket and not v6_flow_scope: + _, _, sock_flowinfo, sock_scopeid = s.getsockname() + v6_flow_scope = (sock_flowinfo, sock_scopeid) + transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) + + class Zeroconf(QuietLogger): """Implementation of Zeroconf Multicast DNS Service Discovery @@ -824,48 +861,10 @@ def async_send( self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) return for send_transport in transports: - self._async_send_transport( + async_send_with_transport( log_debug, send_transport, packet, packet_num, out, addr, port, v6_flow_scope ) - def _async_send_transport( - self, - log_debug: bool, - transport: asyncio.DatagramTransport, - packet: bytes, - packet_num: int, - out: DNSOutgoing, - addr: Optional[str], - port: int, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - s = transport.get_extra_info('socket') - ipv6_socket = s.family == socket.AF_INET6 - if addr is None: - real_addr = _MDNS_ADDR6 if ipv6_socket else _MDNS_ADDR - else: - real_addr = addr - if not can_send_to(ipv6_socket, real_addr): - return - if log_debug: - log.debug( - 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', - real_addr, - port or _MDNS_PORT, - s.fileno(), - transport.get_extra_info('sockname'), - len(packet), - packet_num + 1, - out, - packet, - ) - # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 - # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families - if ipv6_socket and not v6_flow_scope: - _, _, sock_flowinfo, sock_scopeid = s.getsockname() - v6_flow_scope = (sock_flowinfo, sock_scopeid) - transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) - def _close(self) -> None: """Set global done and remove all service listeners.""" if self.done: From d3ed69107330f1a29f45d174caafdec1e894f666 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 08:44:08 -0500 Subject: [PATCH 0656/1433] Use more f-strings in zeroconf._dns (#1002) --- zeroconf/_dns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index 35594b37e..a551a7dad 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -89,12 +89,12 @@ def __eq__(self, other: Any) -> bool: @staticmethod def get_class_(class_: int) -> str: """Class accessor""" - return _CLASSES.get(class_, "?(%s)" % class_) + return _CLASSES.get(class_, f"?({class_})") @staticmethod def get_type(t: int) -> str: """Type accessor""" - return _TYPES.get(t, "?(%s)" % t) + return _TYPES.get(t, f"?({t})") def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: """String representation with additional information""" From af4d082240a545ba3014eb7f1056c3b32ce2cb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 08:44:23 -0500 Subject: [PATCH 0657/1433] Breakout functions with no self-use in zeroconf._handlers (#1003) --- zeroconf/_handlers.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 5215f2026..b4c31e2db 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -227,6 +227,19 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) +def _get_address_and_nsec_records(service: ServiceInfo, now: float) -> Set[DNSRecord]: + """Build a set of address records and NSEC records for non-present record types.""" + seen_types: Set[int] = set() + records: Set[DNSRecord] = set() + for dns_address in service.dns_addresses(created=now): + seen_types.add(dns_address.type) + records.add(dns_address) + missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + if missing_types: + records.add(construct_nsec_record(service.server, list(missing_types), now)) + return records + + class QueryHandler: """Query the ServiceRegistry.""" @@ -261,21 +274,9 @@ def _add_pointer_answers( if known_answers.suppresses(dns_pointer): continue additionals: Set[DNSRecord] = {service.dns_service(created=now), service.dns_text(created=now)} - additionals |= self._get_address_and_nsec_records(service, now) + additionals |= _get_address_and_nsec_records(service, now) answer_set[dns_pointer] = additionals - def _get_address_and_nsec_records(self, service: ServiceInfo, now: float) -> Set[DNSRecord]: - """Build a set of address records and NSEC records for non-present record types.""" - seen_types: Set[int] = set() - records: Set[DNSRecord] = set() - for dns_address in service.dns_addresses(created=now): - seen_types.add(dns_address.type) - records.add(dns_address) - missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types - if missing_types: - records.add(construct_nsec_record(service.server, list(missing_types), now)) - return records - def _add_address_answers( self, name: str, @@ -332,7 +333,7 @@ def _answer_question( # https://tools.ietf.org/html/rfc6763#section-12.2. dns_service = service.dns_service(created=now) if not known_answers.suppresses(dns_service): - answer_set[dns_service] = self._get_address_and_nsec_records(service, now) + answer_set[dns_service] = _get_address_and_nsec_records(service, now) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service.dns_text(created=now) if not known_answers.suppresses(dns_text): From 543558d0498ed03eb9dc4597c4c40484e16ee4e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Sep 2021 08:44:32 -0500 Subject: [PATCH 0658/1433] Cleanup typing in zeroconf._protocol.outgoing (#1000) --- zeroconf/_protocol/outgoing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_protocol/outgoing.py b/zeroconf/_protocol/outgoing.py index 21ff4b640..59c8382e5 100644 --- a/zeroconf/_protocol/outgoing.py +++ b/zeroconf/_protocol/outgoing.py @@ -158,7 +158,7 @@ def add_additional_answer(self, record: DNSRecord) -> None: self.additionals.append(record) def add_question_or_one_cache( - self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + self, cache: DNSCache, now: float, name: str, type_: int, class_: int ) -> None: """Add a question if it is not already cached.""" cached_entry = cache.get_by_details(name, type_, class_) @@ -168,7 +168,7 @@ def add_question_or_one_cache( self.add_answer_at_time(cached_entry, now) def add_question_or_all_cache( - self, cache: 'DNSCache', now: float, name: str, type_: int, class_: int + self, cache: DNSCache, now: float, name: str, type_: int, class_: int ) -> None: """Add a question if it is not already cached. This is currently only used for IPv6 addresses. From fec9f3dc9626be08eccdf1263dbf4d1686fd27b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Oct 2021 11:26:59 -1000 Subject: [PATCH 0659/1433] Update CI to use python 3.10, pypy 3.7 (#1007) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b181feb..71ff1ddfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "pypy-3.6", "pypy-3.7"] include: - os: ubuntu-latest venvcmd: . env/bin/activate @@ -27,7 +27,8 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: '3.x' + architecture: 'x64' - uses: actions/cache@v2 id: cache with: From b0e8c8a21fd721e60adbac4dbf7a03959fc3f641 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Oct 2021 11:44:51 -1000 Subject: [PATCH 0660/1433] Fix ServiceBrowser infinite looping when zeroconf is closed before its canceled (#1008) --- zeroconf/_services/browser.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index f6448fd27..dd5bf4f35 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -465,9 +465,6 @@ def reschedule_type(self, type_: str, next_time: float) -> None: def _async_send_ready_queries(self) -> None: """Send any ready queries.""" - if self.done or self.zc.done: - return - outs = self._generate_ready_queries(self._first_request) if outs: self._first_request = False @@ -476,6 +473,8 @@ def _async_send_ready_queries(self) -> None: def _async_send_ready_queries_schedule_next(self) -> None: """Send ready queries and schedule next one.""" + if self.done or self.zc.done: + return self._async_send_ready_queries() self._async_schedule_next() From 15516188f346c70f64a923bb587804b9bf948873 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Oct 2021 13:04:04 -1000 Subject: [PATCH 0661/1433] Update changelog for 0.36.8 (#1010) --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index ea7c3d1c8..41fdd86c3 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,11 @@ See examples directory for more. Changelog ========= +0.36.8 +====== + +* Fixed ServiceBrowser infinite loop when zeroconf is closed before it is canceled (#1008) @bdraco + 0.36.7 ====== From 61275efd05688a61d656b43125b01a5d588f1dba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Oct 2021 18:04:21 -0500 Subject: [PATCH 0662/1433] =?UTF-8?q?Bump=20version:=200.36.7=20=E2=86=92?= =?UTF-8?q?=200.36.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 67e50408c..aeb06e90c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.7 +current_version = 0.36.8 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 4821cbb8c..e671c88e1 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.7' +__version__ = '0.36.8' __license__ = 'LGPL' From 87a4d8f4d5c8365425c2ee969032205f916f80c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Oct 2021 12:42:15 -1000 Subject: [PATCH 0663/1433] Ensure ServiceInfo orders newest addresess first (#1012) --- tests/services/test_info.py | 26 +++++++++++++-- tests/test_exceptions.py | 4 +-- zeroconf/_services/info.py | 66 ++++++++++++++++++++++++------------- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index a72d82f98..9c9bfa02c 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -560,7 +560,27 @@ def test_multiple_addresses(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio -async def test_multiple_a_addresses(): +async def test_multiple_a_addresses_newest_address_first(): + """Test that info.addresses returns the newest seen address first.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + cache = aiozc.zeroconf.cache + host = "multahost.local." + record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'\x7f\x00\x00\x01') + record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'\x7f\x00\x00\x02') + cache.async_add_records([record1, record2]) + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_invalid_a_addresses(caplog): type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ desc = {'path': '/~paulsm/'} @@ -574,7 +594,9 @@ async def test_multiple_a_addresses(): # New kwarg way info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) info.load_from_cache(aiozc.zeroconf) - assert set(info.addresses) == set([b'a', b'b']) + assert not info.addresses + assert "Encountered invalid address while processing record" in caplog.text + await aiozc.async_close() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 47e68b75f..6b682093a 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -141,11 +141,11 @@ def test_invalid_addresses(self): name = "xxxyyy" registration_name = f"{name}.{type_}" - bad = ('127.0.0.1', '::1', 42) + bad = (b'127.0.0.1', b'::1') for addr in bad: self.assertRaisesRegex( TypeError, - 'Addresses must be bytes', + 'Addresses must either ', ServiceInfo, type_, registration_name, diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index beaf0678d..2558f726a 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -27,6 +27,7 @@ from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException +from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener from .._utils.asyncio import get_running_loop, run_coro_with_timeout @@ -124,19 +125,12 @@ def __init__( self.type = type_ self._name = name self.key = name.lower() + self._ipv4_addresses: List[ipaddress.IPv4Address] = [] + self._ipv6_addresses: List[ipaddress.IPv6Address] = [] if addresses is not None: - self._addresses = addresses + self.addresses = addresses elif parsed_addresses is not None: - self._addresses = [_encode_address(a) for a in parsed_addresses] - else: - self._addresses = [] - # This results in an ugly error when registering, better check now - invalid = [a for a in self._addresses if not isinstance(a, bytes) or len(a) not in (4, 16)] - if invalid: - raise TypeError( - 'Addresses must be bytes, got %s. Hint: convert string addresses ' - 'with socket.inet_pton' % invalid - ) + self.addresses = [_encode_address(a) for a in parsed_addresses] self.port = port self.weight = weight self.priority = priority @@ -178,7 +172,21 @@ def addresses(self, value: List[bytes]) -> None: This replaces all currently stored addresses, both IPv4 and IPv6. """ - self._addresses = value + self._ipv4_addresses.clear() + self._ipv6_addresses.clear() + + for address in value: + try: + addr = ipaddress.ip_address(address) + except ValueError: + raise TypeError( + "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" + f" got {address}. Hint: convert string addresses with socket.inet_pton" # type: ignore + ) + if addr.version == 4: + self._ipv4_addresses.append(addr) + else: + self._ipv6_addresses.append(addr) @property def properties(self) -> Dict: @@ -194,10 +202,13 @@ def properties(self) -> Dict: def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version.""" if version == IPVersion.V4Only: - return [addr for addr in self._addresses if not _is_v6_address(addr)] + return [addr.packed for addr in self._ipv4_addresses] if version == IPVersion.V6Only: - return list(filter(_is_v6_address, self._addresses)) - return self._addresses + return [addr.packed for addr in self._ipv6_addresses] + return [ + *(addr.packed for addr in self._ipv4_addresses), + *(addr.packed for addr in self._ipv6_addresses), + ] def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """List addresses in their parsed string form.""" @@ -220,7 +231,7 @@ def is_link_local(addr_str: str) -> Any: ll_addrs = list(filter(is_link_local, self.parsed_addresses(version))) other_addrs = list(filter(lambda addr: not is_link_local(addr), self.parsed_addresses(version))) - return ["{}%{}".format(addr, self.interface_index) for addr in ll_addrs] + other_addrs + return [f"{addr}%{self.interface_index}" for addr in ll_addrs] + other_addrs def _set_properties(self, properties: Dict) -> None: """Sets properties and text of this info from a dictionary""" @@ -315,9 +326,20 @@ def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: return if isinstance(record, DNSAddress): - if record.key == self.server_key and record.address not in self._addresses: - self._addresses.append(record.address) - if record.type is _TYPE_AAAA and ipaddress.IPv6Address(record.address).is_link_local: + if record.key != self.server_key: + return + try: + ip_addr = ipaddress.ip_address(record.address) + except ValueError as ex: + log.warning("Encountered invalid address while processing %s: %s", record, ex) + return + if ip_addr.version == 4: + if ip_addr not in self._ipv4_addresses: + self._ipv4_addresses.insert(0, ip_addr) + return + if ip_addr not in self._ipv6_addresses: + self._ipv6_addresses.insert(0, ip_addr) + if ip_addr.is_link_local: self.interface_index = record.scope_id return @@ -422,7 +444,7 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: @property def _is_complete(self) -> bool: """The ServiceInfo has all expected properties.""" - return not (self.text is None or not self._addresses) + return bool(self.text is not None and (self._ipv4_addresses or self._ipv6_addresses)) def request( self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None @@ -494,10 +516,10 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: """String representation""" - return '%s(%s)' % ( + return '{}({})'.format( type(self).__name__, ', '.join( - '%s=%r' % (name, getattr(self, name)) + '{}={!r}'.format(name, getattr(self, name)) for name in ( 'type', 'name', From 1427ba75a8f7e2962aa0b3105d3c856002134790 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Oct 2021 13:13:34 -1000 Subject: [PATCH 0664/1433] Update changelog for 0.36.9 (#1016) --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index 41fdd86c3..f97a6143f 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,14 @@ See examples directory for more. Changelog ========= +0.36.9 +====== + +* Ensure ServiceInfo orders newest addresess first (#1012) @bdraco + + This change effectively restored the behavior before 1s cache flush + expire behavior described in rfc6762 section 10.2 was added for callers that rely on this. + 0.36.8 ====== From d92d3d030558c1b81b2e35f701b585f4b48fa99a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Oct 2021 18:14:47 -0500 Subject: [PATCH 0665/1433] =?UTF-8?q?Bump=20version:=200.36.8=20=E2=86=92?= =?UTF-8?q?=200.36.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index aeb06e90c..cf7e23d3a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.8 +current_version = 0.36.9 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e671c88e1..e5a142d46 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.8' +__version__ = '0.36.9' __license__ = 'LGPL' From 0fdcd5146264b37daa7cc35bda883519175e362f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Oct 2021 13:17:55 -1000 Subject: [PATCH 0666/1433] Fix typo in changelog (#1017) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f97a6143f..a92b2a200 100644 --- a/README.rst +++ b/README.rst @@ -141,7 +141,7 @@ Changelog 0.36.9 ====== -* Ensure ServiceInfo orders newest addresess first (#1012) @bdraco +* Ensure ServiceInfo orders newest addresses first (#1012) @bdraco This change effectively restored the behavior before 1s cache flush expire behavior described in rfc6762 section 10.2 was added for callers that rely on this. From 4b9a6c3fd4aec920597e7e63e82e935df68804f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 09:13:37 -0500 Subject: [PATCH 0667/1433] Optimize decoding labels from incoming packets (#1019) - decode is a bit faster vs str() ``` >>> ts = Timer("s.decode('utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") >>> ts.timeit() 0.09910525000003645 >>> ts = Timer("str(s, 'utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") >>> ts.timeit() 0.1304596250000145 ``` --- zeroconf/_protocol/incoming.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index 6d7a61531..5558949f8 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -287,7 +287,7 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S if length < 0x40: label_idx = off + DNS_COMPRESSION_HEADER_LEN - labels.append(str(self.data[label_idx : label_idx + length], 'utf-8', 'replace')) + labels.append(self.data[label_idx : label_idx + length].decode('utf-8', 'replace')) off += DNS_COMPRESSION_HEADER_LEN + length continue @@ -302,9 +302,9 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") if link in seen_pointers: raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") - seen_pointers.add(link) linked_labels = self.name_cache.get(link, []) if not linked_labels: + seen_pointers.add(link) self._decode_labels_at_offset(link, linked_labels, seen_pointers) self.name_cache[link] = linked_labels labels.extend(linked_labels) From 686febdd181c837fa6a41afce91edeeded731fbe Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sat, 30 Oct 2021 17:06:14 +0200 Subject: [PATCH 0668/1433] Strip scope_id from IPv6 address if given. (#1020) --- tests/utils/test_net.py | 1 + zeroconf/_utils/net.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 41fdb7aa2..f901ed507 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -39,6 +39,7 @@ def test_ip6_to_address_and_index(): """Test we can extract from mocked adapters.""" adapters = _generate_mock_adapters() assert netutils.ip6_to_address_and_index(adapters, "2001:db8::") == (('2001:db8::', 1, 1), 1) + assert netutils.ip6_to_address_and_index(adapters, "2001:db8::%1") == (('2001:db8::', 1, 1), 1) with pytest.raises(RuntimeError): assert netutils.ip6_to_address_and_index(adapters, "2005:db8::") diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index c53ec9786..5516cc703 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -83,6 +83,8 @@ def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: + if '%' in ip: + ip = ip[: ip.index('%')] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: From cd8984d3e95bffe6fd32b97eae9844bf5afed4de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:16:36 -0500 Subject: [PATCH 0669/1433] Fix test failure when has_working_ipv6 generates an exception (#1022) --- tests/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/__init__.py b/tests/__init__.py index 2671fe62d..9c29693e4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -59,6 +59,7 @@ def has_working_ipv6(): if not socket.has_ipv6: return False + sock = None try: sock = socket.socket(socket.AF_INET6) sock.bind(('::1', 0)) From 69ce817a68d65f2db0bfe6d4790d3a6a356ac83f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:16:51 -0500 Subject: [PATCH 0670/1433] Update changelog for 0.36.10 (#1021) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index a92b2a200..5286a243a 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,15 @@ See examples directory for more. Changelog ========= +0.36.10 +====== + +* scope_id is now stripped from IPv6 addresses if given (#1020) @StevenLooman + + cpython 3.9 allows a scope_id in the ipv6 address. This caused an error + with the existing code if it was not stripped +* Optimized decoding labels from incoming packets (#1019) @bdraco + 0.36.9 ====== From e0b340afbfd25ae9d05a59a577938b062287c8b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:18:00 -0500 Subject: [PATCH 0671/1433] =?UTF-8?q?Bump=20version:=200.36.9=20=E2=86=92?= =?UTF-8?q?=200.36.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index cf7e23d3a..aa274faa6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.9 +current_version = 0.36.10 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e5a142d46..d7c70a0f4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.9' +__version__ = '0.36.10' __license__ = 'LGPL' From c966976531ac9222460763d647d0a3b75459e275 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:53:31 -0500 Subject: [PATCH 0672/1433] Add readme check to the CI (#1023) --- .github/workflows/ci.yml | 5 +++++ README.rst | 4 ++-- requirements-dev.txt | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ff1ddfe..fdfe47b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,11 @@ jobs: python -m venv env ${{ matrix.venvcmd }} pip install --upgrade -r requirements-dev.txt pytest-github-actions-annotate-failures + - name: Validate readme + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + run: | + ${{ matrix.venvcmd }} + python -m readme_renderer README.rst -o - - name: Run flake8 if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} run: | diff --git a/README.rst b/README.rst index 5286a243a..06aa24599 100644 --- a/README.rst +++ b/README.rst @@ -139,11 +139,11 @@ Changelog ========= 0.36.10 -====== +======= * scope_id is now stripped from IPv6 addresses if given (#1020) @StevenLooman - cpython 3.9 allows a scope_id in the ipv6 address. This caused an error + cpython 3.9 allows a suffix %scope_id in IPv6Address. This caused an error with the existing code if it was not stripped * Optimized decoding labels from incoming packets (#1019) @bdraco diff --git a/requirements-dev.txt b/requirements-dev.txt index e74836667..c8c11ec73 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,3 +14,4 @@ pytest pytest-asyncio pytest-cov pytest-timeout +readme_renderer From 69a9b8e060ae8a596050d393c0a5c8b43beadc8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:56:02 -0500 Subject: [PATCH 0673/1433] Update changelog for 0.36.11 (#1024) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 06aa24599..e46598a6f 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,12 @@ See examples directory for more. Changelog ========= +0.36.11 +======= + +No functional changes from 0.36.10. This release corrects an error in the README.rst file +that prevented the build from uploading to PyPI + 0.36.10 ======= From 3d8f50de74f7b3941d9b35b6ae6e42ba02be9361 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Oct 2021 10:56:28 -0500 Subject: [PATCH 0674/1433] =?UTF-8?q?Bump=20version:=200.36.10=20=E2=86=92?= =?UTF-8?q?=200.36.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index aa274faa6..c00e021a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.10 +current_version = 0.36.11 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d7c70a0f4..62dde5eda 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.10' +__version__ = '0.36.11' __license__ = 'LGPL' From 38380a58a64f563f105cecc610f194c20056b2b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Nov 2021 23:24:32 -0500 Subject: [PATCH 0675/1433] Prevent service lookups from deadlocking if time abruptly moves backwards (#1006) - The typical reason time moves backwards is via an ntp update --- tests/services/test_browser.py | 2 +- tests/test_asyncio.py | 2 +- zeroconf/_utils/time.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index e22ebfe33..bed286120 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -442,7 +442,7 @@ def test_backoff(): old_send = zeroconf_browser.async_send time_offset = 0.0 - start_time = time.time() * 1000 + start_time = time.monotonic() * 1000 initial_query_interval = _services_browser._BROWSER_TIME / 1000 def current_time_millis(): diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index ea80d6f5f..d5aec0e0c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -850,7 +850,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): def _new_current_time_millis(): """Current system time in milliseconds""" - return (time.time() * 1000) + (time_offset * 1000) + return (time.monotonic() * 1000) + (time_offset * 1000) expected_ttl = const._DNS_HOST_TTL nbr_answers = 0 diff --git a/zeroconf/_utils/time.py b/zeroconf/_utils/time.py index 0ba91ead8..4c083d2b8 100644 --- a/zeroconf/_utils/time.py +++ b/zeroconf/_utils/time.py @@ -26,7 +26,7 @@ def current_time_millis() -> float: """Current system time in milliseconds""" - return time.time() * 1000 + return time.monotonic() * 1000 def millis_to_seconds(millis: float) -> float: From 3c708080b3e42a02930ad17c96a2cf0dcb06f441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Nov 2021 23:49:25 -0500 Subject: [PATCH 0676/1433] Account for intricacies of floating-point arithmetic in service browser tests (#1026) --- tests/services/test_browser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index bed286120..2519b7538 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -989,32 +989,32 @@ async def test_query_scheduler(): assert set(query_scheduler.process_ready_types(now)) == types_ assert set(query_scheduler.process_ready_types(now)) == set() - assert query_scheduler.millis_to_wait(now) == delay + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay, 0.00001) assert set(query_scheduler.process_ready_types(now + delay)) == types_ assert set(query_scheduler.process_ready_types(now + delay)) == set() - assert query_scheduler.millis_to_wait(now) == delay * 3 + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 3, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 3)) == types_ assert set(query_scheduler.process_ready_types(now + delay * 3)) == set() - assert query_scheduler.millis_to_wait(now) == delay * 7 + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 7, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 7)) == types_ assert set(query_scheduler.process_ready_types(now + delay * 7)) == set() - assert query_scheduler.millis_to_wait(now) == delay * 15 + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 15, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 15)) == types_ assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() # Test if we reschedule 1 second later, the millis_to_wait goes up by 1 query_scheduler.reschedule_type("_hap._tcp.local.", now + delay * 16) - assert query_scheduler.millis_to_wait(now) == delay * 16 + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 16, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() # Test if we reschedule 1 second later... and its ready for processing assert set(query_scheduler.process_ready_types(now + delay * 16)) == set(["_hap._tcp.local."]) - assert query_scheduler.millis_to_wait(now) == delay * 31 + assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 31, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() assert set(query_scheduler.process_ready_types(now + delay * 31)) == set(["_http._tcp.local."]) From 51bf364b364ecaad16503df4a4c4c3bb5ead2775 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Nov 2021 00:10:47 -0500 Subject: [PATCH 0677/1433] Update changelog for 0.36.12 (#1027) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index e46598a6f..be23faf5e 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,13 @@ See examples directory for more. Changelog ========= +0.36.12 +======= + +* Prevent service lookups from deadlocking if time abruptly moves backwards (#1006) @bdraco + + The typical reason time moves backwards is via an ntp update + 0.36.11 ======= From 8b0dc48ed42d8edc78750122eb5685a50c3cdc11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Nov 2021 00:11:04 -0500 Subject: [PATCH 0678/1433] =?UTF-8?q?Bump=20version:=200.36.11=20=E2=86=92?= =?UTF-8?q?=200.36.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index c00e021a0..95ecdcb92 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.11 +current_version = 0.36.12 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 62dde5eda..d0e48a5c2 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.11' +__version__ = '0.36.12' __license__ = 'LGPL' From aa59998182ce29c55f8c3dde9a058ce36ac2bb2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Nov 2021 09:08:43 -0600 Subject: [PATCH 0679/1433] Skip unavailable interfaces during socket bind (#1028) - We already skip these when adding multicast members. Apply the same logic to the socket bind call --- tests/utils/test_net.py | 25 ++++++++++++++++++++++++- zeroconf/_utils/net.py | 16 ++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index f901ed507..3d75bbc4e 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -3,7 +3,7 @@ """Unit tests for zeroconf._utils.net.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import errno import ifaddr @@ -198,3 +198,26 @@ def test_add_multicast_member(): # No error should return True with patch("socket.socket.setsockopt"): assert netutils.add_multicast_member(sock, interface) is True + + +def test_bind_raises_skips_address(): + """Test bind failing in new_socket returns None on EADDRNOTAVAIL.""" + err = errno.EADDRNOTAVAIL + + def _mock_socket(*args, **kwargs): + sock = MagicMock() + sock.bind = MagicMock(side_effect=OSError(err, "Error: {}".format(err))) + return sock + + with patch("socket.socket", _mock_socket): + assert netutils.new_socket(("0.0.0.0", 0)) is None + + err = errno.EAGAIN + with pytest.raises(OSError), patch("socket.socket", _mock_socket): + netutils.new_socket(("0.0.0.0", 0)) + + +def test_new_respond_socket_new_socket_returns_none(): + """Test new_respond_socket returns None if new_socket returns None.""" + with patch.object(netutils, "new_socket", return_value=None): + assert netutils.new_respond_socket(("0.0.0.0", 0)) is None diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 5516cc703..0f5ad4111 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -218,7 +218,7 @@ def new_socket( port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, -) -> socket.socket: +) -> Optional[socket.socket]: log.debug( 'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r', port, @@ -243,7 +243,17 @@ def new_socket( # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) - s.bind((bind_addr[0], port, *bind_addr[1:])) + bind_tup = (bind_addr[0], port, *bind_addr[1:]) + try: + s.bind(bind_tup) + except OSError as ex: + if ex.errno == errno.EADDRNOTAVAIL: + log.warning( + 'Address not available when binding to %s, ' 'it is expected to happen on some systems', + bind_tup, + ) + return None + raise log.debug('Created socket %s', s) return s @@ -323,6 +333,8 @@ def new_respond_socket( apple_p2p=apple_p2p, bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), ) + if not respond_socket: + return None log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) if is_v6: iface_bin = struct.pack('@I', cast(int, interface[1])) From 73c52d04a140bc744669777a0f353eefc6623ff9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Nov 2021 09:29:39 -0600 Subject: [PATCH 0680/1433] Downgrade incoming corrupt packet logging to debug (#1029) - Warning about network traffic we have no control over is confusing to users as they think there is something wrong with zeroconf --- tests/test_logger.py | 20 +++++++++++++++++++- zeroconf/_logger.py | 14 ++++++++++++++ zeroconf/_protocol/incoming.py | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/test_logger.py b/tests/test_logger.py index 2d8bbb086..9413f252c 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -4,7 +4,7 @@ """Unit tests for logger.py.""" import logging -from unittest.mock import patch +from unittest.mock import call, patch from zeroconf._logger import QuietLogger, set_logger_level_if_unset @@ -25,6 +25,7 @@ def test_loading_logger(): def test_log_warning_once(): """Test we only log with warning level once.""" + QuietLogger._seen_logs = {} quiet_logger = QuietLogger() with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( "zeroconf._logger.log.debug" @@ -45,6 +46,7 @@ def test_log_warning_once(): def test_log_exception_warning(): """Test we only log with warning level once.""" + QuietLogger._seen_logs = {} quiet_logger = QuietLogger() with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( "zeroconf._logger.log.debug" @@ -63,8 +65,24 @@ def test_log_exception_warning(): assert mock_log_debug.mock_calls +def test_llog_exception_debug(): + """Test we only log with a trace once.""" + QuietLogger._seen_logs = {} + quiet_logger = QuietLogger() + with patch("zeroconf._logger.log.debug") as mock_log_debug: + quiet_logger.log_exception_debug("the exception") + + assert mock_log_debug.mock_calls == [call('the exception', exc_info=True)] + + with patch("zeroconf._logger.log.debug") as mock_log_debug: + quiet_logger.log_exception_debug("the exception") + + assert mock_log_debug.mock_calls == [call('the exception', exc_info=False)] + + def test_log_exception_once(): """Test we only log with warning level once.""" + QuietLogger._seen_logs = {} quiet_logger = QuietLogger() exc = Exception() with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index 932d1a2f1..b7bb65de6 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -51,6 +51,20 @@ def log_exception_warning(cls, *logger_data: Any) -> None: logger = log.debug logger(*(logger_data or ['Exception occurred']), exc_info=True) + @classmethod + def log_exception_debug(cls, *logger_data: Any) -> None: + log_exc_info = False + exc_info = sys.exc_info() + exc_str = str(exc_info[1]) + import pprint + + pprint.pprint(cls._seen_logs) + if exc_str not in cls._seen_logs: + # log the trace only on the first time + cls._seen_logs[exc_str] = exc_info + log_exc_info = True + log.debug(*(logger_data or ['Exception occurred']), exc_info=log_exc_info) + @classmethod def log_warning_once(cls, *args: Any) -> None: msg_str = args[0] diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index 5558949f8..c25c7a531 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -110,7 +110,7 @@ def _parse_data(self, parser_call: Callable) -> None: try: parser_call() except DECODE_EXCEPTIONS: - self.log_exception_warning( + self.log_exception_debug( 'Received invalid packet from %s at offset %d while unpacking %r', self.source, self.offset, From 106cf27478bb0c1e6e5a7194661ff52947d61c96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Nov 2021 09:43:31 -0600 Subject: [PATCH 0681/1433] Update changelog for 0.36.13 (#1030) --- README.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index be23faf5e..f14c01359 100644 --- a/README.rst +++ b/README.rst @@ -138,10 +138,19 @@ See examples directory for more. Changelog ========= +0.36.13 +======= + +* Unavailable interfaces are now skipped during socket bind (#1028) @bdraco +* Downgraded incoming corrupt packet logging to debug (#1029) @bdraco + + Warning about network traffic we have no control over is confusing + to users as they think there is something wrong with zeroconf + 0.36.12 ======= -* Prevent service lookups from deadlocking if time abruptly moves backwards (#1006) @bdraco +* Prevented service lookups from deadlocking if time abruptly moves backwards (#1006) @bdraco The typical reason time moves backwards is via an ntp update From 4241c76550130469aecbe88cc1a7cdc13505f8ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Nov 2021 09:43:58 -0600 Subject: [PATCH 0682/1433] =?UTF-8?q?Bump=20version:=200.36.12=20=E2=86=92?= =?UTF-8?q?=200.36.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 95ecdcb92..82aa1d3b1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.12 +current_version = 0.36.13 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d0e48a5c2..a293148c0 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.12' +__version__ = '0.36.13' __license__ = 'LGPL' From 21bd10762a89ca3f4ca89f598c9d93684a02f51b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Nov 2021 13:13:40 -0600 Subject: [PATCH 0683/1433] Throw EventLoopBlocked instead of concurrent.futures.TimeoutError (#1032) --- tests/utils/test_asyncio.py | 12 ++++++++++++ zeroconf/__init__.py | 23 +++++++++++++++-------- zeroconf/_core.py | 35 ++++++++++++++++++++++++++++++----- zeroconf/_exceptions.py | 24 +++++++++++++++++------- zeroconf/_services/info.py | 4 ++++ zeroconf/_utils/asyncio.py | 22 ++++++++++++++++++---- 6 files changed, 96 insertions(+), 24 deletions(-) diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 2939b5ab5..c82c6fd8b 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -13,6 +13,7 @@ import pytest +from zeroconf import EventLoopBlocked from zeroconf._core import _CLOSE_TIMEOUT from zeroconf._utils import asyncio as aioutils from zeroconf.const import _LOADED_SYSTEM_TIMEOUT @@ -112,3 +113,14 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): assert ( aioutils._TASK_AWAIT_TIMEOUT + aioutils._GET_ALL_TASKS_TIMEOUT + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT + + +async def test_run_coro_with_timeout() -> None: + """Test running a coroutine with a timeout raises EventLoopBlocked.""" + loop = asyncio.get_event_loop() + + def _run_in_loop(): + aioutils.run_coro_with_timeout(asyncio.sleep(0.3), loop, 0.1) + + with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): + await loop.run_in_executor(None, _run_in_loop) diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index a293148c0..9ce359f76 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -23,7 +23,7 @@ import sys from ._cache import DNSCache # noqa # import needed for backwards compat -from ._core import Zeroconf # noqa # import needed for backwards compat +from ._core import Zeroconf from ._dns import ( # noqa # import needed for backwards compat DNSAddress, DNSEntry, @@ -36,16 +36,17 @@ DNSText, DNSQuestionType, ) -from ._logger import QuietLogger, log # noqa # import needed for backwards compat -from ._exceptions import ( # noqa # import needed for backwards compat +from ._exceptions import ( AbstractMethodException, BadTypeInNameException, Error, + EventLoopBlocked, IncomingDecodeError, NamePartTooLongException, NonUniqueNameException, ServiceNameAlreadyRegistered, ) +from ._logger import QuietLogger, log # noqa # import needed for backwards compat from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat from ._services import ( # noqa # import needed for backwards compat @@ -54,9 +55,7 @@ ServiceListener, ServiceStateChange, ) -from ._services.browser import ( # noqa # import needed for backwards compat - ServiceBrowser, -) +from ._services.browser import ServiceBrowser from ._services.info import ( # noqa # import needed for backwards compat instance_name_from_service_info, ServiceInfo, @@ -85,16 +84,24 @@ __all__ = [ "__version__", - "DNSQuestionType", "Zeroconf", "ServiceInfo", "ServiceBrowser", "ServiceListener", - "Error", + "DNSQuestionType", "InterfaceChoice", "ServiceStateChange", "IPVersion", "ZeroconfServiceTypes", + # Exceptions + "Error", + "AbstractMethodException", + "BadTypeInNameException", + "EventLoopBlocked", + "IncomingDecodeError", + "NamePartTooLongException", + "NonUniqueNameException", + "ServiceNameAlreadyRegistered", ] if sys.version_info <= (3, 6): # pragma: no cover diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1575eba29..90e566a80 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -192,7 +192,12 @@ def _async_shutdown(self) -> None: transport.close() def close(self) -> None: - """Close from sync context.""" + """Close from sync context. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `_async_close` cannot be completed. + """ assert self.loop is not None # Guard against Zeroconf.close() being called from the eventloop if get_running_loop() == self.loop: @@ -554,7 +559,12 @@ def register_service( service. The name of the service may be changed if needed to make it unique on the network. Additionally multiple cooperating responders can register the same service on the network for resilience - (if you want this behavior set `cooperating_responders` to `True`).""" + (if you want this behavior set `cooperating_responders` to `True`). + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `register_service` cannot be completed. + """ assert self.loop is not None run_coro_with_timeout( await_awaitable( @@ -591,7 +601,12 @@ async def async_register_service( def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that - service.""" + service. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `async_update_service` cannot be completed. + """ assert self.loop is not None run_coro_with_timeout( await_awaitable(self.async_update_service(info)), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS @@ -662,7 +677,12 @@ def _add_broadcast_answer( # pylint: disable=no-self-use out.add_answer_at_time(dns_address, 0) def unregister_service(self, info: ServiceInfo) -> None: - """Unregister a service.""" + """Unregister a service. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `async_unregister_service` cannot be completed. + """ assert self.loop is not None run_coro_with_timeout( self.async_unregister_service(info), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS @@ -708,7 +728,12 @@ async def async_unregister_all_services(self) -> None: self.async_send(out) def unregister_all_services(self) -> None: - """Unregister all registered services.""" + """Unregister all registered services. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `async_unregister_all_services` cannot be completed. + """ assert self.loop is not None run_coro_with_timeout( self.async_unregister_all_services(), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS diff --git a/zeroconf/_exceptions.py b/zeroconf/_exceptions.py index 02771140f..667408c29 100644 --- a/zeroconf/_exceptions.py +++ b/zeroconf/_exceptions.py @@ -22,28 +22,38 @@ class Error(Exception): - pass + """Base class for all zeroconf exceptions.""" class IncomingDecodeError(Error): - pass + """Exception when there is invalid data in an incoming packet.""" class NonUniqueNameException(Error): - pass + """Exception when the name is already registered.""" class NamePartTooLongException(Error): - pass + """Exception when the name is too long.""" class AbstractMethodException(Error): - pass + """Exception when a required method is not implemented.""" class BadTypeInNameException(Error): - pass + """Exception when the type in a name is invalid.""" class ServiceNameAlreadyRegistered(Error): - pass + """Exception when a service name is already registered.""" + + +class EventLoopBlocked(Error): + """Exception when the event loop is blocked. + + This exception is never expected to be thrown + during normal operation. It should only happen + when the cpu is maxed out or there is something blocking + the event loop. + """ diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 2558f726a..ecf7c25ea 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -451,6 +451,10 @@ def request( ) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `async_request` cannot be completed. """ assert zc.loop is not None and zc.loop.is_running() if zc.loop == get_running_loop(): diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index 10b8b3d97..37b7865a8 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -21,11 +21,13 @@ """ import asyncio +import concurrent.futures import contextlib import queue from typing import Any, Awaitable, Coroutine, List, Optional, Set, cast from .time import millis_to_seconds +from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT # The combined timeouts should be lower than _CLOSE_TIMEOUT + _WAIT_FOR_LOOP_TASKS_TIMEOUT @@ -91,10 +93,22 @@ async def await_awaitable(aw: Awaitable) -> None: def run_coro_with_timeout(aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float) -> Any: - """Run a coroutine with a timeout.""" - return asyncio.run_coroutine_threadsafe(aw, loop).result( - millis_to_seconds(timeout) + _LOADED_SYSTEM_TIMEOUT - ) + """Run a coroutine with a timeout. + + The timeout should only be used as a safeguard to prevent + the program from blocking forever. The timeout should + never be expected to be reached during normal operation. + + While not expected during normal operations, the + function raises `EventLoopBlocked` if the coroutine takes + longer to complete than the timeout. + """ + try: + return asyncio.run_coroutine_threadsafe(aw, loop).result( + millis_to_seconds(timeout) + _LOADED_SYSTEM_TIMEOUT + ) + except concurrent.futures.TimeoutError as ex: + raise EventLoopBlocked from ex def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: From 28938d20bb62ae0d9aa2f94929f60434fb346704 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Nov 2021 13:45:08 -0600 Subject: [PATCH 0684/1433] Throw NotRunningException when Zeroconf is not running (#1033) - Before this change the consumer would get a timeout or an EventLoopBlocked exception when calling `ServiceInfo.*request` when the instance had already been shutdown. This was quite a confusing result. --- tests/test_asyncio.py | 10 ++++++++++ zeroconf/__init__.py | 2 ++ zeroconf/_core.py | 29 +++++++++++++++++------------ zeroconf/_exceptions.py | 8 ++++++++ zeroconf/asyncio.py | 5 +++-- zeroconf/const.py | 1 + 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d5aec0e0c..0157f06c2 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -23,6 +23,7 @@ DNSService, DNSAddress, DNSText, + NotRunningException, ServiceStateChange, Zeroconf, const, @@ -1090,6 +1091,15 @@ async def test_async_request_timeout(): assert (end_time - start_time) < 3000 + 1000 +@pytest.mark.asyncio +async def test_async_request_non_running_instance(): + """Test that the async_request throws when zeroconf is not running.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.async_close() + with pytest.raises(NotRunningException): + await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") + + @pytest.mark.asyncio async def test_legacy_unicast_response(run_isolated): """Verify legacy unicast responses include questions and correct id.""" diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 9ce359f76..5cd39a94c 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -44,6 +44,7 @@ IncomingDecodeError, NamePartTooLongException, NonUniqueNameException, + NotRunningException, ServiceNameAlreadyRegistered, ) from ._logger import QuietLogger, log # noqa # import needed for backwards compat @@ -101,6 +102,7 @@ "IncomingDecodeError", "NamePartTooLongException", "NonUniqueNameException", + "NotRunningException", "ServiceNameAlreadyRegistered", ] diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 90e566a80..0c70b7afc 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -32,7 +32,7 @@ from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType -from ._exceptions import NonUniqueNameException +from ._exceptions import NonUniqueNameException, NotRunningException from ._handlers import ( MulticastOutgoingQueue, QueryHandler, @@ -80,6 +80,7 @@ _MDNS_PORT, _ONE_SECOND, _REGISTER_TIME, + _STARTUP_TIMEOUT, _TYPE_PTR, _UNREGISTER_TIME, ) @@ -118,15 +119,15 @@ def __init__( self.protocols: List[AsyncListener] = [] self.readers: List[asyncio.DatagramTransport] = [] self.senders: List[asyncio.DatagramTransport] = [] + self.running_event: Optional[asyncio.Event] = None self._listen_socket = listen_socket self._respond_sockets = respond_sockets self._cleanup_timer: Optional[asyncio.TimerHandle] = None - self._running_event: Optional[asyncio.Event] = None def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: """Set up the instance.""" self.loop = loop - self._running_event = asyncio.Event() + self.running_event = asyncio.Event() self.loop.create_task(self._async_setup(loop_thread_ready)) async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: @@ -136,16 +137,11 @@ async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> No millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup ) await self._async_create_endpoints() - assert self._running_event is not None - self._running_event.set() + assert self.running_event is not None + self.running_event.set() if loop_thread_ready: loop_thread_ready.set() - async def async_wait_for_start(self) -> None: - """Wait for start up.""" - assert self._running_event is not None - await self._running_event.wait() - async def _async_create_endpoints(self) -> None: """Create endpoints to send and receive.""" assert self.loop is not None @@ -495,8 +491,17 @@ def _run_loop() -> None: loop_thread_ready.wait() async def async_wait_for_start(self) -> None: - """Wait for start up.""" - await self.engine.async_wait_for_start() + """Wait for start up for actions that require a running Zeroconf instance. + + Throws NotRunningException if the instance is not running or could + not be started. + """ + if self.done: # If the instance was shutdown from under us, raise immediately + raise NotRunningException + assert self.engine.running_event is not None + await wait_event_or_timeout(self.engine.running_event, timeout=_STARTUP_TIMEOUT) + if not self.engine.running_event.is_set() or self.done: + raise NotRunningException @property def listeners(self) -> List[RecordUpdateListener]: diff --git a/zeroconf/_exceptions.py b/zeroconf/_exceptions.py index 667408c29..f4fcbd551 100644 --- a/zeroconf/_exceptions.py +++ b/zeroconf/_exceptions.py @@ -57,3 +57,11 @@ class EventLoopBlocked(Error): when the cpu is maxed out or there is something blocking the event loop. """ + + +class NotRunningException(Error): + """Exception when an action is called with a zeroconf instance that is not running. + + The instance may not be running because it was already shutdown + or startup has failed in some unexpected way. + """ diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index ef7e7f649..de7e97dd3 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -218,8 +218,9 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) + if not self.zeroconf.done: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) await self.async_remove_all_service_listeners() await self.async_unregister_all_services() await self.zeroconf._async_close() # pylint: disable=protected-access diff --git a/zeroconf/const.py b/zeroconf/const.py index ff5cc3a26..690d26710 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -34,6 +34,7 @@ _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10000 # ms _LOADED_SYSTEM_TIMEOUT = 10 # s +_STARTUP_TIMEOUT = 9 # s must be lower than _LOADED_SYSTEM_TIMEOUT _ONE_SECOND = 1000 # ms # If the system is loaded or the event From ee071a12f31f7010110eef5ccef80c6cdf469d87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Nov 2021 14:12:56 -0600 Subject: [PATCH 0685/1433] Log an error when listeners are added that do not inherit from RecordUpdateListener (#1034) --- tests/test_handlers.py | 22 ++++++++++++++++++++++ zeroconf/__init__.py | 5 ++++- zeroconf/_handlers.py | 6 ++++++ zeroconf/_updates.py | 6 ++++++ zeroconf/_utils/time.py | 6 +++++- 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 44ee1d5af..1b630a1b5 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1538,3 +1538,25 @@ async def test_future_answers_are_removed_on_send(): # But the one we have not sent yet shoudl still go out later assert info2.dns_pointer() in outgoing_queue.queue[0].answers + + +@pytest.mark.asyncio +async def test_add_listener_warns_when_not_using_record_update_listener(caplog): + """Log when a listener is added that is not using RecordUpdateListener as a base class.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc: Zeroconf = aiozc.zeroconf + updated = [] + + class MyListener: + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + """Update multiple records in one shot.""" + updated.extend(records) + + zc.add_listener(MyListener(), None) + await asyncio.sleep(0) # flush out any call soons + assert "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text + + await aiozc.async_close() diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 5cd39a94c..10fd47298 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -63,7 +63,7 @@ ) from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat from ._services.types import ZeroconfServiceTypes -from ._updates import RecordUpdate, RecordUpdateListener # noqa # import needed for backwards compat +from ._updates import RecordUpdate, RecordUpdateListener from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat add_multicast_member, @@ -94,6 +94,9 @@ "ServiceStateChange", "IPVersion", "ZeroconfServiceTypes", + "RecordUpdate", + "RecordUpdateListener", + "current_time_millis", # Exceptions "Error", "AbstractMethodException", diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index b4c31e2db..ee756f75e 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -492,6 +492,12 @@ def async_add_listener( This function is not threadsafe and must be called in the eventloop. """ + if not isinstance(listener, RecordUpdateListener): + log.error( + "listeners passed to async_add_listener must inherit from RecordUpdateListener;" + " In the future this will fail" + ) + self.listeners.append(listener) if question is None: diff --git a/zeroconf/_updates.py b/zeroconf/_updates.py index bc7dcab56..e10c01eab 100644 --- a/zeroconf/_updates.py +++ b/zeroconf/_updates.py @@ -36,6 +36,12 @@ class RecordUpdate(NamedTuple): class RecordUpdateListener: + """Base call for all record listeners. + + All listeners passed to async_add_listener should use RecordUpdateListener + as a base class. In the future it will be required. + """ + def update_record( # pylint: disable=no-self-use self, zc: 'Zeroconf', now: float, record: DNSRecord ) -> None: diff --git a/zeroconf/_utils/time.py b/zeroconf/_utils/time.py index 4c083d2b8..59362c557 100644 --- a/zeroconf/_utils/time.py +++ b/zeroconf/_utils/time.py @@ -25,7 +25,11 @@ def current_time_millis() -> float: - """Current system time in milliseconds""" + """Current time in milliseconds. + + The current implemention uses `time.monotonic` + but may change in the future. + """ return time.monotonic() * 1000 From 61a7e3fb65d99db7d51f1df42b286b55710a2e99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Nov 2021 14:30:03 -0600 Subject: [PATCH 0686/1433] Update changelog for 0.37.0 (#1035) --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index f14c01359..d17664a1c 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,23 @@ See examples directory for more. Changelog ========= +0.37.0 +====== + +Technically backwards incompatible: + +* Adding a listener that does not inherit from RecordUpdateListener now logs an error (#1034) @bdraco +* The NotRunningException exception is now thrown when Zeroconf is not running (#1033) @bdraco + + Before this change the consumer would get a timeout or an EventLoopBlocked + exception when calling `ServiceInfo.*request` when the instance had already been shutdown + or had failed to startup. + +* The EventLoopBlocked exception is now thrown when a coroutine times out (#1032) @bdraco + + Previously `concurrent.futures.TimeoutError` would have been raised + instead. This is never expected to happen during normal operation. + 0.36.13 ======= From 2996e642f6b1abba1dbb8242ccca4cd4b96696f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Nov 2021 14:30:53 -0600 Subject: [PATCH 0687/1433] =?UTF-8?q?Bump=20version:=200.36.13=20=E2=86=92?= =?UTF-8?q?=200.37.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 82aa1d3b1..07c7c46f9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.36.13 +current_version = 0.37.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 10fd47298..83146ee51 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.36.13' +__version__ = '0.37.0' __license__ = 'LGPL' From 631a6f7c7863897336a9d6ca4bd1736cc7cc97af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 13:12:11 -1000 Subject: [PATCH 0688/1433] Drop python 3.6 support (#1009) --- .github/workflows/ci.yml | 2 +- README.rst | 4 ++-- tests/services/test_browser.py | 19 +++++++++---------- tests/services/test_info.py | 17 ++++++++--------- tests/services/test_registry.py | 15 +++++++-------- tests/services/test_types.py | 11 +++++------ tests/utils/test_asyncio.py | 1 - tests/utils/test_name.py | 1 - tests/utils/test_net.py | 3 +-- zeroconf/_services/browser.py | 11 +++++------ zeroconf/_utils/asyncio.py | 27 ++++++--------------------- zeroconf/_utils/name.py | 2 +- zeroconf/_utils/net.py | 8 ++++---- 13 files changed, 49 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdfe47b17..1181cac70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "pypy-3.6", "pypy-3.7"] + python-version: [3.7, 3.8, 3.9, "3.10", "pypy-3.7"] include: - os: ubuntu-latest venvcmd: . env/bin/activate diff --git a/README.rst b/README.rst index d17664a1c..d76ecaf75 100644 --- a/README.rst +++ b/README.rst @@ -44,8 +44,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.6+ -* PyPy3 7.2+ +* CPython 3.7+ +* PyPy3.7 7.3+ Versioning ---------- diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 2519b7538..120aadb23 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._services.browser. """ @@ -682,7 +681,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -902,7 +901,7 @@ def test_group_ptr_queries_with_known_answers(): now = current_time_millis() for i in range(120): name = f"_hap{i}._tcp._local." - questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = set( + questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = { DNSPointer( name, const._TYPE_PTR, @@ -911,7 +910,7 @@ def test_group_ptr_queries_with_known_answers(): f"zoo{counter}.{name}", ) for counter in range(i) - ) + } outs = _services_browser._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) for out in outs: packets = out.packets() @@ -937,7 +936,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): 10000, f'known-to-other.{name}', ) - other_known_answers = set([answer]) + other_known_answers = {answer} zc.question_history.add_question_at_time(question, now, other_known_answers) assert zc.question_history.suppresses(question, now, other_known_answers) @@ -976,7 +975,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): @pytest.mark.asyncio async def test_query_scheduler(): delay = const._BROWSER_TIME - types_ = set(["_hap._tcp.local.", "_http._tcp.local."]) + types_ = {"_hap._tcp.local.", "_http._tcp.local."} query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0)) now = current_time_millis() @@ -984,8 +983,8 @@ async def test_query_scheduler(): # Test query interval is increasing assert query_scheduler.millis_to_wait(now - 1) == 1 - assert query_scheduler.millis_to_wait(now) is 0 - assert query_scheduler.millis_to_wait(now + 1) is 0 + assert query_scheduler.millis_to_wait(now) == 0 + assert query_scheduler.millis_to_wait(now + 1) == 0 assert set(query_scheduler.process_ready_types(now)) == types_ assert set(query_scheduler.process_ready_types(now)) == set() @@ -1013,8 +1012,8 @@ async def test_query_scheduler(): assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() # Test if we reschedule 1 second later... and its ready for processing - assert set(query_scheduler.process_ready_types(now + delay * 16)) == set(["_hap._tcp.local."]) + assert set(query_scheduler.process_ready_types(now + delay * 16)) == {"_hap._tcp.local."} assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 31, 0.00001) assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() - assert set(query_scheduler.process_ready_types(now + delay * 31)) == set(["_http._tcp.local."]) + assert set(query_scheduler.process_ready_types(now + delay * 31)) == {"_http._tcp.local."} diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 9c9bfa02c..39420109c 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ Unit tests for zeroconf._services.info. """ @@ -607,7 +606,7 @@ def test_filter_address_by_type_from_service_info(): desc = {'path': '/~paulsm/'} type_ = "_homeassistant._tcp.local." name = "MyTestHome" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" ipv4 = socket.inet_aton("10.0.1.2") ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) @@ -627,7 +626,7 @@ def test_changing_name_updates_serviceinfo_key(): name = "MyTestHome" info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -649,7 +648,7 @@ def test_serviceinfo_address_updates(): with pytest.raises(TypeError): info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -661,7 +660,7 @@ def test_serviceinfo_address_updates(): info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -680,12 +679,12 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): addresses = [socket.inet_aton("10.0.1.2")] server_name = "ash-2.local." info_service = ServiceInfo( - type_, '%s.%s' % (name, type_), 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses + type_, f'{name}.{type_}', 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses ) assert info_service.dns_text().text == b'\x0epath=/~paulsm/' info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -696,7 +695,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): assert info_service.dns_text().text == b'\x0epath=/~paulsm/' info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, @@ -707,7 +706,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): assert info_service.dns_text().text == b'\x0epath=/~paulsm/' info_service = ServiceInfo( type_, - '%s.%s' % (name, type_), + f'{name}.{type_}', 80, 0, 0, diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 3c105cbb3..d5fd43e8c 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for zeroconf._services.registry.""" @@ -15,7 +14,7 @@ class TestServiceRegistry(unittest.TestCase): def test_only_register_once(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -32,8 +31,8 @@ def test_register_same_server(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" name2 = "xxxyyy2" - registration_name = "%s.%s" % (name, type_) - registration_name2 = "%s.%s" % (name2, type_) + registration_name = f"{name}.{type_}" + registration_name2 = f"{name2}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -61,7 +60,7 @@ def test_unregister_multiple_times(self): """ type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -77,7 +76,7 @@ def test_unregister_multiple_times(self): def test_lookups(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -96,7 +95,7 @@ def test_lookups(self): def test_lookups_upper_case_by_lower_case(self): type_ = "_test-SRVC-type._tcp.local." name = "Xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( @@ -115,7 +114,7 @@ def test_lookups_upper_case_by_lower_case(self): def test_lookups_lower_case_by_upper_case(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" desc = {'path': '/~paulsm/'} info = ServiceInfo( diff --git a/tests/services/test_types.py b/tests/services/test_types.py index b1c312db6..33940434a 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for zeroconf._services.types.""" @@ -36,7 +35,7 @@ def test_integration_with_listener(self): type_ = "_test-listen-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} @@ -72,7 +71,7 @@ def test_integration_with_listener_v6_records(self): type_ = "_test-listenv6rec-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) @@ -109,7 +108,7 @@ def test_integration_with_listener_ipv6(self): type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" - registration_name = "%s.%s" % (name, type_) + registration_name = f"{name}.{type_}" addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) @@ -145,8 +144,8 @@ def test_integration_with_subtype_and_listener(self): type_ = "_listen._tcp.local." name = "xxxyyy" # Note: discovery returns only DNS-SD type not subtype - discovery_type = "%s.%s" % (subtype_, type_) - registration_name = "%s.%s" % (name, type_) + discovery_type = f"{subtype_}.{type_}" + registration_name = f"{name}.{type_}" zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) desc = {'path': '/~paulsm/'} diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index c82c6fd8b..2530fcf4d 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for zeroconf._utils.asyncio.""" diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 6f8b417d9..e9b781adc 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for zeroconf._utils.name.""" diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 3d75bbc4e..367bd93b3 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Unit tests for zeroconf._utils.net.""" @@ -87,7 +86,7 @@ def test_normalize_interface_choice_errors(): def test_add_multicast_member_socket_errors(errno, expected_result): """Test we handle socket errors when adding multicast members.""" if errno: - setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, "Error: {}".format(errno))) + setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, f"Error: {errno}")) else: setsockopt_mock = unittest.mock.Mock() fileno_mock = unittest.mock.PropertyMock(return_value=10) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index dd5bf4f35..83716331f 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -38,7 +38,6 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.asyncio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( @@ -145,11 +144,11 @@ def generate_service_query( for type_ in types_: question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) question.unicast = qu_question - known_answers = set( + known_answers = { cast(DNSPointer, record) for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) - ) + } if not qu_question and zc.question_history.suppresses( question, now, cast(Set[DNSRecord], known_answers) ): @@ -293,7 +292,7 @@ def __init__( self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) - self.queue: Optional[queue.Queue] = None + self.queue: Optional[queue.SimpleQueue] = None self.done = False self._first_request: bool = True self._next_send_timer: Optional[asyncio.TimerHandle] = None @@ -511,11 +510,11 @@ def __init__( # Add the queue before the listener is installed in _setup # to ensure that events run in the dedicated thread and do # not block the event loop - self.queue = get_best_available_queue() + self.queue = queue.SimpleQueue() self.daemon = True self.start() zc.loop.call_soon_threadsafe(self._async_start) - self.name = "zeroconf-ServiceBrowser-%s-%s" % ( + self.name = "zeroconf-ServiceBrowser-{}-{}".format( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index 37b7865a8..358698ca9 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -23,8 +23,7 @@ import asyncio import concurrent.futures import contextlib -import queue -from typing import Any, Awaitable, Coroutine, List, Optional, Set, cast +from typing import Any, Awaitable, Coroutine, Optional, Set from .time import millis_to_seconds from .._exceptions import EventLoopBlocked @@ -36,13 +35,6 @@ _WAIT_FOR_LOOP_TASKS_TIMEOUT = 3 # Must be larger than _TASK_AWAIT_TIMEOUT -def get_best_available_queue() -> queue.Queue: - """Create the best available queue type.""" - if hasattr(queue, "SimpleQueue"): - return queue.SimpleQueue() # type: ignore # pylint: disable=all - return queue.Queue() - - # Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: """Wait for an event or timeout.""" @@ -67,7 +59,7 @@ def _handle_timeout_or_wait_complete(*_: Any) -> None: await event_wait -async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio.Task]: +async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: """Return all tasks running.""" await asyncio.sleep(0) # flush out any call_soon_threadsafe # If there are multiple event loops running, all_tasks is not @@ -75,10 +67,8 @@ async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> List[asyncio. # under PyPy so we have to try a few times. for _ in range(3): with contextlib.suppress(RuntimeError): - if hasattr(asyncio, 'all_tasks'): - return asyncio.all_tasks(loop) # type: ignore # pylint: disable=no-member - return asyncio.Task.all_tasks(loop) # type: ignore # pylint: disable=no-member - return [] + return asyncio.all_tasks(loop) + return set() async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: @@ -116,7 +106,7 @@ def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: pending_tasks = set( asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result(_GET_ALL_TASKS_TIMEOUT) ) - pending_tasks -= set(task for task in pending_tasks if task.done()) + pending_tasks -= {task for task in pending_tasks if task.done()} if pending_tasks: asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result( _WAIT_FOR_LOOP_TASKS_TIMEOUT @@ -128,10 +118,5 @@ def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: def get_running_loop() -> Optional[asyncio.AbstractEventLoop]: """Check if an event loop is already running.""" with contextlib.suppress(RuntimeError): - if hasattr(asyncio, "get_running_loop"): - return cast( - asyncio.AbstractEventLoop, - asyncio.get_running_loop(), # type: ignore # pylint: disable=no-member # noqa - ) - return asyncio._get_running_loop() # pylint: disable=no-member,protected-access + return asyncio.get_running_loop() return None diff --git a/zeroconf/_utils/name.py b/zeroconf/_utils/name.py index c59ac33ad..f0c34e5d1 100644 --- a/zeroconf/_utils/name.py +++ b/zeroconf/_utils/name.py @@ -92,7 +92,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis trailer = type_[-len(_LOCAL_TRAILER) + 1 :] has_protocol = False else: - raise BadTypeInNameException("Type '%s' must end with '%s'" % (type_, _LOCAL_TRAILER)) + raise BadTypeInNameException(f"Type '{type_}' must end with '{_LOCAL_TRAILER}'") if strict or has_protocol: service_name = remaining.pop() diff --git a/zeroconf/_utils/net.py b/zeroconf/_utils/net.py index 0f5ad4111..c361a8e7b 100644 --- a/zeroconf/_utils/net.py +++ b/zeroconf/_utils/net.py @@ -71,14 +71,14 @@ def _encode_address(address: str) -> bytes: def get_all_addresses() -> List[str]: - return list(set(addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4)) + return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( - set((addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6) + {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} ) @@ -203,7 +203,7 @@ def set_mdns_port_socket_options_for_ip_version( try: s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) - except socket.error as e: + except OSError as e: if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS raise @@ -286,7 +286,7 @@ def add_multicast_member( else: _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface)) listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) - except socket.error as e: + except OSError as e: _errno = get_errno(e) if _errno == errno.EADDRINUSE: log.info( From 22ed08c7e5403a788b1c177a1bb9558419bce2b1 Mon Sep 17 00:00:00 2001 From: Christian Glombek Date: Fri, 24 Dec 2021 00:30:13 +0100 Subject: [PATCH 0689/1433] Add tests for instance names containing dot(s) (#1039) Co-authored-by: J. Nick Koston --- tests/test_exceptions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 6b682093a..28a63ce8c 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -76,6 +76,8 @@ def test_bad_local_names_for_get_service_info(self): def test_good_instance_names(self): assert r.service_type_name('.._x._tcp.local.') == '_x._tcp.local.' + assert r.service_type_name('x.y._http._tcp.local.') == '_http._tcp.local.' + assert r.service_type_name('1.2.3._mqtt._tcp.local.') == '_mqtt._tcp.local.' assert r.service_type_name('x.sub._http._tcp.local.') == '_http._tcp.local.' assert ( r.service_type_name('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') From a4d619a9f094682d9dcfc7f8fa293f17bcae88f2 Mon Sep 17 00:00:00 2001 From: apworks1 <83057857+apworks1@users.noreply.github.com> Date: Fri, 24 Dec 2021 01:32:18 +0100 Subject: [PATCH 0690/1433] Handle Service types that end with another service type (#1041) Co-authored-by: J. Nick Koston --- tests/services/test_browser.py | 83 ++++++++++++++++++++++++++++++++++ tests/test_services.py | 36 +++++++++++---- zeroconf/_handlers.py | 12 ++--- zeroconf/_services/browser.py | 43 +++++++++--------- 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 120aadb23..b264a2f44 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1017,3 +1017,86 @@ async def test_query_scheduler(): assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() assert set(query_scheduler.process_ready_types(now + delay * 31)) == {"_http._tcp.local."} + + +def test_service_browser_matching(): + """Test that the ServiceBrowser matching does not match partial names.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + not_match_type_ = "_asustor-looksgood_http._tcp.local." + not_match_registration_name = "xxxyyy.%s" % not_match_type_ + callbacks = [] + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + should_not_match = ServiceInfo( + not_match_type_, not_match_registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + _inject_response( + zc, + mock_incoming_msg( + [ + should_not_match.dns_pointer(), + should_not_match.dns_service(), + should_not_match.dns_text(), + *should_not_match.dns_addresses(), + ] + ), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + should_not_match.port = 400 + _inject_response( + zc, + mock_incoming_msg([should_not_match.dns_service()]), + ) + time.sleep(0.2) + + assert callbacks == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ] + browser.cancel() + + zc.close() diff --git a/tests/test_services.py b/tests/test_services.py index 7994cbdc5..f1499eae8 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -38,10 +38,11 @@ def teardown_module(): class ListenerTest(unittest.TestCase): def test_integration_with_listener_class(self): + sub_service_added = Event() service_added = Event() service_removed = Event() - service_updated = Event() - service_updated2 = Event() + sub_service_updated = Event() + duplicate_service_added = Event() subtype_name = "My special Subtype" type_ = "_http._tcp.local." @@ -58,21 +59,32 @@ def remove_service(self, zeroconf, type, name): service_removed.set() def update_service(self, zeroconf, type, name): - service_updated2.set() + pass + + class DuplicateListener(r.ServiceListener): + def add_service(self, zeroconf, type, name): + duplicate_service_added.set() + + def remove_service(self, zeroconf, type, name): + pass + + def update_service(self, zeroconf, type, name): + pass class MySubListener(r.ServiceListener): def add_service(self, zeroconf, type, name): + sub_service_added.set() pass def remove_service(self, zeroconf, type, name): pass def update_service(self, zeroconf, type, name): - service_updated.set() + sub_service_updated.set() listener = MyListener() zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - zeroconf_browser.add_service_listener(subtype, listener) + zeroconf_browser.add_service_listener(type_, listener) properties = dict( prop_none=None, @@ -107,6 +119,11 @@ def update_service(self, zeroconf, type, name): # short pause to allow multicast timers to expire time.sleep(3) + zeroconf_browser.add_service_listener(type_, DuplicateListener()) + duplicate_service_added.wait( + 1 + ) # Ensure a listener for the same type calls back right away from cache + # clear the answer cache to force query _clear_cache(zeroconf_browser) @@ -160,7 +177,9 @@ def update_service(self, zeroconf, type, name): # test TXT record update sublistener = MySubListener() - zeroconf_browser.add_service_listener(registration_name, sublistener) + + zeroconf_browser.add_service_listener(subtype, sublistener) + properties['prop_blank'] = b'an updated string' desc.update(properties) info_service = ServiceInfo( @@ -174,8 +193,9 @@ def update_service(self, zeroconf, type, name): addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_registrar.update_service(info_service) - service_updated.wait(1) - assert service_updated.is_set() + + sub_service_added.wait(1) # we cleared the cache above + assert sub_service_added.is_set() info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index ee756f75e..3ffd5e18e 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -515,12 +515,12 @@ def _async_update_matching_records( This function must be run from the event loop. """ now = current_time_millis() - records: List[RecordUpdate] = [] - for question in questions: - for record in self.cache.async_entries_with_name(question.name): - if not record.is_expired(now) and question.answered_by(record): - records.append(RecordUpdate(record, None)) - + records: List[RecordUpdate] = [ + RecordUpdate(record, None) + for question in questions + for record in self.cache.async_entries_with_name(question.name) + if not record.is_expired(now) and question.answered_by(record) + ] if not records: return listener.async_update_records(self.zc, now, records) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 83716331f..f3a4af1fd 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -26,7 +26,7 @@ import threading import warnings from collections import OrderedDict -from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import Callable, Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log @@ -324,9 +324,9 @@ def _async_start(self) -> None: def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface - def _record_matching_type(self, record: DNSRecord) -> Optional[str]: - """Return the type if the record matches one of the types we are browsing.""" - return next((type_ for type_ in self.types if record.name.endswith(type_)), None) + def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: + """Return the type and name for records matching the types we are browsing.""" + return [(type_, name) for type_ in self.types for name in names if name.endswith(f".{type_}")] def _enqueue_callback( self, @@ -352,14 +352,18 @@ def _async_process_record_update( ) -> None: """Process a single record update from a batch of updates.""" if isinstance(record, DNSPointer): - if record.name not in self.types: - return - if old_record is None: - self._enqueue_callback(ServiceStateChange.Added, record.name, record.alias) - elif record.is_expired(now): - self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias) - else: - self.reschedule_type(record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) + name = record.name + alias = record.alias + matches = self._names_matching_types((alias,)) + if name in self.types: + matches.append((name, alias)) + for type_, name in matches: + if old_record is None: + self._enqueue_callback(ServiceStateChange.Added, type_, name) + elif record.is_expired(now): + self._enqueue_callback(ServiceStateChange.Removed, type_, name) + else: + self.reschedule_type(type_, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) return # If its expired or already exists in the cache it cannot be updated. @@ -368,17 +372,14 @@ def _async_process_record_update( if isinstance(record, DNSAddress): # Iterate through the DNSCache and callback any services that use this address - for service in self.zc.cache.async_entries_with_server(record.name): - type_ = self._record_matching_type(service) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, service.name) - break - + for type_, name in self._names_matching_types( + {service.name for service in self.zc.cache.async_entries_with_server(record.name)} + ): + self._enqueue_callback(ServiceStateChange.Updated, type_, name) return - type_ = self._record_matching_type(record) - if type_: - self._enqueue_callback(ServiceStateChange.Updated, type_, record.name) + for type_, name in self._names_matching_types((record.name,)): + self._enqueue_callback(ServiceStateChange.Updated, type_, name) def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. From de1420213cd7e3bd8f57e727ff1031c7b10cf7a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 14:47:25 -1000 Subject: [PATCH 0691/1433] Update changelog for 0.38.0 (#1042) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index d76ecaf75..934e2edd9 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,15 @@ See examples directory for more. Changelog ========= +0.38.0 +====== + +* Handle Service types that end with another service type (#1041) @apworks1 + +Backwards incompatible: + +* Dropped Python 3.6 support (#1009) @bdraco + 0.37.0 ====== From 95ee5dc031c9c512f99536186d1d89a99e4af37f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 18:50:15 -0600 Subject: [PATCH 0692/1433] =?UTF-8?q?Bump=20version:=200.37.0=20=E2=86=92?= =?UTF-8?q?=200.38.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 07c7c46f9..7021a43c0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.37.0 +current_version = 0.38.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 83146ee51..8047d75c4 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.37.0' +__version__ = '0.38.0' __license__ = 'LGPL' From 27e50ff95625d128f71864138b8e5d871503adf0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 15:33:11 -1000 Subject: [PATCH 0693/1433] Improve performance of query scheduler (#1043) --- tests/test_asyncio.py | 2 +- zeroconf/_handlers.py | 2 +- zeroconf/_services/browser.py | 29 ++++++++++++++--------------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 0157f06c2..a41000891 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -923,7 +923,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 now = _new_current_time_millis() - browser.reschedule_type(type_, now) + browser.reschedule_type(type_, now, now) sleep_count += 1 await asyncio.wait_for(got_query.wait(), 1) got_query.clear() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 3ffd5e18e..d40aeb911 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -326,7 +326,7 @@ def _answer_question( self._add_address_answers(question.name, answer_set, known_answers, now, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): - service = self.registry.async_get_info_name(question.name) # type: ignore + service = self.registry.async_get_info_name(question.name) if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index f3a4af1fd..12c19a1da 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -363,7 +363,7 @@ def _async_process_record_update( elif record.is_expired(now): self._enqueue_callback(ServiceStateChange.Removed, type_, name) else: - self.reschedule_type(type_, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) + self.reschedule_type(type_, now, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) return # If its expired or already exists in the cache it cannot be updated. @@ -431,9 +431,8 @@ def _async_cancel(self) -> None: self._cancel_send_timer() self.zc.async_remove_listener(self) - def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: + def _generate_ready_queries(self, first_request: bool, now: float) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" - now = current_time_millis() ready_types = self.query_scheduler.process_ready_types(now) if not ready_types: return [] @@ -448,40 +447,40 @@ def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]: async def _async_start_query_sender(self) -> None: """Start scheduling queries.""" await self.zc.async_wait_for_start() - self._async_send_ready_queries() - self._async_schedule_next() + self._async_send_ready_queries_schedule_next() def _cancel_send_timer(self) -> None: """Cancel the next send.""" if self._next_send_timer: self._next_send_timer.cancel() - def reschedule_type(self, type_: str, next_time: float) -> None: + def reschedule_type(self, type_: str, now: float, next_time: float) -> None: """Reschedule a type to be refreshed in the future.""" if self.query_scheduler.reschedule_type(type_, next_time): self._cancel_send_timer() - self._async_schedule_next() - self._async_send_ready_queries() + self._async_schedule_next(now) + self._async_send_ready_queries(now) - def _async_send_ready_queries(self) -> None: + def _async_send_ready_queries(self, now: float) -> None: """Send any ready queries.""" - outs = self._generate_ready_queries(self._first_request) + outs = self._generate_ready_queries(self._first_request, now) if outs: self._first_request = False for out in outs: self.zc.async_send(out, addr=self.addr, port=self.port) def _async_send_ready_queries_schedule_next(self) -> None: - """Send ready queries and schedule next one.""" + """Send ready queries and schedule next one checking for done first.""" if self.done or self.zc.done: return - self._async_send_ready_queries() - self._async_schedule_next() + now = current_time_millis() + self._async_send_ready_queries(now) + self._async_schedule_next(now) - def _async_schedule_next(self) -> None: + def _async_schedule_next(self, now: float) -> None: """Scheule the next time.""" assert self.zc.loop is not None - delay = millis_to_seconds(self.query_scheduler.millis_to_wait(current_time_millis())) + delay = millis_to_seconds(self.query_scheduler.millis_to_wait(now)) self._next_send_timer = self.zc.loop.call_later(delay, self._async_send_ready_queries_schedule_next) From ff766345461a82547abe462b5d690621c755d480 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 16:38:15 -1000 Subject: [PATCH 0694/1433] Avoid linear type searches in ServiceBrowsers (#1044) --- tests/test_services.py | 2 +- tests/utils/test_name.py | 22 ++++++++++++++++++++++ zeroconf/_services/browser.py | 15 +++++---------- zeroconf/_utils/name.py | 15 +++++++++++++++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index f1499eae8..2730811e4 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -44,7 +44,7 @@ def test_integration_with_listener_class(self): sub_service_updated = Event() duplicate_service_added = Event() - subtype_name = "My special Subtype" + subtype_name = "_printer" type_ = "_http._tcp.local." subtype = subtype_name + "._sub." + type_ name = "UPPERxxxyyyæøå" diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index e9b781adc..07feccb75 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -23,3 +23,25 @@ def test_service_type_name_overlong_full_name(): nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.") with pytest.raises(BadTypeInNameException): nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.", strict=False) + + +def test_possible_types(): + """Test possible types from name.""" + assert nameutils.possible_types('.') == set() + assert nameutils.possible_types('local.') == set() + assert nameutils.possible_types('_tcp.local.') == set() + assert nameutils.possible_types('_test-srvc-type._tcp.local.') == {'_test-srvc-type._tcp.local.'} + assert nameutils.possible_types('_any._tcp.local.') == {'_any._tcp.local.'} + assert nameutils.possible_types('.._x._tcp.local.') == {'_x._tcp.local.'} + assert nameutils.possible_types('x.y._http._tcp.local.') == {'_http._tcp.local.'} + assert nameutils.possible_types('1.2.3._mqtt._tcp.local.') == {'_mqtt._tcp.local.'} + assert nameutils.possible_types('x.sub._http._tcp.local.') == {'_http._tcp.local.'} + assert nameutils.possible_types('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') == { + '_http._tcp.local.', + '_zget._http._tcp.local.', + } + assert nameutils.possible_types('my._printer._sub._http._tcp.local.') == { + '_http._tcp.local.', + '_sub._http._tcp.local.', + '_printer._sub._http._tcp.local.', + } diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 12c19a1da..bbe5a0562 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -38,7 +38,7 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.name import service_type_name +from .._utils.name import possible_types, service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, @@ -326,7 +326,7 @@ def service_state_changed(self) -> SignalRegistrationInterface: def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: """Return the type and name for records matching the types we are browsing.""" - return [(type_, name) for type_ in self.types for name in names if name.endswith(f".{type_}")] + return [(type_, name) for name in names for type_ in self.types.intersection(possible_types(name))] def _enqueue_callback( self, @@ -352,16 +352,11 @@ def _async_process_record_update( ) -> None: """Process a single record update from a batch of updates.""" if isinstance(record, DNSPointer): - name = record.name - alias = record.alias - matches = self._names_matching_types((alias,)) - if name in self.types: - matches.append((name, alias)) - for type_, name in matches: + for type_ in self.types.intersection(possible_types(record.name)): if old_record is None: - self._enqueue_callback(ServiceStateChange.Added, type_, name) + self._enqueue_callback(ServiceStateChange.Added, type_, record.alias) elif record.is_expired(now): - self._enqueue_callback(ServiceStateChange.Removed, type_, name) + self._enqueue_callback(ServiceStateChange.Removed, type_, record.alias) else: self.reschedule_type(type_, now, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) return diff --git a/zeroconf/_utils/name.py b/zeroconf/_utils/name.py index f0c34e5d1..367dfb18a 100644 --- a/zeroconf/_utils/name.py +++ b/zeroconf/_utils/name.py @@ -20,6 +20,8 @@ USA """ +from typing import Set + from .._exceptions import BadTypeInNameException from ..const import ( _HAS_ASCII_CONTROL_CHARS, @@ -155,3 +157,16 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis ) return service_name + trailer + + +def possible_types(name: str) -> Set[str]: + """Build a set of all possible types from a fully qualified name.""" + labels = name.split('.') + label_count = len(labels) + types = set() + for count in range(label_count): + parts = labels[label_count - count - 4 :] + if not parts[0].startswith('_'): + break + types.add('.'.join(parts)) + return types From 670d4ac3be7e32d02afe85b72264a241b5a25ba8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 16:40:06 -1000 Subject: [PATCH 0695/1433] Update changelog for 0.38.1 (#1045) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 934e2edd9..df4680ad2 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,12 @@ See examples directory for more. Changelog ========= +0.38.1 +====== + +* Improve performance of query scheduler (#1043) @bdraco +* Avoid linear type searches in ServiceBrowsers (#1044) @bdraco + 0.38.0 ====== From 6a11f24e1fc9d73f0dbb62efd834f17a9bd451c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Dec 2021 20:47:53 -0600 Subject: [PATCH 0696/1433] =?UTF-8?q?Bump=20version:=200.38.0=20=E2=86=92?= =?UTF-8?q?=200.38.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7021a43c0..8641adba1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.0 +current_version = 0.38.1 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 8047d75c4..030b22264 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.0' +__version__ = '0.38.1' __license__ = 'LGPL' From 25e6123a07a9560e978a04d5e285bfa74ee41e64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Jan 2022 17:17:21 -0600 Subject: [PATCH 0697/1433] Make decode errors more helpful in finding the source of the bad data (#1052) --- zeroconf/_protocol/incoming.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index c25c7a531..1aec290d1 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -275,7 +275,9 @@ def read_name(self) -> str: self.offset = self._decode_labels_at_offset(self.offset, labels, seen_pointers) name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: - raise IncomingDecodeError(f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH}") + raise IncomingDecodeError( + f"DNS name {name} exceeds maximum length of {MAX_NAME_LENGTH} from {self.source}" + ) return name def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: @@ -292,16 +294,24 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S continue if length < 0xC0: - raise IncomingDecodeError(f"DNS compression type {length} is unknown at {off}") + raise IncomingDecodeError( + f"DNS compression type {length} is unknown at {off} from {self.source}" + ) # We have a DNS compression pointer link = (length & 0x3F) * 256 + self.data[off + 1] if link > self.data_len: - raise IncomingDecodeError(f"DNS compression pointer at {off} points to {link} beyond packet") + raise IncomingDecodeError( + f"DNS compression pointer at {off} points to {link} beyond packet from {self.source}" + ) if link == off: - raise IncomingDecodeError(f"DNS compression pointer at {off} points to itself") + raise IncomingDecodeError( + f"DNS compression pointer at {off} points to itself from {self.source}" + ) if link in seen_pointers: - raise IncomingDecodeError(f"DNS compression pointer at {off} was seen again") + raise IncomingDecodeError( + f"DNS compression pointer at {off} was seen again from {self.source}" + ) linked_labels = self.name_cache.get(link, []) if not linked_labels: seen_pointers.add(link) @@ -309,7 +319,9 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S self.name_cache[link] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: - raise IncomingDecodeError(f"Maximum dns labels reached while processing pointer at {off}") + raise IncomingDecodeError( + f"Maximum dns labels reached while processing pointer at {off} from {self.source}" + ) return off + DNS_COMPRESSION_POINTER_LEN - raise IncomingDecodeError("Corrupt packet received while decoding name") + raise IncomingDecodeError("Corrupt packet received while decoding name from {self.source}") From 50cd12d8c2ced166da8f4852120ba8a28b13cba0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Jan 2022 17:17:54 -0600 Subject: [PATCH 0698/1433] =?UTF-8?q?Bump=20version:=200.38.1=20=E2=86=92?= =?UTF-8?q?=200.38.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8641adba1..701e57880 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.1 +current_version = 0.38.2 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 030b22264..cb56c47fd 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.1' +__version__ = '0.38.2' __license__ = 'LGPL' From d99c7ffea37fd27c315115133dab08445aa417d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Jan 2022 17:23:23 -0600 Subject: [PATCH 0699/1433] Update changelog for 0.38.2/3 (#1053) --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index df4680ad2..c33308918 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,16 @@ See examples directory for more. Changelog ========= +0.38.3 +====== + +Version bump only, no changes from 0.38.2 + +0.38.2 +====== + +* Make decode errors more helpful in finding the source of the bad data (#1052) @bdraco + 0.38.1 ====== From e42549cb70796d0577c97be96a09bca0056a5755 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Jan 2022 17:23:46 -0600 Subject: [PATCH 0700/1433] =?UTF-8?q?Bump=20version:=200.38.2=20=E2=86=92?= =?UTF-8?q?=200.38.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 701e57880..80e22c134 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.2 +current_version = 0.38.3 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index cb56c47fd..b7b956e48 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.2' +__version__ = '0.38.3' __license__ = 'LGPL' From 79d067b88f9108259a44f33801e26bd3a25ca759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Feb 2022 13:27:35 -1000 Subject: [PATCH 0701/1433] Fix IP Address updates when hostname is uppercase (#1057) --- tests/test_asyncio.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_dns.py | 11 +++++++++++ zeroconf/_cache.py | 4 ++-- zeroconf/_dns.py | 3 ++- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index a41000891..db27c9adb 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1132,3 +1132,42 @@ async def test_legacy_unicast_response(run_isolated): assert outgoing.questions == [question] assert outgoing.id == query.id await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_update_with_uppercase_names(run_isolated): + """Test an ip update from a shelly which uses uppercase names.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + + callbacks = [] + + class MyServiceListener(ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + browser = AsyncServiceBrowser(aiozc.zeroconf, "_http._tcp.local.", None, listener) + protocol = aiozc.zeroconf.engine.protocols[0] + + packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbc=\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' + protocol.datagram_received(packet, ('127.0.0.1', 6503)) + await asyncio.sleep(0) + packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbcA\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' + protocol.datagram_received(packet, ('127.0.0.1', 6503)) + + await aiozc.async_close() + + assert callbacks == [ + ('add', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), + ('update', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), + ] diff --git a/tests/test_dns.py b/tests/test_dns.py index c26692058..7de7fa99e 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -334,6 +334,17 @@ def test_dns_service_record_hashablity(): assert len(record_set) == 4 +def test_dns_service_server_key(): + """Test DNSService server_key is lowercase.""" + srv1 = r.DNSService( + 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'X.local.' + ) + assert srv1.name == 'X._tcp._http.local.' + assert srv1.key == 'x._tcp._http.local.' + assert srv1.server == 'X.local.' + assert srv1.server_key == 'x.local.' + + def test_dns_nsec_record_hashablity(): """Test DNSNsec are hashable.""" nsec1 = r.DNSNsec( diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index 24b6a2337..ddb59aac6 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -74,7 +74,7 @@ def _async_add(self, entry: DNSRecord) -> None: # direction would return the old incorrect entry. self.cache.setdefault(entry.key, {})[entry] = entry if isinstance(entry, DNSService): - self.service_cache.setdefault(entry.server, {})[entry] = entry + self.service_cache.setdefault(entry.server_key, {})[entry] = entry def async_add_records(self, entries: Iterable[DNSRecord]) -> None: """Add multiple records. @@ -90,7 +90,7 @@ def _async_remove(self, entry: DNSRecord) -> None: This function must be run in from event loop. """ if isinstance(entry, DNSService): - _remove_key(self.service_cache, entry.server, entry) + _remove_key(self.service_cache, entry.server_key, entry) _remove_key(self.cache, entry.key, entry) def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index a551a7dad..a3a48594d 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -401,7 +401,7 @@ class DNSService(DNSRecord): """A DNS service record""" - __slots__ = ('_hash', 'priority', 'weight', 'port', 'server') + __slots__ = ('_hash', 'priority', 'weight', 'port', 'server', 'server_key') def __init__( self, @@ -420,6 +420,7 @@ def __init__( self.weight = weight self.port = port self.server = server + self.server_key = server.lower() self._hash = hash((self.key, type_, self.class_, priority, weight, port, server)) def write(self, out: 'DNSOutgoing') -> None: From 3736348da30ee4b7c50713936f2ae919e5446ffa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Feb 2022 13:30:14 -1000 Subject: [PATCH 0702/1433] Update changelog for 0.38.4 (#1058) --- README.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.rst b/README.rst index c33308918..248bc5113 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,15 @@ See examples directory for more. Changelog ========= +0.38.4 +====== + +* Fix IP Address updates when hostname is uppercase (#1057) @bdraco + + ServiceBrowsers would not callback updates when the ip address changed + if the hostname contained uppercase characters + + 0.38.3 ====== From 5c40e89420255b5b978bff4682b21f0820fb4682 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Feb 2022 13:31:20 -1000 Subject: [PATCH 0703/1433] =?UTF-8?q?Bump=20version:=200.38.3=20=E2=86=92?= =?UTF-8?q?=200.38.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 80e22c134..0c1f79f9a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.3 +current_version = 0.38.4 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b7b956e48..dab31c69d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.3' +__version__ = '0.38.4' __license__ = 'LGPL' From 6c451f64e7cbeaa0bb77f66790936afda2d058ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Mar 2022 10:57:57 -1000 Subject: [PATCH 0704/1433] Refactor to fix mypy error (#1061) --- zeroconf/_services/info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index ecf7c25ea..1329121fc 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -183,7 +183,7 @@ def addresses(self, value: List[bytes]) -> None: "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" f" got {address}. Hint: convert string addresses with socket.inet_pton" # type: ignore ) - if addr.version == 4: + if isinstance(addr, ipaddress.IPv4Address): self._ipv4_addresses.append(addr) else: self._ipv6_addresses.append(addr) @@ -333,7 +333,7 @@ def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) return - if ip_addr.version == 4: + if isinstance(ip_addr, ipaddress.IPv4Address): if ip_addr not in self._ipv4_addresses: self._ipv4_addresses.insert(0, ip_addr) return From e9d25f7749778979b7449464153163587583bf8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Mar 2022 11:08:13 -1000 Subject: [PATCH 0705/1433] Fix mypy error in zeroconf._service.info (#1062) --- zeroconf/_services/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 1329121fc..05bbf43a1 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -181,7 +181,7 @@ def addresses(self, value: List[bytes]) -> None: except ValueError: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" - f" got {address}. Hint: convert string addresses with socket.inet_pton" # type: ignore + f" got {address!r}. Hint: convert string addresses with socket.inet_pton" ) if isinstance(addr, ipaddress.IPv4Address): self._ipv4_addresses.append(addr) From 6e842f238b3e1f3b738ed058e0fa4068115f041b Mon Sep 17 00:00:00 2001 From: Steven Crader Date: Sat, 26 Mar 2022 14:17:17 -0700 Subject: [PATCH 0706/1433] Force minimum version of 3.7 and update example (#1060) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 2 +- README.rst | 17 ++++++++++------- setup.py | 5 +++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1181cac70..23dc86877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} architecture: 'x64' - uses: actions/cache@v2 id: cache diff --git a/README.rst b/README.rst index 248bc5113..7286c131f 100644 --- a/README.rst +++ b/README.rst @@ -99,17 +99,20 @@ Here's an example of browsing for a service: .. code-block:: python - from zeroconf import ServiceBrowser, Zeroconf + from zeroconf import ServiceBrowser, ServiceListener, Zeroconf - class MyListener: + class MyListener(ServiceListener): - def remove_service(self, zeroconf, type, name): - print("Service %s removed" % (name,)) + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name} updated") - def add_service(self, zeroconf, type, name): - info = zeroconf.get_service_info(type, name) - print("Service %s added, service info: %s" % (name, info)) + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name} removed") + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + info = zc.get_service_info(type_, name) + print(f"Service {name} added, service info: {info}") zeroconf = Zeroconf() diff --git a/setup.py b/setup.py index 41e74842c..2513bd365 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ platforms=['unix', 'linux', 'osx'], license='LGPL', zip_safe=False, + python_requires='>=3.7', classifiers=[ 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', @@ -36,10 +37,10 @@ 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], From 31662b7a0bba65bea1fbfc09c70cd2970160c5c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 May 2022 09:04:59 -0500 Subject: [PATCH 0707/1433] Fix ci trying to run mypy on pypy (#1065) --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23dc86877..19d16be66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,22 +43,22 @@ jobs: ${{ matrix.venvcmd }} pip install --upgrade -r requirements-dev.txt pytest-github-actions-annotate-failures - name: Validate readme - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} run: | ${{ matrix.venvcmd }} python -m readme_renderer README.rst -o - - name: Run flake8 - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} run: | ${{ matrix.venvcmd }} make flake8 - name: Run mypy - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} run: | ${{ matrix.venvcmd }} make mypy - name: Run black_check - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy3' }} + if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} run: | ${{ matrix.venvcmd }} make black_check From 10ee2053a80f7c7221b4fa1475d66b01abd21b11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 May 2022 11:43:32 -0500 Subject: [PATCH 0708/1433] Fix ServiceBrowsers not getting `ServiceStateChange.Removed` callbacks on PTR record expire (#1064) --- tests/services/test_browser.py | 73 +++++++++++++++++++++++++++++++++- zeroconf/_core.py | 2 +- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index b264a2f44..68b324b82 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -3,7 +3,6 @@ """ Unit tests for zeroconf._services.browser. """ -import asyncio import logging import socket import time @@ -17,7 +16,7 @@ import zeroconf as r from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis, millis_to_seconds import zeroconf._services.browser as _services_browser -from zeroconf import Zeroconf +from zeroconf import _core, _handlers, Zeroconf from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo @@ -1100,3 +1099,73 @@ def mock_incoming_msg(records) -> r.DNSIncoming: browser.cancel() zc.close() + + +@patch.object(_handlers, '_DNS_PTR_MIN_TTL', 1) +@patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10) +def test_service_browser_expire_callbacks(): + """Test that the ServiceBrowser matching does not match partial names.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + callbacks = [] + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("add", type_, name)) + + def remove_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: + nonlocal callbacks + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + desc = {'path': '/~paulsm/'} + address_parsed = "10.0.1.2" + address = socket.inet_aton(address_parsed) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", host_ttl=1, other_ttl=1, addresses=[address] + ) + + def mock_incoming_msg(records) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + _inject_response( + zc, + mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + ) + time.sleep(0.2) + info.port = 400 + _inject_response( + zc, + mock_incoming_msg([info.dns_service()]), + ) + + assert callbacks == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ] + time.sleep(1.2) + assert callbacks == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] + browser.cancel() + + zc.close() diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 0c70b7afc..4b11546e8 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -167,7 +167,7 @@ def _async_cache_cleanup(self) -> None: now = current_time_millis() self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates( - now, [RecordUpdate(record, None) for record in self.zc.cache.async_expire(now)] + now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] ) self.zc.record_manager.async_updates_complete() assert self.loop is not None From ae3635b9ee73edeaabe2cbc027b8fb8bd7cd97da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 May 2022 12:00:34 -0500 Subject: [PATCH 0709/1433] Update changelog for 0.38.5 (#1066) --- README.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.rst b/README.rst index 7286c131f..319a7ae55 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,17 @@ See examples directory for more. Changelog ========= +0.38.5 +====== + +* Fix ServiceBrowsers not getting ServiceStateChange.Removed callbacks on PTR record expire (#1064) @bdraco + + ServiceBrowsers were only getting a `ServiceStateChange.Removed` callback + when the record was sent with a TTL of 0. ServiceBrowsers now correctly + get a `ServiceStateChange.Removed` callback when the record expires as well. +* Fix missing minimum version of python 3.7 (#1060) @stevencrader + + 0.38.4 ====== From 3c5538899b8974e99c9a279ce3ac46971ab5d91c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 May 2022 11:57:26 -0500 Subject: [PATCH 0710/1433] =?UTF-8?q?Bump=20version:=200.38.4=20=E2=86=92?= =?UTF-8?q?=200.38.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0c1f79f9a..acf27c892 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.4 +current_version = 0.38.5 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index dab31c69d..e393e19c1 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.4' +__version__ = '0.38.5' __license__ = 'LGPL' From f9b2816e15b0459f8051079f77b70e983769cd44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 May 2022 12:16:02 -0400 Subject: [PATCH 0711/1433] Fix CI failures (#1070) --- Makefile | 2 +- requirements-dev.txt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 88980ff21..2e8abf81e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# version: 1.1 +# version: 1.2 .PHONY: all virtualenv MAX_LINE_LENGTH=110 diff --git a/requirements-dev.txt b/requirements-dev.txt index c8c11ec73..174ca65e9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,8 +7,7 @@ flake8 flake8-import-order ifaddr mypy;implementation_name=="cpython" -# 0.11.0 breaks things https://github.com/PyCQA/pep8-naming/issues/152 -pep8-naming!=0.6.0,!=0.11.0 +pep8-naming>=0.12.0 pylint pytest pytest-asyncio From 89c9022f87d3a83cc586b153fb7d5ea3af69ae3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 May 2022 12:42:31 -0400 Subject: [PATCH 0712/1433] Use unique name in test_service_browser_expire_callbacks test (#1069) --- tests/services/test_browser.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 68b324b82..4665aa9a4 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1108,8 +1108,8 @@ def test_service_browser_expire_callbacks(): # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) # start a browser - type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + type_ = "_old._tcp.local." + registration_name = "uniquezip323.%s" % type_ callbacks = [] class MyServiceListener(r.ServiceListener): @@ -1132,11 +1132,11 @@ def update_service(self, zc, type_, name) -> None: browser = r.ServiceBrowser(zc, type_, None, listener) - desc = {'path': '/~paulsm/'} - address_parsed = "10.0.1.2" + desc = {'path': '/~paul2/'} + address_parsed = "10.0.1.3" address = socket.inet_aton(address_parsed) info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", host_ttl=1, other_ttl=1, addresses=[address] + type_, registration_name, 80, 0, 0, desc, "newname-2.local.", host_ttl=1, other_ttl=1, addresses=[address] ) def mock_incoming_msg(records) -> r.DNSIncoming: @@ -1149,7 +1149,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), ) - time.sleep(0.2) + time.sleep(0.3) info.port = 400 _inject_response( zc, @@ -1160,7 +1160,7 @@ def mock_incoming_msg(records) -> r.DNSIncoming: ('add', type_, registration_name), ('update', type_, registration_name), ] - time.sleep(1.2) + time.sleep(1.1) assert callbacks == [ ('add', type_, registration_name), ('update', type_, registration_name), From 5fb0954cf2c6040704c3db1d2b0fece389425e5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 May 2022 11:33:19 -0400 Subject: [PATCH 0713/1433] Remove left-in debug print (#1071) --- zeroconf/_logger.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index b7bb65de6..930c867e9 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -56,9 +56,6 @@ def log_exception_debug(cls, *logger_data: Any) -> None: log_exc_info = False exc_info = sys.exc_info() exc_str = str(exc_info[1]) - import pprint - - pprint.pprint(cls._seen_logs) if exc_str not in cls._seen_logs: # log the trace only on the first time cls._seen_logs[exc_str] = exc_info From 59624a6cfb1839b2654a6021a7317a1bdad179e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 May 2022 11:56:20 -0500 Subject: [PATCH 0714/1433] Avoid waking up ServiceInfo listeners when there is no new data (#1068) --- tests/services/test_browser.py | 11 +++++- tests/services/test_info.py | 67 ++++++++++++++++++++++++++++++++++ zeroconf/_cache.py | 19 ++++++++-- zeroconf/_core.py | 9 ++++- zeroconf/_handlers.py | 10 +++-- zeroconf/_logger.py | 1 + zeroconf/_services/browser.py | 3 +- zeroconf/_services/info.py | 3 +- 8 files changed, 111 insertions(+), 12 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 4665aa9a4..bdc734fcd 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1136,7 +1136,16 @@ def update_service(self, zc, type_, name) -> None: address_parsed = "10.0.1.3" address = socket.inet_aton(address_parsed) info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "newname-2.local.", host_ttl=1, other_ttl=1, addresses=[address] + type_, + registration_name, + 80, + 0, + 0, + desc, + "newname-2.local.", + host_ttl=1, + other_ttl=1, + addresses=[address], ) def mock_incoming_msg(records) -> r.DNSIncoming: diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 39420109c..b8f437d4e 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -3,6 +3,7 @@ """ Unit tests for zeroconf._services.info. """ +import asyncio import logging import socket import threading @@ -16,6 +17,7 @@ import zeroconf as r from zeroconf import DNSAddress, const +from zeroconf._services import types from zeroconf._services.info import ServiceInfo from zeroconf.asyncio import AsyncZeroconf @@ -798,3 +800,68 @@ def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): await aiozc.async_close() assert request_count == 4 + + +@pytest.mark.asyncio +async def test_release_wait_when_new_recorded_added(): + """Test that async_request returns as soon as new matching records are added to the cache.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + task = asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200)) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + assert await asyncio.wait_for(task, timeout=2) + assert info.addresses == [b'\x7f\x00\x00\x01'] + await aiozc.async_close() diff --git a/zeroconf/_cache.py b/zeroconf/_cache.py index ddb59aac6..cb485eaf6 100644 --- a/zeroconf/_cache.py +++ b/zeroconf/_cache.py @@ -27,6 +27,7 @@ DNSAddress, DNSEntry, DNSHinfo, + DNSNsec, DNSPointer, DNSRecord, DNSService, @@ -61,9 +62,11 @@ def __init__(self) -> None: # Functions prefixed with async_ are NOT threadsafe and must # be run in the event loop. - def _async_add(self, entry: DNSRecord) -> None: + def _async_add(self, entry: DNSRecord) -> bool: """Adds an entry. + Returns true if the entry was not already in the cache. + This function must be run in from event loop. """ # Previously storage of records was implemented as a list @@ -72,17 +75,25 @@ def _async_add(self, entry: DNSRecord) -> None: # replaces any existing records that are __eq__ to each other which # removes the risk that accessing the cache from the wrong # direction would return the old incorrect entry. - self.cache.setdefault(entry.key, {})[entry] = entry + store = self.cache.setdefault(entry.key, {}) + new = entry not in store and not isinstance(entry, DNSNsec) + store[entry] = entry if isinstance(entry, DNSService): self.service_cache.setdefault(entry.server_key, {})[entry] = entry + return new - def async_add_records(self, entries: Iterable[DNSRecord]) -> None: + def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: """Add multiple records. + Returns true if any of the records were not in the cache. + This function must be run in from event loop. """ + new = False for entry in entries: - self._async_add(entry) + if self._async_add(entry): + new = True + return new def _async_remove(self, entry: DNSRecord) -> None: """Removes an entry. diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 4b11546e8..d14608e4e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -169,7 +169,7 @@ def _async_cache_cleanup(self) -> None: self.zc.record_manager.async_updates( now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] ) - self.zc.record_manager.async_updates_complete() + self.zc.record_manager.async_updates_complete(False) assert self.loop is not None self._cleanup_timer = self.loop.call_later( millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup @@ -184,6 +184,8 @@ async def _async_close(self) -> None: def _async_shutdown(self) -> None: """Shutdown transports and sockets.""" + assert self.running_event is not None + self.running_event.clear() for transport in itertools.chain(self.senders, self.readers): transport.close() @@ -466,6 +468,11 @@ def __init__( self.start() + @property + def started(self) -> bool: + """Check if the instance has started.""" + return bool(self.engine.running_event and self.engine.running_event.is_set()) + def start(self) -> None: """Start Zeroconf.""" self.loop = get_running_loop() diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index d40aeb911..0ad95c228 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -391,7 +391,7 @@ def async_updates(self, now: float, records: List[RecordUpdate]) -> None: for listener in self.listeners: listener.async_update_records(self.zc, now, records) - def async_updates_complete(self) -> None: + def async_updates_complete(self, notify: bool) -> None: """Used to notify listeners of new information that has updated a record. @@ -401,7 +401,8 @@ def async_updates_complete(self) -> None: """ for listener in self.listeners: listener.async_update_records_complete() - self.zc.async_notify_all() + if notify: + self.zc.async_notify_all() def async_updates_from_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers @@ -459,15 +460,16 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # zc.get_service_info will see the cached value # but ONLY after all the record updates have been # processsed. + new = False if other_adds or address_adds: - self.cache.async_add_records(itertools.chain(address_adds, other_adds)) + new = self.cache.async_add_records(itertools.chain(address_adds, other_adds)) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. if removes: self.cache.async_remove_records(removes) if updates: - self.async_updates_complete() + self.async_updates_complete(new) def _async_mark_unique_cached_records_older_than_1s_to_expire( self, unique_types: Set[Tuple[str, int, int]], answers: Iterable[DNSRecord], now: float diff --git a/zeroconf/_logger.py b/zeroconf/_logger.py index 930c867e9..b0e66bc90 100644 --- a/zeroconf/_logger.py +++ b/zeroconf/_logger.py @@ -1,4 +1,5 @@ """ Multicast DNS Service Discovery for Python, v0.14-wmcbrine + ) Copyright 2003 Paul Scott-Murphy, 2014 William McBrine This module provides a framework for the use of DNS Service Discovery diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index bbe5a0562..27d7c301f 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -441,7 +441,8 @@ def _generate_ready_queries(self, first_request: bool, now: float) -> List[DNSOu async def _async_start_query_sender(self) -> None: """Start scheduling queries.""" - await self.zc.async_wait_for_start() + if not self.zc.started: + await self.zc.async_wait_for_start() self._async_send_ready_queries_schedule_next() def _cancel_send_timer(self) -> None: diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 05bbf43a1..a48e74d45 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -467,6 +467,8 @@ async def async_request( """Returns true if the service could be discovered on the network, and updates this object with details discovered. """ + if not zc.started: + await zc.async_wait_for_start() if self.load_from_cache(zc): return True @@ -475,7 +477,6 @@ async def async_request( delay = _LISTENER_TIME next_ = now last = now + timeout - await zc.async_wait_for_start() try: zc.async_add_listener(self, None) while not self._is_complete: From ed02e5d92768d1fc41163f59e303a76843bfd9fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 May 2022 12:49:27 -0500 Subject: [PATCH 0715/1433] Always return `started` as False once Zeroconf has been marked as done (#1072) --- tests/test_core.py | 14 +++++++++++++- zeroconf/_core.py | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index eab769be8..af1b71d77 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -18,7 +18,7 @@ from unittest.mock import patch import zeroconf as r -from zeroconf import _core, const, Zeroconf, current_time_millis +from zeroconf import _core, const, Zeroconf, current_time_millis, NotRunningException from zeroconf.asyncio import AsyncZeroconf from zeroconf._protocol import outgoing @@ -798,3 +798,15 @@ def _background_register(): zc.close() bgthread.join() + + +@pytest.mark.asyncio +@unittest.skipIf(sys.version_info[:3][1] < 8, 'Requires Python 3.8 or later to patch _async_setup') +@patch("zeroconf._core._STARTUP_TIMEOUT", 0) +@patch("zeroconf._core.AsyncEngine._async_setup") +async def test_event_loop_blocked(mock_start): + """Test we raise NotRunningException when waiting for startup that times out.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + with pytest.raises(NotRunningException): + await aiozc.zeroconf.async_wait_for_start() + assert aiozc.zeroconf.started is False diff --git a/zeroconf/_core.py b/zeroconf/_core.py index d14608e4e..48813f7f6 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -471,7 +471,7 @@ def __init__( @property def started(self) -> bool: """Check if the instance has started.""" - return bool(self.engine.running_event and self.engine.running_event.is_set()) + return bool(not self.done and self.engine.running_event and self.engine.running_event.is_set()) def start(self) -> None: """Start Zeroconf.""" From dfd3222405f0123a849d376d8be466be46bdb557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 May 2022 14:14:19 -0500 Subject: [PATCH 0716/1433] Update changelog for 0.38.6 (#1073) --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 319a7ae55..4ea76db52 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,13 @@ See examples directory for more. Changelog ========= + +0.38.6 +====== + +* Performance improvements for fetching ServiceInfo (#1068) @bdraco + + 0.38.5 ====== From 1aa7842ae0f914c10465ae977551698046406d55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 May 2022 14:11:56 -0500 Subject: [PATCH 0717/1433] =?UTF-8?q?Bump=20version:=200.38.5=20=E2=86=92?= =?UTF-8?q?=200.38.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index acf27c892..42a25b9e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.5 +current_version = 0.38.6 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index e393e19c1..b40aaee51 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.5' +__version__ = '0.38.6' __license__ = 'LGPL' From 533ad10121739997a4925d90792cbe9e00a5ac4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 07:38:25 -1000 Subject: [PATCH 0718/1433] Speed up unpacking incoming packet data (#1076) --- zeroconf/_protocol/incoming.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index 1aec290d1..09ecd055c 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -47,6 +47,11 @@ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) +UNPACK_3H = struct.Struct(b'!3H').unpack +UNPACK_6H = struct.Struct(b'!6H').unpack +UNPACK_HH = struct.Struct(b'!HH').unpack +UNPACK_HHiH = struct.Struct(b'!HHiH').unpack + class DNSIncoming(DNSMessage, QuietLogger): @@ -139,9 +144,9 @@ def __repr__(self) -> str: ] ) - def unpack(self, format_: bytes, length: int) -> tuple: + def unpack(self, unpacker: Callable[[bytes], tuple], length: int) -> tuple: self.offset += length - return struct.unpack(format_, self.data[self.offset - length : self.offset]) + return unpacker(self.data[self.offset - length : self.offset]) def read_header(self) -> None: """Reads header portion of packet""" @@ -152,12 +157,12 @@ def read_header(self) -> None: self.num_answers, self.num_authorities, self.num_additionals, - ) = self.unpack(b'!6H', 12) + ) = self.unpack(UNPACK_6H, 12) def read_questions(self) -> None: """Reads questions section of packet""" self.questions = [ - DNSQuestion(self.read_name(), *self.unpack(b'!HH', 4)) for _ in range(self.num_questions) + DNSQuestion(self.read_name(), *self.unpack(UNPACK_HH, 4)) for _ in range(self.num_questions) ] def read_character_string(self) -> bytes: @@ -172,10 +177,6 @@ def read_string(self, length: int) -> bytes: self.offset += length return info - def read_unsigned_short(self) -> int: - """Reads an unsigned short from the packet""" - return cast(int, self.unpack(b'!H', 2)[0]) - def read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" @@ -183,7 +184,7 @@ def read_others(self) -> None: n = self.num_answers + self.num_authorities + self.num_additionals for _ in range(n): domain = self.read_name() - type_, class_, ttl, length = self.unpack(b'!HHiH', 10) + type_, class_, ttl, length = self.unpack(UNPACK_HHiH, 10) end = self.offset + length rec = None try: @@ -218,9 +219,7 @@ def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: in type_, class_, ttl, - self.read_unsigned_short(), - self.read_unsigned_short(), - self.read_unsigned_short(), + *cast(Tuple[int, int, int], self.unpack(UNPACK_3H, 6)), self.read_name(), self.now, ) From 5f7ba0d7dc9a5a6b2cf3a321b7b2f448d4332de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 07:46:44 -1000 Subject: [PATCH 0719/1433] Update changelog for 0.38.7 (#1078) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 4ea76db52..c0de235dd 100644 --- a/README.rst +++ b/README.rst @@ -142,6 +142,12 @@ Changelog ========= +0.38.7 +====== + +* Performance improvements for parsing incoming packet data (#1076) @bdraco + + 0.38.6 ====== From f3a9f804914fec37e961f80f347c4e706c4bae33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 12:49:19 -0500 Subject: [PATCH 0720/1433] =?UTF-8?q?Bump=20version:=200.38.6=20=E2=86=92?= =?UTF-8?q?=200.38.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 42a25b9e9..3fd0b8e5b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.6 +current_version = 0.38.7 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index b40aaee51..678c8d81d 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.6' +__version__ = '0.38.7' __license__ = 'LGPL' From 88323d0c7866f78edde063080c63a72c6e875772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jun 2022 13:11:23 -0500 Subject: [PATCH 0721/1433] Update stale docstrings in AsyncZeroconf (#1079) --- Makefile | 2 +- zeroconf/asyncio.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 2e8abf81e..2d2bce124 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# version: 1.2 +# version: 1.3 .PHONY: all virtualenv MAX_LINE_LENGTH=110 diff --git a/zeroconf/asyncio.py b/zeroconf/asyncio.py index de7e97dd3..c5c9457b1 100644 --- a/zeroconf/asyncio.py +++ b/zeroconf/asyncio.py @@ -132,8 +132,9 @@ class AsyncZeroconf: Supports registration, unregistration, queries and browsing. - The async version is currently a wrapper around the sync version - with I/O being done in the executor for backwards compatibility. + The async version is currently a wrapper around Zeroconf which + is now also async. It is expected that an asyncio event loop + is already running before creating the AsyncZeroconf object. """ def __init__( @@ -145,7 +146,7 @@ def __init__( zc: Optional[Zeroconf] = None, ) -> None: """Creates an instance of the Zeroconf class, establishing - multicast communications, listening and reaping threads. + multicast communications, and listening. :param interfaces: :class:`InterfaceChoice` or a list of IP addresses (IPv4 and IPv6) and interface indexes (IPv6 only). From 7ffea9f93e758f75a0eeb9997ff8d9c9d47ec31a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 11:24:11 -1000 Subject: [PATCH 0722/1433] Replace wait_event_or_timeout internals with async_timeout (#1081) Its unlikely that https://bugs.python.org/issue39032 and https://github.com/python/cpython/issues/83213 will be fixed soon. While we moved away from an asyncio.Condition, we still has a similar problem with waiting for an asyncio.Event which wait_event_or_timeout played well with. async_timeout avoids creating a task so its a bit more efficient. Since we call these when resolving ServiceInfo, avoiding task creation will resolve a performance problem when ServiceBrowsers startup as they tend to create task storms when coupled with ServiceInfo lookups. --- requirements-dev.txt | 1 + setup.py | 2 +- zeroconf/_utils/asyncio.py | 26 +++++--------------------- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 174ca65e9..2d4932b51 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +async_timeout>=4.0.1 autopep8 black;implementation_name=="cpython" bump2version diff --git a/setup.py b/setup.py index 2513bd365..a7f9aa0e7 100755 --- a/setup.py +++ b/setup.py @@ -45,5 +45,5 @@ 'Programming Language :: Python :: Implementation :: PyPy', ], keywords=['Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', 'mDNS'], - install_requires=['ifaddr>=0.1.7'], + install_requires=['async_timeout>=4.0.1', 'ifaddr>=0.1.7'], ) diff --git a/zeroconf/_utils/asyncio.py b/zeroconf/_utils/asyncio.py index 358698ca9..d8101afdf 100644 --- a/zeroconf/_utils/asyncio.py +++ b/zeroconf/_utils/asyncio.py @@ -25,6 +25,8 @@ import contextlib from typing import Any, Awaitable, Coroutine, Optional, Set +import async_timeout + from .time import millis_to_seconds from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT @@ -35,28 +37,11 @@ _WAIT_FOR_LOOP_TASKS_TIMEOUT = 3 # Must be larger than _TASK_AWAIT_TIMEOUT -# Switch to asyncio.wait_for once https://bugs.python.org/issue39032 is fixed async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: """Wait for an event or timeout.""" - loop = asyncio.get_event_loop() - future = loop.create_future() - - def _handle_timeout_or_wait_complete(*_: Any) -> None: - if not future.done(): - future.set_result(None) - - timer_handle = loop.call_later(timeout, _handle_timeout_or_wait_complete) - event_wait = loop.create_task(event.wait()) - event_wait.add_done_callback(_handle_timeout_or_wait_complete) - - try: - await future - finally: - timer_handle.cancel() - if not event_wait.done(): - event_wait.cancel() - with contextlib.suppress(asyncio.CancelledError): - await event_wait + with contextlib.suppress(asyncio.TimeoutError): + async with async_timeout.timeout(timeout): + await event.wait() async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: @@ -114,7 +99,6 @@ def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: loop.call_soon_threadsafe(loop.stop) -# Remove the call to _get_running_loop once we drop python 3.6 support def get_running_loop() -> Optional[asyncio.AbstractEventLoop]: """Check if an event loop is already running.""" with contextlib.suppress(RuntimeError): From 389658d998a23deecd96023794d3672e51189a35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 11:47:50 -1000 Subject: [PATCH 0723/1433] Fix flakey test_sending_unicast on windows (#1083) --- tests/test_core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index af1b71d77..1fa2c76fa 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -460,7 +460,13 @@ def test_sending_unicast(): assert zc.cache.get(entry) is None zc.send(generated) - time.sleep(0.2) + + # Handle slow github CI runners on windows + for _ in range(10): + time.sleep(0.05) + if zc.cache.get(entry) is not None: + break + assert zc.cache.get(entry) is not None zc.close() From d5032b70b6ebc5c221a43f778f4d897a1d891f91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 11:54:00 -1000 Subject: [PATCH 0724/1433] Fix flakey service_browser_expire_callbacks test (#1084) --- tests/services/test_browser.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index bdc734fcd..66e3194af 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1165,11 +1165,21 @@ def mock_incoming_msg(records) -> r.DNSIncoming: mock_incoming_msg([info.dns_service()]), ) + for _ in range(10): + time.sleep(0.05) + if len(callbacks) == 2: + break + assert callbacks == [ ('add', type_, registration_name), ('update', type_, registration_name), ] - time.sleep(1.1) + + for _ in range(25): + time.sleep(0.05) + if len(callbacks) == 3: + break + assert callbacks == [ ('add', type_, registration_name), ('update', type_, registration_name), From b7a24fef05fc6c166b25cfd4235e59c5cbb96a4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 12:06:22 -1000 Subject: [PATCH 0725/1433] Fix run_coro_with_timeout test not running in the CI (#1082) --- tests/utils/test_asyncio.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 2530fcf4d..40a8787dd 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -114,12 +114,45 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT +@pytest.mark.asyncio async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() + task = None + + async def _saved_sleep_task(): + nonlocal task + task = asyncio.create_task(asyncio.sleep(0.2)) + await task def _run_in_loop(): - aioutils.run_coro_with_timeout(asyncio.sleep(0.3), loop, 0.1) + aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 0.1) with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): await loop.run_in_executor(None, _run_in_loop) + + # ensure the thread is shutdown + task.cancel() + await asyncio.sleep(0) + await _shutdown_default_executor(loop) + + +# Remove this when we drop support for older python versions +# since we can use loop.shutdown_default_executor() in 3.9+ +async def _shutdown_default_executor(loop: asyncio.AbstractEventLoop) -> None: + """Backport of cpython 3.9 schedule the shutdown of the default executor.""" + future = loop.create_future() + + def _do_shutdown() -> None: + try: + loop._default_executor.shutdown(wait=True) # type: ignore # pylint: disable=protected-access + loop.call_soon_threadsafe(future.set_result, None) + except Exception as ex: # pylint: disable=broad-except + loop.call_soon_threadsafe(future.set_exception, ex) + + thread = threading.Thread(target=_do_shutdown) + thread.start() + try: + await future + finally: + thread.join() From 087914da2e914275dd0fff1e4466b3c51ae0c6d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 12:18:53 -1000 Subject: [PATCH 0726/1433] Remove coveralls from dev requirements (#1086) --- requirements-dev.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 2d4932b51..1054014ed 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,6 @@ async_timeout>=4.0.1 autopep8 black;implementation_name=="cpython" bump2version -coveralls coverage flake8 flake8-import-order From 946890aca540bbae95abe8a6ffe66db56fa9e986 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 12:29:50 -1000 Subject: [PATCH 0727/1433] 0.39.0 changelog (#1087) --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index c0de235dd..68888c959 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,16 @@ See examples directory for more. Changelog ========= +0.39.0 +====== + +Technically backwards incompatible: + +* Switch to using async_timeout for timeouts (#1081) @bdraco + + Significantly reduces the number of asyncio tasks that are created when + using `ServiceInfo` or `AsyncServiceInfo` + 0.38.7 ====== From 60167b05227ec33668aac5b960a8bc5ba5b833de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Aug 2022 12:29:59 -1000 Subject: [PATCH 0728/1433] =?UTF-8?q?Bump=20version:=200.38.7=20=E2=86=92?= =?UTF-8?q?=200.39.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 3fd0b8e5b..d4b4829b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.38.7 +current_version = 0.39.0 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 678c8d81d..d59001694 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.38.7' +__version__ = '0.39.0' __license__ = 'LGPL' From 5968b76ac2ffe6e41b8961c59bdcc5a48ba410eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Sep 2022 08:12:57 -0500 Subject: [PATCH 0729/1433] Replace pack with to_bytes (#1090) --- zeroconf/_protocol/outgoing.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/zeroconf/_protocol/outgoing.py b/zeroconf/_protocol/outgoing.py index 59c8382e5..00e8a11c8 100644 --- a/zeroconf/_protocol/outgoing.py +++ b/zeroconf/_protocol/outgoing.py @@ -21,8 +21,7 @@ """ import enum -import struct -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Dict, List, Optional, Sequence, Tuple, Union from . import DNSMessage from .incoming import DNSIncoming @@ -180,29 +179,28 @@ def add_question_or_all_cache( for cached_entry in cached_entries: self.add_answer_at_time(cached_entry, now) - def _pack(self, format_: Union[bytes, str], size: int, value: Any) -> None: - self.data.append(struct.pack(format_, value)) - self.size += size - def _write_byte(self, value: int) -> None: """Writes a single byte to the packet""" - self._pack(b'!c', 1, bytes((value,))) + self.data.append(value.to_bytes(1, 'big')) + self.size += 1 def _insert_short_at_start(self, value: int) -> None: """Inserts an unsigned short at the start of the packet""" - self.data.insert(0, struct.pack(b'!H', value)) + self.data.insert(0, value.to_bytes(2, 'big')) def _replace_short(self, index: int, value: int) -> None: """Replaces an unsigned short in a certain position in the packet""" - self.data[index] = struct.pack(b'!H', value) + self.data[index] = value.to_bytes(2, 'big') def write_short(self, value: int) -> None: """Writes an unsigned short to the packet""" - self._pack(b'!H', 2, value) + self.data.append(value.to_bytes(2, 'big')) + self.size += 2 def _write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" - self._pack(b'!I', 4, int(value)) + self.data.append(int(value).to_bytes(4, 'big')) + self.size += 4 def write_string(self, value: bytes) -> None: """Writes a string to the packet""" From cad3963e566a7bb2dd188088c11e7a0abb6b3924 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Sep 2022 14:47:30 -0500 Subject: [PATCH 0730/1433] Update changelog for 0.39.1 (#1091) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 68888c959..3def66f74 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,12 @@ See examples directory for more. Changelog ========= +0.39.1 +====== + +* Performance improvements for constructing outgoing packet data (#1090) @bdraco + + 0.39.0 ====== From 6f90896a590d6d60db75688a1ba753c333c8faab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Sep 2022 14:44:45 -0500 Subject: [PATCH 0731/1433] =?UTF-8?q?Bump=20version:=200.39.0=20=E2=86=92?= =?UTF-8?q?=200.39.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 70 ++++++------- zeroconf/__init__.py | 238 +++++++++++++++++++++---------------------- 2 files changed, 154 insertions(+), 154 deletions(-) diff --git a/setup.cfg b/setup.cfg index d4b4829b4..82988aaa9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,35 +1,35 @@ -[bumpversion] -current_version = 0.39.0 -commit = True -tag = True -tag_name = {new_version} - -[bumpversion:file:zeroconf/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - -[tool:pytest] -testpaths = tests - -[flake8] -show-source = 1 -application-import-names = zeroconf -max-line-length = 110 -ignore = E203,W503,N818 - -[mypy] -ignore_missing_imports = true -follow_imports = skip -check_untyped_defs = true -no_implicit_optional = true -warn_incomplete_stub = true -warn_no_return = true -warn_redundant_casts = true -warn_unused_configs = true -warn_unused_ignores = true -warn_return_any = true -disallow_untyped_calls = false -disallow_untyped_defs = true - -[mypy-zeroconf.test] -disallow_untyped_defs = false +[bumpversion] +current_version = 0.39.1 +commit = True +tag = True +tag_name = {new_version} + +[bumpversion:file:zeroconf/__init__.py] +search = __version__ = '{current_version}' +replace = __version__ = '{new_version}' + +[tool:pytest] +testpaths = tests + +[flake8] +show-source = 1 +application-import-names = zeroconf +max-line-length = 110 +ignore = E203,W503,N818 + +[mypy] +ignore_missing_imports = true +follow_imports = skip +check_untyped_defs = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_no_return = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +warn_return_any = true +disallow_untyped_calls = false +disallow_untyped_defs = true + +[mypy-zeroconf.test] +disallow_untyped_defs = false diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index d59001694..c68a010d9 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -1,119 +1,119 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA -""" - -import sys - -from ._cache import DNSCache # noqa # import needed for backwards compat -from ._core import Zeroconf -from ._dns import ( # noqa # import needed for backwards compat - DNSAddress, - DNSEntry, - DNSHinfo, - DNSNsec, - DNSPointer, - DNSQuestion, - DNSRecord, - DNSService, - DNSText, - DNSQuestionType, -) -from ._exceptions import ( - AbstractMethodException, - BadTypeInNameException, - Error, - EventLoopBlocked, - IncomingDecodeError, - NamePartTooLongException, - NonUniqueNameException, - NotRunningException, - ServiceNameAlreadyRegistered, -) -from ._logger import QuietLogger, log # noqa # import needed for backwards compat -from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat -from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat -from ._services import ( # noqa # import needed for backwards compat - Signal, - SignalRegistrationInterface, - ServiceListener, - ServiceStateChange, -) -from ._services.browser import ServiceBrowser -from ._services.info import ( # noqa # import needed for backwards compat - instance_name_from_service_info, - ServiceInfo, -) -from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat -from ._services.types import ZeroconfServiceTypes -from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.name import service_type_name # noqa # import needed for backwards compat -from ._utils.net import ( # noqa # import needed for backwards compat - add_multicast_member, - autodetect_ip_version, - create_sockets, - get_all_addresses_v6, - InterfaceChoice, - InterfacesType, - IPVersion, - get_all_addresses, -) -from ._utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat - -__author__ = 'Paul Scott-Murphy, William McBrine' -__maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.0' -__license__ = 'LGPL' - - -__all__ = [ - "__version__", - "Zeroconf", - "ServiceInfo", - "ServiceBrowser", - "ServiceListener", - "DNSQuestionType", - "InterfaceChoice", - "ServiceStateChange", - "IPVersion", - "ZeroconfServiceTypes", - "RecordUpdate", - "RecordUpdateListener", - "current_time_millis", - # Exceptions - "Error", - "AbstractMethodException", - "BadTypeInNameException", - "EventLoopBlocked", - "IncomingDecodeError", - "NamePartTooLongException", - "NonUniqueNameException", - "NotRunningException", - "ServiceNameAlreadyRegistered", -] - -if sys.version_info <= (3, 6): # pragma: no cover - raise ImportError( # pragma: no cover - ''' -Python version > 3.6 required for python-zeroconf. -If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 -If you need support for Python 3.5 please use version 0.28.0 - ''' - ) +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import sys + +from ._cache import DNSCache # noqa # import needed for backwards compat +from ._core import Zeroconf +from ._dns import ( # noqa # import needed for backwards compat + DNSAddress, + DNSEntry, + DNSHinfo, + DNSNsec, + DNSPointer, + DNSQuestion, + DNSRecord, + DNSService, + DNSText, + DNSQuestionType, +) +from ._exceptions import ( + AbstractMethodException, + BadTypeInNameException, + Error, + EventLoopBlocked, + IncomingDecodeError, + NamePartTooLongException, + NonUniqueNameException, + NotRunningException, + ServiceNameAlreadyRegistered, +) +from ._logger import QuietLogger, log # noqa # import needed for backwards compat +from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat +from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat +from ._services import ( # noqa # import needed for backwards compat + Signal, + SignalRegistrationInterface, + ServiceListener, + ServiceStateChange, +) +from ._services.browser import ServiceBrowser +from ._services.info import ( # noqa # import needed for backwards compat + instance_name_from_service_info, + ServiceInfo, +) +from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat +from ._services.types import ZeroconfServiceTypes +from ._updates import RecordUpdate, RecordUpdateListener +from ._utils.name import service_type_name # noqa # import needed for backwards compat +from ._utils.net import ( # noqa # import needed for backwards compat + add_multicast_member, + autodetect_ip_version, + create_sockets, + get_all_addresses_v6, + InterfaceChoice, + InterfacesType, + IPVersion, + get_all_addresses, +) +from ._utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat + +__author__ = 'Paul Scott-Murphy, William McBrine' +__maintainer__ = 'Jakub Stasiak ' +__version__ = '0.39.1' +__license__ = 'LGPL' + + +__all__ = [ + "__version__", + "Zeroconf", + "ServiceInfo", + "ServiceBrowser", + "ServiceListener", + "DNSQuestionType", + "InterfaceChoice", + "ServiceStateChange", + "IPVersion", + "ZeroconfServiceTypes", + "RecordUpdate", + "RecordUpdateListener", + "current_time_millis", + # Exceptions + "Error", + "AbstractMethodException", + "BadTypeInNameException", + "EventLoopBlocked", + "IncomingDecodeError", + "NamePartTooLongException", + "NonUniqueNameException", + "NotRunningException", + "ServiceNameAlreadyRegistered", +] + +if sys.version_info <= (3, 6): # pragma: no cover + raise ImportError( # pragma: no cover + ''' +Python version > 3.6 required for python-zeroconf. +If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 +If you need support for Python 3.5 please use version 0.28.0 + ''' + ) From 7430ce1c462be0dd210712b4f7b3675efd3a6963 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Sep 2022 12:07:30 -0500 Subject: [PATCH 0732/1433] Prepare for python 3.11 support by adding rc2 to the CI (#1085) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19d16be66..256fe3081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, "3.10", "pypy-3.7"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11.0-rc.2", "pypy-3.7"] include: - os: ubuntu-latest venvcmd: . env/bin/activate From 0989336d79bc4dd0ef3b26e8d0f9529fca81c1fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Oct 2022 14:29:43 -0500 Subject: [PATCH 0733/1433] Only reprocess address records if the server changes (#1095) --- zeroconf/_services/info.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index a48e74d45..a9d53ea76 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -310,9 +310,7 @@ def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[ """Thread safe record updating.""" update_addresses = False for record_update in records: - if isinstance(record_update[0], DNSService): - update_addresses = True - self._process_record_threadsafe(record_update[0], now) + update_addresses |= self._process_record_threadsafe(record_update[0], now) # Only update addresses if the DNSService (.server) has changed if not update_addresses: @@ -321,42 +319,50 @@ def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[ for record in self._get_address_records_from_cache(zc): self._process_record_threadsafe(record, now) - def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: + def _process_record_threadsafe(self, record: DNSRecord, now: float) -> bool: + """Thread safe record updating. + + Returns True if the server has changed. + """ if record.is_expired(now): - return + return False if isinstance(record, DNSAddress): if record.key != self.server_key: - return + return False try: ip_addr = ipaddress.ip_address(record.address) except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) - return + return False if isinstance(ip_addr, ipaddress.IPv4Address): if ip_addr not in self._ipv4_addresses: self._ipv4_addresses.insert(0, ip_addr) - return + return False if ip_addr not in self._ipv6_addresses: self._ipv6_addresses.insert(0, ip_addr) if ip_addr.is_link_local: self.interface_index = record.scope_id - return + return False + + if isinstance(record, DNSText): + if record.key == self.key: + self._set_text(record.text) + return False if isinstance(record, DNSService): if record.key != self.key: - return + return False self.name = record.name + server_changed = record.server != self.server self.server = record.server self.server_key = record.server.lower() self.port = record.port self.weight = record.weight self.priority = record.priority - return + return server_changed - if isinstance(record, DNSText): - if record.key == self.key: - self._set_text(record.text) + return False def dns_addresses( self, From d3c475f3e2590ae5a3056d85c29a66dc71ae3bdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Oct 2022 14:34:37 -0500 Subject: [PATCH 0734/1433] Improve cache of decode labels at offset (#1097) --- bench/incoming.py | 167 +++++++++++++++++++++++++++++++++ zeroconf/_protocol/incoming.py | 4 +- 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 bench/incoming.py diff --git a/bench/incoming.py b/bench/incoming.py new file mode 100644 index 000000000..fd4890662 --- /dev/null +++ b/bench/incoming.py @@ -0,0 +1,167 @@ +"""Benchmark for DNSIncoming.""" +import socket +import timeit + +from zeroconf import DNSAddress, DNSIncoming, DNSOutgoing, DNSService, DNSText, const + + +def generate_packets(): + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + return out.packets() + + +packets = generate_packets() + + +def parse_incoming_message(): + for packet in packets: + DNSIncoming(packet).answers + break + + +count = 100000 +time = timeit.Timer(parse_incoming_message).timeit(count) +print(f"Parsing {count} incoming messages took {time} seconds") diff --git a/zeroconf/_protocol/incoming.py b/zeroconf/_protocol/incoming.py index 09ecd055c..6fea2e83c 100644 --- a/zeroconf/_protocol/incoming.py +++ b/zeroconf/_protocol/incoming.py @@ -271,7 +271,9 @@ def read_name(self) -> str: """Reads a domain name from the packet.""" labels: List[str] = [] seen_pointers: Set[int] = set() - self.offset = self._decode_labels_at_offset(self.offset, labels, seen_pointers) + original_offset = self.offset + self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) + self.name_cache[original_offset] = labels name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: raise IncomingDecodeError( From b19734484b4c5eebb86fe6897a26ad082b07bed5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Oct 2022 15:42:17 -0500 Subject: [PATCH 0735/1433] Update changelog for 0.39.2 (#1098) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 3def66f74..7218c4e53 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,12 @@ See examples directory for more. Changelog ========= +0.39.2 +====== + +* Performance improvements for parsing incoming packet data (#1095) (#1097) @bdraco + + 0.39.1 ====== From 785e475467225ddc4930d5302f130781223fd298 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Oct 2022 15:43:43 -0500 Subject: [PATCH 0736/1433] =?UTF-8?q?Bump=20version:=200.39.1=20=E2=86=92?= =?UTF-8?q?=200.39.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 82988aaa9..992e8b4f0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.39.1 +current_version = 0.39.2 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index c68a010d9..1697f41e2 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.1' +__version__ = '0.39.2' __license__ = 'LGPL' From 6976980b4874dd65ee533d43be57694bb3b7d0fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Oct 2022 11:00:47 -0500 Subject: [PATCH 0737/1433] Update CI to use released python 3.11 (#1099) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256fe3081..bc24770c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, "3.10", "3.11.0-rc.2", "pypy-3.7"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "pypy-3.7"] include: - os: ubuntu-latest venvcmd: . env/bin/activate From c96f5f69d8e68672bb6760b1e40a0de51b62efd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Oct 2022 17:08:56 -0500 Subject: [PATCH 0738/1433] Fix port changes not being seen by ServiceInfo (#1100) --- tests/services/test_info.py | 83 +++++++++++++++++++++++++++++++++++++ zeroconf/_services/info.py | 9 +++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index b8f437d4e..e84379df1 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -865,3 +865,86 @@ async def test_release_wait_when_new_recorded_added(): assert await asyncio.wait_for(task, timeout=2) assert info.addresses == [b'\x7f\x00\x00\x01'] await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_port_changes_are_seen(): + """Test that port changes are seen by async_request.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 90, + 90, + 81, + host, + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.port == 81 + assert info.priority == 90 + assert info.weight == 90 + await aiozc.async_close() diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index a9d53ea76..9f4dc0a4d 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -312,7 +312,7 @@ def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[ for record_update in records: update_addresses |= self._process_record_threadsafe(record_update[0], now) - # Only update addresses if the DNSService (.server) has changed + # Only update addresses if the DNSService has changed if not update_addresses: return @@ -354,7 +354,12 @@ def _process_record_threadsafe(self, record: DNSRecord, now: float) -> bool: if record.key != self.key: return False self.name = record.name - server_changed = record.server != self.server + server_changed = ( + record.server != self.server + or record.port != self.port + or record.weight != self.weight + or record.priority != self.priority + ) self.server = record.server self.server_key = record.server.lower() self.port = record.port From 39c9842b80ac7d978e8c7ffef0ad836b3b4700f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Oct 2022 17:16:46 -0500 Subject: [PATCH 0739/1433] Update changelog for 0.39.3 (#1101) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 7218c4e53..b1604686a 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,12 @@ See examples directory for more. Changelog ========= +0.39.3 +====== + +* Fix port changes not being seen by ServiceInfo (#1100) @bdraco + + 0.39.2 ====== From aee316539b0778eaf2b8878f78d9ead373760cfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Oct 2022 17:17:20 -0500 Subject: [PATCH 0740/1433] =?UTF-8?q?Bump=20version:=200.39.2=20=E2=86=92?= =?UTF-8?q?=200.39.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 992e8b4f0..c04972a05 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.39.2 +current_version = 0.39.3 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1697f41e2..1a6240aab 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.2' +__version__ = '0.39.3' __license__ = 'LGPL' From 524ae89966d9300e78642a91434ad55643277a48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Oct 2022 11:41:38 -0500 Subject: [PATCH 0741/1433] Fix IP changes being missed by ServiceInfo (#1102) --- tests/services/test_info.py | 84 +++++++++++++++++++++++++++++++++++++ zeroconf/_services/info.py | 79 ++++++++++++++++++---------------- 2 files changed, 127 insertions(+), 36 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index e84379df1..258080c6b 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -19,6 +19,7 @@ from zeroconf import DNSAddress, const from zeroconf._services import types from zeroconf._services.info import ServiceInfo +from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf from .. import has_working_ipv6, _inject_response @@ -948,3 +949,86 @@ async def test_port_changes_are_seen(): assert info.priority == 90 assert info.weight == 90 await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_ip_changes_are_seen(): + """Test that ip changes are seen by async_request.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x02', + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + await aiozc.async_close() diff --git a/zeroconf/_services/info.py b/zeroconf/_services/info.py index 9f4dc0a4d..fc5e93741 100644 --- a/zeroconf/_services/info.py +++ b/zeroconf/_services/info.py @@ -23,7 +23,7 @@ import ipaddress import random import socket -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, cast +from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union, cast from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText from .._exceptions import BadTypeInNameException @@ -99,7 +99,23 @@ class ServiceInfo(RecordUpdateListener): where the peer is connected to """ - text = b'' + __slots__ = ( + "text", + "type", + "_name", + "key", + "_ipv4_addresses", + "_ipv6_addresses", + "port", + "weight", + "priority", + "server", + "server_key", + "_properties", + "host_ttl", + "other_ttl", + "interface_index", + ) def __init__( self, @@ -122,6 +138,7 @@ def __init__( raise TypeError("addresses and parsed_addresses cannot be provided together") if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException + self.text = b'' self.type = type_ self._name = name self.key = name.lower() @@ -308,66 +325,53 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Thread safe record updating.""" - update_addresses = False + seen_addresses: Set[bytes] = set() for record_update in records: - update_addresses |= self._process_record_threadsafe(record_update[0], now) - - # Only update addresses if the DNSService has changed - if not update_addresses: - return - - for record in self._get_address_records_from_cache(zc): + record = record_update.new + if isinstance(record, DNSAddress): + seen_addresses.add(record.address) self._process_record_threadsafe(record, now) + for record in self._get_address_records_from_cache(zc): + if record.address not in seen_addresses: + self._process_record_threadsafe(record, now) - def _process_record_threadsafe(self, record: DNSRecord, now: float) -> bool: - """Thread safe record updating. - - Returns True if the server has changed. - """ + def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: + """Thread safe record updating.""" if record.is_expired(now): - return False + return if isinstance(record, DNSAddress): if record.key != self.server_key: - return False + return try: ip_addr = ipaddress.ip_address(record.address) except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) - return False + return if isinstance(ip_addr, ipaddress.IPv4Address): if ip_addr not in self._ipv4_addresses: self._ipv4_addresses.insert(0, ip_addr) - return False + return if ip_addr not in self._ipv6_addresses: self._ipv6_addresses.insert(0, ip_addr) if ip_addr.is_link_local: self.interface_index = record.scope_id - return False + return if isinstance(record, DNSText): if record.key == self.key: self._set_text(record.text) - return False + return if isinstance(record, DNSService): if record.key != self.key: - return False + return self.name = record.name - server_changed = ( - record.server != self.server - or record.port != self.port - or record.weight != self.weight - or record.priority != self.priority - ) self.server = record.server self.server_key = record.server.lower() self.port = record.port self.weight = record.weight self.priority = record.priority - return server_changed - - return False def dns_addresses( self, @@ -424,12 +428,15 @@ def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] created, ) - def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSRecord]: + def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSAddress]: """Get the address records from the cache.""" - return [ - *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), - *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), - ] + return cast( + "List[DNSAddress]", + [ + *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), + *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), + ], + ) def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache. From 03821b6f4d9fdc40d94d1070f69553649d18909b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Oct 2022 11:49:04 -0500 Subject: [PATCH 0742/1433] Update changelog for 0.39.4 (#1103) --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index b1604686a..d376d9a37 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,12 @@ See examples directory for more. Changelog ========= +0.39.4 +====== + +* Fix IP changes being missed by ServiceInfo (#1102) @bdraco + + 0.39.3 ====== From e620f2a1d4f381feb99b639c6ab17845396ba7ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Oct 2022 11:49:38 -0500 Subject: [PATCH 0743/1433] =?UTF-8?q?Bump=20version:=200.39.3=20=E2=86=92?= =?UTF-8?q?=200.39.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index c04972a05..cfddb847e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.39.3 +current_version = 0.39.4 commit = True tag = True tag_name = {new_version} diff --git a/zeroconf/__init__.py b/zeroconf/__init__.py index 1a6240aab..c82e4f6b6 100644 --- a/zeroconf/__init__.py +++ b/zeroconf/__init__.py @@ -79,7 +79,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.3' +__version__ = '0.39.4' __license__ = 'LGPL' From 755ceae1f6a899a8befcc033d99acdddee224ba2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 11:47:56 -1000 Subject: [PATCH 0744/1433] chore: add release automation (#1105) --- .coveragerc | 2 +- .flake8 | 4 + .github/workflows/ci.yml | 212 ++- .pre-commit-config.yaml | 61 + CHANGELOG.md | 1196 +++++++++++++++++ COPYING | 18 +- Makefile | 50 +- README.rst | 1149 +--------------- bench/incoming.py | 5 +- build_ext.py | 38 + docs/conf.py | 16 +- examples/async_browser.py | 7 +- examples/async_service_info_request.py | 2 - examples/browser.py | 8 +- poetry.lock | 394 ++++++ pyproject.toml | 112 +- setup.cfg | 35 - setup.py | 49 - {zeroconf => src/zeroconf}/__init__.py | 25 +- {zeroconf => src/zeroconf}/_cache.py | 0 {zeroconf => src/zeroconf}/_core.py | 2 +- {zeroconf => src/zeroconf}/_dns.py | 10 +- {zeroconf => src/zeroconf}/_exceptions.py | 0 {zeroconf => src/zeroconf}/_handlers.py | 17 +- {zeroconf => src/zeroconf}/_history.py | 0 {zeroconf => src/zeroconf}/_logger.py | 0 .../zeroconf}/_protocol/__init__.py | 7 +- .../zeroconf}/_protocol/incoming.py | 15 +- .../zeroconf}/_protocol/outgoing.py | 4 +- .../zeroconf}/_services/__init__.py | 3 +- .../zeroconf}/_services/browser.py | 15 +- {zeroconf => src/zeroconf}/_services/info.py | 22 +- .../zeroconf}/_services/registry.py | 3 +- {zeroconf => src/zeroconf}/_services/types.py | 4 +- {zeroconf => src/zeroconf}/_updates.py | 4 +- {zeroconf => src/zeroconf}/_utils/__init__.py | 0 {zeroconf => src/zeroconf}/_utils/asyncio.py | 2 +- {zeroconf => src/zeroconf}/_utils/name.py | 2 +- {zeroconf => src/zeroconf}/_utils/net.py | 6 +- {zeroconf => src/zeroconf}/_utils/time.py | 0 {zeroconf => src/zeroconf}/asyncio.py | 9 +- {zeroconf => src/zeroconf}/const.py | 0 {zeroconf => src/zeroconf}/py.typed | 0 tests/__init__.py | 1 - tests/conftest.py | 3 +- tests/services/test_browser.py | 89 +- tests/services/test_info.py | 23 +- tests/services/test_registry.py | 2 +- tests/services/test_types.py | 4 +- tests/test_asyncio.py | 65 +- tests/test_cache.py | 7 +- tests/test_core.py | 38 +- tests/test_dns.py | 7 +- tests/test_exceptions.py | 6 +- tests/test_handlers.py | 36 +- tests/test_history.py | 10 +- tests/test_logger.py | 1 + tests/test_protocol.py | 21 +- tests/test_services.py | 6 +- tests/test_updates.py | 5 +- tests/utils/test_asyncio.py | 13 +- tests/utils/test_name.py | 2 +- tests/utils/test_net.py | 24 +- 63 files changed, 2293 insertions(+), 1578 deletions(-) create mode 100644 .flake8 create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 build_ext.py create mode 100644 poetry.lock delete mode 100644 setup.cfg delete mode 100755 setup.py rename {zeroconf => src/zeroconf}/__init__.py (91%) rename {zeroconf => src/zeroconf}/_cache.py (100%) rename {zeroconf => src/zeroconf}/_core.py (100%) rename {zeroconf => src/zeroconf}/_dns.py (98%) rename {zeroconf => src/zeroconf}/_exceptions.py (100%) rename {zeroconf => src/zeroconf}/_handlers.py (99%) rename {zeroconf => src/zeroconf}/_history.py (100%) rename {zeroconf => src/zeroconf}/_logger.py (100%) rename {zeroconf => src/zeroconf}/_protocol/__init__.py (93%) rename {zeroconf => src/zeroconf}/_protocol/incoming.py (98%) rename {zeroconf => src/zeroconf}/_protocol/outgoing.py (100%) rename {zeroconf => src/zeroconf}/_services/__init__.py (97%) rename {zeroconf => src/zeroconf}/_services/browser.py (98%) rename {zeroconf => src/zeroconf}/_services/info.py (98%) rename {zeroconf => src/zeroconf}/_services/registry.py (99%) rename {zeroconf => src/zeroconf}/_services/types.py (97%) rename {zeroconf => src/zeroconf}/_updates.py (97%) rename {zeroconf => src/zeroconf}/_utils/__init__.py (100%) rename {zeroconf => src/zeroconf}/_utils/asyncio.py (100%) rename {zeroconf => src/zeroconf}/_utils/name.py (100%) rename {zeroconf => src/zeroconf}/_utils/net.py (98%) rename {zeroconf => src/zeroconf}/_utils/time.py (100%) rename {zeroconf => src/zeroconf}/asyncio.py (98%) rename {zeroconf => src/zeroconf}/const.py (100%) rename {zeroconf => src/zeroconf}/py.typed (100%) diff --git a/.coveragerc b/.coveragerc index 7648cf0d8..13add00a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [report] -exclude_lines = +exclude_lines = pragma: no cover if TYPE_CHECKING: if sys.version_info diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..bb5e15d37 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +exclude = docs +max-line-length = 180 +extend-ignore = E203 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc24770c6..e7773fc22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,66 +5,174 @@ on: branches: - master pull_request: - branches: - - "**" + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: - build: - runs-on: ${{ matrix.os }} + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: "3.9" + - uses: pre-commit/action@v2.0.3 + + # Make sure commit messages follow the conventional commits convention: + # https://www.conventionalcommits.org + commitlint: + name: Lint Commit Messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v4.1.11 + + test: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "pypy-3.7"] - include: - - os: ubuntu-latest - venvcmd: . env/bin/activate + python-version: + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "pypy-3.7" + os: + - ubuntu-latest + - macos-latest + - windows-latest + extension: + - "skip_cython" + - "use_cython" + exclude: - os: macos-latest - venvcmd: . env/bin/activate + extension: use_cython - os: windows-latest - venvcmd: env\Scripts\Activate.ps1 + extension: use_cython + - os: windows-latest + python-version: "pypy-3.7" + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Install poetry + run: pipx install poetry + - name: Set up Python + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - architecture: 'x64' - - uses: actions/cache@v2 - id: cache + cache: "poetry" + - name: Install Dependencies no cython + if: ${{ matrix.extension == 'skip_cython' }} + env: + SKIP_CYTHON: 1 + run: poetry install --only=main,dev + - name: Install Dependencies with cython + if: ${{ matrix.extension != 'skip_cython' }} + run: poetry install --only=main,dev + - name: Test with Pytest + run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 with: - path: env - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Makefile') }}-${{ hashFiles('**/requirements-dev.txt') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/Makefile') }} - - name: Install dependencies - if: steps.cache.outputs.cache-hit != 'true' - run: | - python -m venv env - ${{ matrix.venvcmd }} - pip install --upgrade -r requirements-dev.txt pytest-github-actions-annotate-failures - - name: Validate readme - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} - run: | - ${{ matrix.venvcmd }} - python -m readme_renderer README.rst -o - - - name: Run flake8 - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} - run: | - ${{ matrix.venvcmd }} - make flake8 - - name: Run mypy - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} - run: | - ${{ matrix.venvcmd }} - make mypy - - name: Run black_check - if: ${{ runner.os == 'Linux' && matrix.python-version != 'pypy-3.7' }} - run: | - ${{ matrix.venvcmd }} - make black_check - - name: Run tests + token: ${{ secrets.CODECOV_TOKEN }} + + release: + runs-on: ubuntu-latest + environment: release + if: github.ref == 'refs/heads/master' + needs: + - test + - lint + - commitlint + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + # Run semantic release: + # - Update CHANGELOG.md + # - Update version in code + # - Create git tag + # - Create GitHub release + # - Publish to PyPI + - name: Python Semantic Release + uses: relekang/python-semantic-release@v7.32.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + pypi_token: ${{ secrets.PYPI_TOKEN }} + + build_wheels: + needs: [release] + + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, windows-2019, macOS-11] + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: "master" + + # Used to host cibuildwheel + - name: Set up Python + uses: actions/setup-python@v4 + + - name: Install python-semantic-release + run: python -m pip install python-semantic-release + + - name: Get Release Tag + id: release_tag + shell: bash run: | - ${{ matrix.venvcmd }} - make test_coverage - - name: Report coverage to Codecov - uses: codecov/codecov-action@v1 + echo "::set-output name=newest_release_tag::$(semantic-release print-version --current)" + + - uses: actions/checkout@v3 + with: + ref: "v${{ steps.release_tag.outputs.newest_release_tag }}" + fetch-depth: 0 + + - name: Install cibuildwheel + run: python -m pip install cibuildwheel==2.11.3 + + - name: Build wheels + run: python -m cibuildwheel --output-dir wheelhouse + # to supply options, put them in 'env', like: + env: + CIBW_SKIP: cp36-* + CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc + CIBW_BUILD_VERBOSITY: 3 + REQUIRE_CYTHON: 1 + + - uses: actions/upload-artifact@v3 + with: + path: ./wheelhouse/*.whl + + upload_pypi: + needs: [build_wheels] + runs-on: ubuntu-latest + environment: release + + steps: + - uses: actions/download-artifact@v3 + with: + # unpacks default artifact into dist/ + # if `name: artifact` is omitted, the action will create extra parent dir + name: artifact + path: dist + + - uses: pypa/gh-action-pypi-publish@v1.5.0 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + # To test: repository_url: https://test.pypi.org/legacy/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..1a2e0df94 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +exclude: "CHANGELOG.md" +default_stages: [commit] + +ci: + autofix_commit_msg: "chore(pre-commit.ci): auto fixes" + autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" + +repos: + - repo: https://github.com/commitizen-tools/commitizen + rev: v2.32.4 + hooks: + - id: commitizen + stages: [commit-msg] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: debug-statements + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: check-json + - id: check-toml + - id: check-xml + - id: check-yaml + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + - id: debug-statements + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + args: ["--tab-width", "2"] + - repo: https://github.com/asottile/pyupgrade + rev: v2.37.3 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 22.8.0 + hooks: + - id: black + # - repo: https://github.com/codespell-project/codespell + # rev: v2.2.1 + # hooks: + # - id: codespell + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.931 + hooks: + - id: mypy + additional_dependencies: [] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..4e7e0cf60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1196 @@ +# Changelog + + + +# 0.39.4 + + - Fix IP changes being missed by ServiceInfo (\#1102) @bdraco + +# 0.39.3 + + - Fix port changes not being seen by ServiceInfo (\#1100) @bdraco + +# 0.39.2 + + - Performance improvements for parsing incoming packet data (\#1095) + (\#1097) @bdraco + +# 0.39.1 + + - Performance improvements for constructing outgoing packet data + (\#1090) @bdraco + +# 0.39.0 + +Technically backwards incompatible: + + - Switch to using async\_timeout for timeouts (\#1081) @bdraco + + Significantly reduces the number of asyncio tasks that are created + when using ServiceInfo or + AsyncServiceInfo + +# 0.38.7 + + - Performance improvements for parsing incoming packet data (\#1076) + @bdraco + +# 0.38.6 + + - Performance improvements for fetching ServiceInfo (\#1068) @bdraco + +# 0.38.5 + + - Fix ServiceBrowsers not getting ServiceStateChange.Removed callbacks + on PTR record expire (\#1064) @bdraco + + ServiceBrowsers were only getting a + ServiceStateChange.Removed callback + when the record was sent with a TTL of 0. ServiceBrowsers now + correctly get a + ServiceStateChange.Removed callback + when the record expires as well. + + - Fix missing minimum version of python 3.7 (\#1060) @stevencrader + +# 0.38.4 + + - Fix IP Address updates when hostname is uppercase (\#1057) @bdraco + + ServiceBrowsers would not callback updates when the ip address + changed if the hostname contained uppercase characters + +# 0.38.3 + +Version bump only, no changes from 0.38.2 + +# 0.38.2 + + - Make decode errors more helpful in finding the source of the bad + data (\#1052) @bdraco + +# 0.38.1 + + - Improve performance of query scheduler (\#1043) @bdraco + - Avoid linear type searches in ServiceBrowsers (\#1044) @bdraco + +# 0.38.0 + + - Handle Service types that end with another service type (\#1041) + @apworks1 + +Backwards incompatible: + + - Dropped Python 3.6 support (\#1009) @bdraco + +# 0.37.0 + +Technically backwards incompatible: + + - Adding a listener that does not inherit from RecordUpdateListener + now logs an error (\#1034) @bdraco + + - The NotRunningException exception is now thrown when Zeroconf is not + running (\#1033) @bdraco + + Before this change the consumer would get a timeout or an + EventLoopBlocked exception when calling + ServiceInfo.\*request when the + instance had already been shutdown or had failed to startup. + + - The EventLoopBlocked exception is now thrown when a coroutine times + out (\#1032) @bdraco + + Previously + concurrent.futures.TimeoutError would + have been raised instead. This is never expected to happen during + normal operation. + +# 0.36.13 + + - Unavailable interfaces are now skipped during socket bind (\#1028) + @bdraco + + - Downgraded incoming corrupt packet logging to debug (\#1029) @bdraco + + Warning about network traffic we have no control over is confusing + to users as they think there is something wrong with zeroconf + +# 0.36.12 + + - Prevented service lookups from deadlocking if time abruptly moves + backwards (\#1006) @bdraco + + The typical reason time moves backwards is via an ntp update + +# 0.36.11 + +No functional changes from 0.36.10. This release corrects an error in +the README.rst file that prevented the build from uploading to PyPI + +# 0.36.10 + + - scope\_id is now stripped from IPv6 addresses if given (\#1020) + @StevenLooman + + cpython 3.9 allows a suffix %scope\_id in IPv6Address. This caused + an error with the existing code if it was not stripped + + - Optimized decoding labels from incoming packets (\#1019) @bdraco + +# 0.36.9 + + - Ensure ServiceInfo orders newest addresses first (\#1012) @bdraco + + This change effectively restored the behavior before 1s cache flush + expire behavior described in rfc6762 section 10.2 was added for + callers that rely on this. + +# 0.36.8 + + - Fixed ServiceBrowser infinite loop when zeroconf is closed before it + is canceled (\#1008) @bdraco + +# 0.36.7 + + - Improved performance of responding to queries (\#994) (\#996) + (\#997) @bdraco + - Improved log message when receiving an invalid or corrupt packet + (\#998) @bdraco + +# 0.36.6 + + - Improved performance of sending outgoing packets (\#990) @bdraco + +# 0.36.5 + + - Reduced memory usage for incoming and outgoing packets (\#987) + @bdraco + +# 0.36.4 + + - Improved performance of constructing outgoing packets (\#978) + (\#979) @bdraco + - Deferred parsing of incoming packets when it can be avoided (\#983) + @bdraco + +# 0.36.3 + + - Improved performance of parsing incoming packets (\#975) @bdraco + +# 0.36.2 + + - Include NSEC records for non-existent types when responding with + addresses (\#972) (\#971) @bdraco Implements RFC6762 sec 6.2 + () + +# 0.36.1 + + - Skip goodbye packets for addresses when there is another service + registered with the same name (\#968) @bdraco + + If a ServiceInfo that used the same server name as another + ServiceInfo was unregistered, goodbye packets would be sent for the + addresses and would cause the other service to be seen as offline. + + - Fixed equality and hash for dns records with the unique bit (\#969) + @bdraco + + These records should have the same hash and equality since the + unique bit (cache flush bit) is not considered when adding or + removing the records from the cache. + +# 0.36.0 + +Technically backwards incompatible: + + - Fill incomplete IPv6 tuples to avoid WinError on windows (\#965) + @lokesh2019 + + Fixed \#932 + +# 0.35.1 + + - Only reschedule types if the send next time changes (\#958) @bdraco + + When the PTR response was seen again, the timer was being canceled + and rescheduled even if the timer was for the same time. While this + did not cause any breakage, it is quite inefficient. + + - Cache DNS record and question hashes (\#960) @bdraco + + The hash was being recalculated every time the object was being used + in a set or dict. Since the hashes are effectively immutable, we + only calculate them once now. + +# 0.35.0 + + - Reduced chance of accidental synchronization of ServiceInfo requests + (\#955) @bdraco + - Sort aggregated responses to increase chance of name compression + (\#954) @bdraco + +Technically backwards incompatible: + + - Send unicast replies on the same socket the query was received + (\#952) @bdraco + + When replying to a QU question, we do not know if the sending host + is reachable from all of the sending sockets. We now avoid this + problem by replying via the receiving socket. This was the existing + behavior when InterfaceChoice.Default + is set. + + This change extends the unicast relay behavior to used with + InterfaceChoice.Default to apply when + InterfaceChoice.All or interfaces are + explicitly passed when instantiating a + Zeroconf instance. + + Fixes \#951 + +# 0.34.3 + + - Fix sending immediate multicast responses (\#949) @bdraco + +# 0.34.2 + + - Coalesce aggregated multicast answers (\#945) @bdraco + + When the random delay is shorter than the last scheduled response, + answers are now added to the same outgoing time group. + + This reduces traffic when we already know we will be sending a group + of answers inside the random delay window described in + datatracker.ietf.org/doc/html/rfc6762\#section-6.3 + + - Ensure ServiceInfo requests can be answered inside the default + timeout with network protection (\#946) @bdraco + + Adjust the time windows to ensure responses that have triggered the + protection against against excessive packet flooding due to software + bugs or malicious attack described in RFC6762 section 6 can respond + in under 1350ms to ensure ServiceInfo can ask two questions within + the default timeout of 3000ms + +# 0.34.1 + + - Ensure multicast aggregation sends responses within 620ms (\#942) + @bdraco + + Responses that trigger the protection against against excessive + packet flooding due to software bugs or malicious attack described + in RFC6762 section 6 could cause the multicast aggregation response + to be delayed longer than 620ms (The maximum random delay of 120ms + and 500ms additional for aggregation). + + Only responses that trigger the protection are delayed longer than + 620ms + +# 0.34.0 + + - Implemented Multicast Response Aggregation (\#940) @bdraco + + Responses are now aggregated when possible per rules in RFC6762 + section 6.4 + + Responses that trigger the protection against against excessive + packet flooding due to software bugs or malicious attack described + in RFC6762 section 6 are delayed instead of discarding as it was + causing responders that implement Passive Observation Of Failures + (POOF) to evict the records. + + Probe responses are now always sent immediately as there were cases + where they would fail to be answered in time to defend a name. + +# 0.33.4 + + - Ensure zeroconf can be loaded when the system disables IPv6 (\#933) + @che0 + +# 0.33.3 + + - Added support for forward dns compression pointers (\#934) @bdraco + - Provide sockname when logging a protocol error (\#935) @bdraco + +# 0.33.2 + + - Handle duplicate goodbye answers in the same packet (\#928) @bdraco + + Solves an exception being thrown when we tried to remove the known + answer from the cache when the second goodbye answer in the same + packet was processed + + Fixed \#926 + + - Skip ipv6 interfaces that return ENODEV (\#930) @bdraco + +# 0.33.1 + + - Version number change only with less restrictive directory + permissions + + Fixed \#923 + +# 0.33.0 + +This release eliminates all threading locks as all non-threadsafe +operations now happen in the event loop. + + - Let connection\_lost close the underlying socket (\#918) @bdraco + + The socket was closed during shutdown before asyncio's + connection\_lost handler had a chance to close it which resulted in + a traceback on windows. + + Fixed \#917 + +Technically backwards incompatible: + + - Removed duplicate unregister\_all\_services code (\#910) @bdraco + + Calling Zeroconf.close from same asyncio event loop zeroconf is + running in will now skip unregister\_all\_services and log a warning + as this a blocking operation and is not async safe and never has + been. + + Use AsyncZeroconf instead, or for legacy code call + async\_unregister\_all\_services before Zeroconf.close + +# 0.32.1 + + - Increased timeout in ServiceInfo.request to handle loaded systems + (\#895) @bdraco + + It can take a few seconds for a loaded system to run the + async\_request coroutine when the + event loop is busy, or the system is CPU bound (example being Home + Assistant startup). We now add an additional + \_LOADED\_SYSTEM\_TIMEOUT (10s) to + the run\_coroutine\_threadsafe calls + to ensure the coroutine has the total amount of time to run up to + its internal timeout (default of 3000ms). + + Ten seconds is a bit large of a timeout; however, it is only used in + cases where we wrap other timeouts. We now expect the only instance + the run\_coroutine\_threadsafe result + timeout will happen in a production circumstance is when someone is + running a ServiceInfo.request() in a + thread and another thread calls + Zeroconf.close() at just the right + moment that the future is never completed unless the system is so + loaded that it is nearly unresponsive. + + The timeout for + run\_coroutine\_threadsafe is the + maximum time a thread can cleanly shut down when zeroconf is closed + out in another thread, which should always be longer than the + underlying thread operation. + +# 0.32.0 + +This release offers 100% line and branch coverage. + + - Made ServiceInfo first question QU (\#852) @bdraco + + We want an immediate response when requesting with ServiceInfo by + asking a QU question; most responders will not delay the response + and respond right away to our question. This also improves + compatibility with split networks as we may not have been able to + see the response otherwise. If the responder has not multicast the + record recently, it may still choose to do so in addition to + responding via unicast + + Reduces traffic when there are multiple zeroconf instances running + on the network running ServiceBrowsers + + If we don't get an answer on the first try, we ask a QM question in + the event, we can't receive a unicast response for some reason + + This change puts ServiceInfo inline with ServiceBrowser which also + asks the first question as QU since ServiceInfo is commonly called + from ServiceBrowser callbacks + + - Limited duplicate packet suppression to 1s intervals (\#841) @bdraco + + Only suppress duplicate packets that happen within the same second. + Legitimate queriers will retry the question if they are suppressed. + The limit was reduced to one second to be in line with rfc6762 + + - Made multipacket known answer suppression per interface (\#836) + @bdraco + + The suppression was happening per instance of Zeroconf instead of + per interface. Since the same network can be seen on multiple + interfaces (usually and wifi and ethernet), this would confuse the + multi-packet known answer supression since it was not expecting to + get the same data more than once + + - New ServiceBrowsers now request QU in the first outgoing when + unspecified (\#812) @bdraco + + When we + start a ServiceBrowser and zeroconf has just started up, the known + answer list will be small. By asking a QU question first, it is + likely that we have a large known answer list by the time we ask the + QM question a second later (current default which is likely too low + but would be a breaking change to increase). This reduces the amount + of traffic on the network, and has the secondary advantage that most + responders will answer a QU question without the typical delay + answering QM questions. + + - IPv6 link-local addresses are now qualified with scope\_id (\#343) + @ibygrave + + When a service is advertised on an IPv6 address where the scope is + link local, i.e. fe80::/64 (see RFC 4007) the resolved IPv6 address + must be extended with the scope\_id that identifies through the "%" + symbol the local interface to be used when routing to that address. + A new API parsed\_scoped\_addresses() + is provided to return qualified addresses to avoid breaking + compatibility on the existing parsed\_addresses(). + + - Network adapters that are disconnected are now skipped (\#327) + @ZLJasonG + + - Fixed listeners missing initial packets if Engine starts too quickly + (\#387) @bdraco + + When manually creating a zeroconf.Engine object, it is no longer + started automatically. It must manually be started by calling + .start() on the created object. + + The Engine thread is now started after all the listeners have been + added to avoid a race condition where packets could be missed at + startup. + + - Fixed answering matching PTR queries with the ANY query (\#618) + @bdraco + + - Fixed lookup of uppercase names in the registry (\#597) @bdraco + + If the ServiceInfo was registered with an uppercase name and the + query was for a lowercase name, it would not be found and + vice-versa. + + - Fixed unicast responses from any source port (\#598) @bdraco + + Unicast responses were only being sent if the source port was 53, + this prevented responses when testing with dig: + + > dig -p 5353 @224.0.0.251 media-12.local + + The above query will now see a response + + - Fixed queries for AAAA records not being answered (\#616) @bdraco + + - Removed second level caching from ServiceBrowsers (\#737) @bdraco + + The ServiceBrowser had its own cache of the last time it saw a + service that was reimplementing the DNSCache and presenting a source + of truth problem that lead to unexpected queries when the two + disagreed. + + - Fixed server cache not being case-insensitive (\#731) @bdraco + + If the server name had uppercase chars and any of the matching + records were lowercase, and the server would not be found + + - Fixed cache handling of records with different TTLs (\#729) @bdraco + + There should only be one unique record in the cache at a time as + having multiple unique records will different TTLs in the cache can + result in unexpected behavior since some functions returned all + matching records and some fetched from the right side of the list to + return the newest record. Instead we now store the records in a dict + to ensure that the newest record always replaces the same unique + record, and we never have a source of truth problem determining the + TTL of a record from the cache. + + - Fixed ServiceInfo with multiple A records (\#725) @bdraco + + If there were multiple A records for the host, ServiceInfo would + always return the last one that was in the incoming packet, which + was usually not the one that was wanted. + + - Fixed stale unique records expiring too quickly (\#706) @bdraco + + Records now expire 1s in the future instead of instant removal. + + tools.ietf.org/html/rfc6762\#section-10.2 Queriers receiving a + Multicast DNS response with a TTL of zero SHOULD NOT immediately + delete the record from the cache, but instead record a TTL of 1 and + then delete the record one second later. In the case of multiple + Multicast DNS responders on the network described in Section 6.6 + above, if one of the responders shuts down and incorrectly sends + goodbye packets for its records, it gives the other cooperating + responders one second to send out their own response to "rescue" the + records before they expire and are deleted. + + - Fixed exception when unregistering a service multiple times (\#679) + @bdraco + + - Added an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to + zeroconf.asyncio (\#658) @bdraco + + - Fixed interface\_index\_to\_ip6\_address not skiping ipv4 adapters + (\#651) @bdraco + + - Added async\_unregister\_all\_services to AsyncZeroconf (\#649) + @bdraco + + - Fixed services not being removed from the registry when calling + unregister\_all\_services (\#644) @bdraco + + There was a race condition where a query could be answered for a + service in the registry, while goodbye packets which could result in + a fresh record being broadcast after the goodbye if a query came in + at just the right time. To avoid this, we now remove the services + from the registry right after we generate the goodbye packet + + - Fixed zeroconf exception on load when the system disables IPv6 + (\#624) @bdraco + + - Fixed the QU bit missing from for probe queries (\#609) @bdraco + + The bit should be set per + datatracker.ietf.org/doc/html/rfc6762\#section-8.1 + + - Fixed the TC bit missing for query packets where the known answers + span multiple packets (\#494) @bdraco + + - Fixed packets not being properly separated when exceeding maximum + size (\#498) @bdraco + + Ensure that questions that exceed the max packet size are moved to + the next packet. This fixes DNSQuestions being sent in multiple + packets in violation of: + datatracker.ietf.org/doc/html/rfc6762\#section-7.2 + + Ensure only one resource record is sent when a record exceeds + \_MAX\_MSG\_TYPICAL + datatracker.ietf.org/doc/html/rfc6762\#section-17 + + - Fixed PTR questions asked in uppercase not being answered (\#465) + @bdraco + + - Added Support for context managers in Zeroconf and AsyncZeroconf + (\#284) @shenek + + - Implemented an AsyncServiceBrowser to compliment the sync + ServiceBrowser (\#429) @bdraco + + - Added async\_get\_service\_info to AsyncZeroconf and async\_request + to AsyncServiceInfo (\#408) @bdraco + + - Implemented allowing passing in a sync Zeroconf instance to + AsyncZeroconf (\#406) @bdraco + + - Fixed IPv6 setup under MacOS when binding to "" (\#392) @bdraco + + - Fixed ZeroconfServiceTypes.find not always cancels the + ServiceBrowser (\#389) @bdraco + + There was a short window where the ServiceBrowser thread could be + left running after Zeroconf is closed because the .join() was never + waited for when a new Zeroconf object was created + + - Fixed duplicate packets triggering duplicate updates (\#376) @bdraco + + If TXT or SRV records update was already processed and then received + again, it was possible for a second update to be called back in the + ServiceBrowser + + - Fixed ServiceStateChange.Updated event happening for IPs that + already existed (\#375) @bdraco + + - Fixed RFC6762 Section 10.2 paragraph 2 compliance (\#374) @bdraco + + - Reduced length of ServiceBrowser thread name with many types (\#373) + @bdraco + + - Fixed empty answers being added in ServiceInfo.request (\#367) + @bdraco + + - Fixed ServiceInfo not populating all AAAA records (\#366) @bdraco + + Use get\_all\_by\_details to ensure all records are loaded into + addresses. + + Only load A/AAAA records from the cache once in load\_from\_cache if + there is a SRV record present + + Move duplicate code that checked if the ServiceInfo was complete + into its own function + + - Fixed a case where the cache list can change during iteration + (\#363) @bdraco + + - Return task objects created by AsyncZeroconf (\#360) @nocarryr + +Traffic Reduction: + + - Added support for handling QU questions (\#621) @bdraco + + Implements RFC 6762 sec 5.4: Questions Requesting Unicast Responses + datatracker.ietf.org/doc/html/rfc6762\#section-5.4 + + - Implemented protect the network against excessive packet flooding + (\#619) @bdraco + + - Additionals are now suppressed when they are already in the answers + section (\#617) @bdraco + + - Additionals are no longer included when the answer is suppressed by + known-answer suppression (\#614) @bdraco + + - Implemented multi-packet known answer supression (\#687) @bdraco + + Implements datatracker.ietf.org/doc/html/rfc6762\#section-7.2 + + - Implemented efficient bucketing of queries with known answers + (\#698) @bdraco + + - Implemented duplicate question suppression (\#770) @bdraco + + + +Technically backwards incompatible: + + - Update internal version check to match docs (3.6+) (\#491) @bdraco + + Python version earlier then 3.6 were likely broken with zeroconf + already, however, the version is now explicitly checked. + + - Update python compatibility as PyPy3 7.2 is required (\#523) @bdraco + +Backwards incompatible: + + - Drop oversize packets before processing them (\#826) @bdraco + + Oversized packets can quickly overwhelm the system and deny service + to legitimate queriers. In practice, this is usually due to broken + mDNS implementations rather than malicious actors. + + - Guard against excessive ServiceBrowser queries from PTR records + significantly lowerthan recommended (\#824) @bdraco + + We now enforce a minimum TTL for PTR records to avoid + ServiceBrowsers generating excessive queries refresh queries. Apple + uses a 15s minimum TTL, however, we do not have the same level of + rate limit and safeguards, so we use 1/4 of the recommended value. + + - RecordUpdateListener now uses async\_update\_records instead of + update\_record (\#419, \#726) @bdraco + + This allows the listener to receive all the records that have been + updated in a single transaction such as a packet or cache expiry. + + update\_record has been deprecated in favor of + async\_update\_records A compatibility shim exists to ensure classes + that use RecordUpdateListener as a base class continue to have + update\_record called, however, they should be updated as soon as + possible. + + A new method async\_update\_records\_complete is now called on each + listener when all listeners have completed processing updates and + the cache has been updated. This allows ServiceBrowsers to delay + calling handlers until they are sure the cache has been updated as + its a common pattern to call for ServiceInfo when a ServiceBrowser + handler fires. + + The async\_ prefix was chosen to make it clear that these functions + run in the eventloop and should never do blocking I/O. Before 0.32+ + these functions ran in a select() loop and should not have been + doing any blocking I/O, but it was not clear to implementors that + I/O would block the loop. + + - Pass both the new and old records to async\_update\_records (\#792) + @bdraco + + Pass the old\_record (cached) as the value and the new\_record + (wire) to async\_update\_records instead of forcing each consumer to + check the cache since we will always have the old\_record when + generating the async\_update\_records call. This avoids the overhead + of multiple cache lookups for each listener. + +# 0.31.0 + + - Separated cache loading from I/O in ServiceInfo and fixed cache + lookup (\#356), thanks to J. Nick Koston. + + The ServiceInfo class gained a load\_from\_cache() method to only + fetch information from Zeroconf cache (if it exists) with no IO + performed. Additionally this should reduce IO in cases where cache + lookups were previously incorrectly failing. + +# 0.30.0 + + - Some nice refactoring work including removal of the Reaper thread, + thanks to J. Nick Koston. + - Fixed a Windows-specific The requested address is not valid in its + context regression, thanks to Timothee ‘TTimo’ Besset and J. Nick + Koston. + - Provided an asyncio-compatible service registration layer (in the + zeroconf.asyncio module), thanks to J. Nick Koston. + +# 0.29.0 + + - A single socket is used for listening on responding when + InterfaceChoice.Default is chosen. + Thanks to J. Nick Koston. + +Backwards incompatible: + + - Dropped Python 3.5 support + +# 0.28.8 + + - Fixed the packet generation when multiple packets are necessary, + previously invalid packets were generated sometimes. Patch thanks to + J. Nick Koston. + +# 0.28.7 + + - Fixed the IPv6 address rendering in the browser example, thanks to + Alexey Vazhnov. + - Fixed a crash happening when a service is added or removed during + handle\_response and improved exception handling, thanks to J. Nick + Koston. + +# 0.28.6 + + - Loosened service name validation when receiving from the network + this lets us handle some real world devices previously causing + errors, thanks to J. Nick Koston. + +# 0.28.5 + + - Enabled ignoring duplicated messages which decreases CPU usage, + thanks to J. Nick Koston. + - Fixed spurious AttributeError: module 'unittest' has no attribute + 'mock' in tests. + +# 0.28.4 + + - Improved cache reaper performance significantly, thanks to J. Nick + Koston. + - Added ServiceListener to \_\_all\_\_ as it's part of the public API, + thanks to Justin Nesselrotte. + +# 0.28.3 + + - Reduced a time an internal lock is held which should eliminate + deadlocks in high-traffic networks, thanks to J. Nick Koston. + +# 0.28.2 + + - Stopped asking questions we already have answers for in cache, + thanks to Paul Daumlechner. + - Removed initial delay before querying for service info, thanks to + Erik Montnemery. + +# 0.28.1 + + - Fixed a resource leak connected to using ServiceBrowser with + multiple types, thanks to + 10. Nick Koston. + +# 0.28.0 + + - Improved Windows support when using socket errno checks, thanks to + Sandy Patterson. + - Added support for passing text addresses to ServiceInfo. + - Improved logging (includes fixing an incorrect logging call) + - Improved Windows compatibility by using Adapter.index from ifaddr, + thanks to PhilippSelenium. + - Improved Windows compatibility by stopping using + socket.if\_nameindex. + - Fixed an OS X edge case which should also eliminate a memory leak, + thanks to Emil Styrke. + +Technically backwards incompatible: + + - `ifaddr` 0.1.7 or newer is required now. + +## 0.27.1 + + - Improved the logging situation (includes fixing a false-positive + "packets() made no progress adding records", thanks to Greg Badros) + +## 0.27.0 + + - Large multi-resource responses are now split into separate packets + which fixes a bad mdns-repeater/ChromeCast Audio interaction ending + with ChromeCast Audio crash (and possibly some others) and improves + RFC 6762 compliance, thanks to Greg Badros + - Added a warning presented when the listener passed to ServiceBrowser + lacks update\_service() callback + - Added support for finding all services available in the browser + example, thanks to Perry Kunder + +Backwards incompatible: + + - Removed previously deprecated ServiceInfo address constructor + parameter and property + +## 0.26.3 + + - Improved readability of logged incoming data, thanks to Erik + Montnemery + - Threads are given unique names now to aid debugging, thanks to Erik + Montnemery + - Fixed a regression where get\_service\_info() called within a + listener add\_service method would deadlock, timeout and incorrectly + return None, fix thanks to Erik Montnemery, but Matt Saxon and + Hmmbob were also involved in debugging it. + +## 0.26.2 + + - Added support for multiple types to ServiceBrowser, thanks to J. + Nick Koston + - Fixed a race condition where a listener gets a message before the + lock is created, thanks to + 10. Nick Koston + +## 0.26.1 + + - Fixed a performance regression introduced in 0.26.0, thanks to J. + Nick Koston (this is close in spirit to an optimization made in + 0.24.5 by the same author) + +## 0.26.0 + + - Fixed a regression where service update listener wasn't called on IP + address change (it's called on SRV/A/AAAA record changes now), + thanks to Matt Saxon + +Technically backwards incompatible: + + - Service update hook is no longer called on service addition (service + added hook is still called), this is related to the fix above + +## 0.25.1 + + - Eliminated 5s hangup when calling Zeroconf.close(), thanks to Erik + Montnemery + +## 0.25.0 + + - Reverted uniqueness assertions when browsing, they caused a + regression + +Backwards incompatible: + + - Rationalized handling of TXT records. Non-bytes values are converted + to str and encoded to bytes using UTF-8 now, None values mean + value-less attributes. When receiving TXT records no decoding is + performed now, keys are always bytes and values are either bytes or + None in value-less attributes. + +## 0.24.5 + + - Fixed issues with shared records being used where they shouldn't be + (TXT, SRV, A records are unique now), thanks to Matt Saxon + - Stopped unnecessarily excluding host-only interfaces from + InterfaceChoice.all as they don't forbid multicast, thanks to + Andreas Oberritter + - Fixed repr() of IPv6 DNSAddress, thanks to Aldo Hoeben + - Removed duplicate update messages sent to listeners, thanks to Matt + Saxon + - Added support for cooperating responders, thanks to Matt Saxon + - Optimized handle\_response cache check, thanks to J. Nick Koston + - Fixed memory leak in DNSCache, thanks to J. Nick Koston + +## 0.24.4 + + - Fixed resetting TTL in DNSRecord.reset\_ttl(), thanks to Matt Saxon + - Improved various DNS class' string representations, thanks to Jay + Hogg + +## 0.24.3 + + - Fixed import-time "TypeError: 'ellipsis' object is not iterable." on + CPython 3.5.2 + +## 0.24.2 + + - Added support for AWDL interface on macOS (needed and used by the + opendrop project but should be useful in general), thanks to Milan + Stute + - Added missing type hints + +## 0.24.1 + + - Applied some significant performance optimizations, thanks to Jaime + van Kessel for the patch and to Ghostkeeper for performance + measurements + - Fixed flushing outdated cache entries when incoming record is + unique, thanks to Michael Hu + - Fixed handling updates of TXT records (they'd not get recorded + previously), thanks to Michael Hu + +## 0.24.0 + + - Added IPv6 support, thanks to Dmitry Tantsur + - Added additional recommended records to PTR responses, thanks to + Scott Mertz + - Added handling of ENOTCONN being raised during shutdown when using + Eventlet, thanks to Tamás Nepusz + - Included the py.typed marker in the package so that type checkers + know to use type hints from the source code, thanks to Dmitry + Tantsur + +## 0.23.0 + + - Added support for MyListener call getting updates to service TXT + records, thanks to Matt Saxon + - Added support for multiple addresses when publishing a service, + getting/setting single address has become deprecated. Change thanks + to Dmitry Tantsur + +Backwards incompatible: + + - Dropped Python 3.4 support + +## 0.22.0 + + - A lot of maintenance work (tooling, typing coverage and + improvements, spelling) done, thanks to Ville Skyttä + - Provided saner defaults in ServiceInfo's constructor, thanks to + Jorge Miranda + - Fixed service removal packets not being sent on shutdown, thanks to + Andrew Bonney + - Added a way to define TTL-s through ServiceInfo contructor + parameters, thanks to Andrew Bonney + +Technically backwards incompatible: + + - Adjusted query intervals to match RFC 6762, thanks to Andrew Bonney + - Made default TTL-s match RFC 6762, thanks to Andrew Bonney + +## 0.21.3 + + - This time really allowed incoming service names to contain + underscores (patch released as part of 0.21.0 was defective) + +## 0.21.2 + + - Fixed import-time typing-related TypeError when older typing version + is used + +## 0.21.1 + + - Fixed installation on Python 3.4 (we use typing now but there was no + explicit dependency on it) + +## 0.21.0 + + - Added an error message when importing the package using unsupported + Python version + - Fixed TTL handling for published service + - Implemented unicast support + - Fixed WSL (Windows Subsystem for Linux) compatibility + - Fixed occasional UnboundLocalError issue + - Fixed UTF-8 multibyte name compression + - Switched from netifaces to ifaddr (pure Python) + - Allowed incoming service names to contain underscores + +## 0.20.0 + + - Dropped support for Python 2 (this includes PyPy) and 3.3 + - Fixed some class' equality operators + - ServiceBrowser entries are being refreshed when 'stale' now + - Cache returns new records first now instead of last + +## 0.19.1 + + - Allowed installation with netifaces \>= 0.10.6 (a bug that was + concerning us got fixed) + +## 0.19.0 + + - Technically backwards incompatible - restricted netifaces dependency + version to work around a bug, see + for details + +## 0.18.0 + + - Dropped Python 2.6 support + - Improved error handling inside code executed when Zeroconf object is + being closed + +## 0.17.7 + + - Better Handling of DNS Incoming Packets parsing exceptions + - Many exceptions will now log a warning the first time they are seen + - Catch and log sendto() errors + - Fix/Implement duplicate name change + - Fix overly strict name validation introduced in 0.17.6 + - Greatly improve handling of oversized packets including: + - Implement name compression per RFC1035 + - Limit size of generated packets to 9000 bytes as per RFC6762 + - Better handle over sized incoming packets + - Increased test coverage to 95% + +## 0.17.6 + + - Many improvements to address race conditions and exceptions during + ZC() startup and shutdown, thanks to: morpav, veawor, justingiorgi, + herczy, stephenrauch + - Added more test coverage: strahlex, stephenrauch + - Stephen Rauch contributed: + - Speed up browser startup + - Add ZeroconfServiceTypes() query class to discover all + advertised service types + - Add full validation for service names, types and subtypes + - Fix for subtype browsing + - Fix DNSHInfo support + +## 0.17.5 + + - Fixed OpenBSD compatibility, thanks to Alessio Sergi + - Fixed race condition on ServiceBrowser startup, thanks to gbiddison + - Fixed installation on some Python 3 systems, thanks to Per Sandström + - Fixed "size change during iteration" bug on Python 3, thanks to + gbiddison + +## 0.17.4 + + - Fixed support for Linux kernel versions \< 3.9 (thanks to Giovanni + Harting and Luckydonald, GitHub pull request \#26) + +## 0.17.3 + + - Fixed DNSText repr on Python 3 (it'd crash when the text was longer + than 10 bytes), thanks to Paulus Schoutsen for the patch, GitHub + pull request \#24 + +## 0.17.2 + + - Fixed installation on Python 3.4.3+ (was failing because of enum34 + dependency which fails to install on 3.4.3+, changed to depend on + enum-compat instead; thanks to Michael Brennan for the original + patch, GitHub pull request \#22) + +## 0.17.1 + + - Fixed EADDRNOTAVAIL when attempting to use dummy network interfaces + on Windows, thanks to daid + +## 0.17.0 + + - Added some Python dependencies so it's not zero-dependencies anymore + - Improved exception handling (it'll be quieter now) + - Messages are listened to and sent using all available network + interfaces by default (configurable); thanks to Marcus Müller + - Started using logging more freely + - Fixed a bug with binary strings as property values being converted + to False (); + thanks to Dr. Seuss + - Added new `ServiceBrowser` event handler interface (see the + examples) + - PyPy3 now officially supported + - Fixed ServiceInfo repr on Python 3, thanks to Yordan Miladinov + +## 0.16.0 + + - Set up Python logging and started using it + - Cleaned up code style (includes migrating from camel case to snake + case) + +## 0.15.1 + + - Fixed handling closed socket (GitHub \#4) + +## 0.15 + + - Forked by Jakub Stasiak + - Made Python 3 compatible + - Added setup script, made installable by pip and uploaded to PyPI + - Set up Travis build + - Reformatted the code and moved files around + - Stopped catching BaseException in several places, that could hide + errors + - Marked threads as daemonic, they won't keep application alive now + +## 0.14 + + - Fix for SOL\_IP undefined on some systems - thanks Mike Erdely. + - Cleaned up examples. + - Lowercased module name. + +## 0.13 + + - Various minor changes; see git for details. + - No longer compatible with Python 2.2. Only tested with 2.5-2.7. + - Fork by William McBrine. + +## 0.12 + + - allow selection of binding interface + - typo fix - Thanks A. M. Kuchlingi + - removed all use of word 'Rendezvous' - this is an API change + +## 0.11 + + - correction to comments for addListener method + - support for new record types seen from OS X + - IPv6 address + - hostinfo + - ignore unknown DNS record types + - fixes to name decoding + - works alongside other processes using port 5353 (e.g. on Mac OS X) + - tested against Mac OS X 10.3.2's mDNSResponder + - corrections to removal of list entries for service browser + +## 0.10 + + - Jonathon Paisley contributed these corrections: + - always multicast replies, even when query is unicast + - correct a pointer encoding problem + - can now write records in any order + - traceback shown on failure + - better TXT record parsing + - server is now separate from name + - can cancel a service browser + - modified some unit tests to accommodate these changes + +## 0.09 + + - remove all records on service unregistration + - fix DOS security problem with readName + +## 0.08 + + - changed licensing to LGPL + +## 0.07 + + - faster shutdown on engine + - pointer encoding of outgoing names + - ServiceBrowser now works + - new unit tests + +## 0.06 + + - small improvements with unit tests + - added defined exception types + - new style objects + - fixed hostname/interface problem + - fixed socket timeout problem + - fixed add\_service\_listener() typo bug + - using select() for socket reads + - tested on Debian unstable with Python 2.2.2 + +## 0.05 + + - ensure case insensitivty on domain names + - support for unicast DNS queries + +## 0.04 + + - added some unit tests + - added \_\_ne\_\_ adjuncts where required + - ensure names end in '.local.' + - timeout on receiving socket for clean shutdown diff --git a/COPYING b/COPYING index 2cba2ac74..3e57b08d5 100644 --- a/COPYING +++ b/COPYING @@ -55,7 +55,7 @@ modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - + Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a @@ -111,7 +111,7 @@ modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - + GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION @@ -146,7 +146,7 @@ such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - + 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an @@ -158,7 +158,7 @@ Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - + 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 @@ -216,7 +216,7 @@ instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - + Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. @@ -267,7 +267,7 @@ Library will still fall under Section 6.) distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - + 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work @@ -329,7 +329,7 @@ restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - + 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined @@ -370,7 +370,7 @@ subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - + 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or @@ -422,7 +422,7 @@ conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - + 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is diff --git a/Makefile b/Makefile index 2d2bce124..95b371566 100644 --- a/Makefile +++ b/Makefile @@ -1,53 +1,5 @@ # version: 1.3 -.PHONY: all virtualenv -MAX_LINE_LENGTH=110 -PYTHON_IMPLEMENTATION:=$(shell python -c "import sys;import platform;sys.stdout.write(platform.python_implementation())") -PYTHON_VERSION:=$(shell python -c "import sys;sys.stdout.write('%d.%d' % sys.version_info[:2])") - -LINT_TARGETS:=flake8 - -ifneq ($(findstring PyPy,$(PYTHON_IMPLEMENTATION)),PyPy) - LINT_TARGETS:=$(LINT_TARGETS) mypy black_check pylint -endif - - -virtualenv: ./env/requirements.built - -env: - python -m venv env - -./env/requirements.built: env requirements-dev.txt - ./env/bin/pip install -r requirements-dev.txt - cp requirements-dev.txt ./env/requirements.built - -.PHONY: ci -ci: lint test_coverage - -.PHONY: lint -lint: $(LINT_TARGETS) - -flake8: - flake8 --max-line-length=$(MAX_LINE_LENGTH) setup.py examples zeroconf - -pylint: - pylint zeroconf - -.PHONY: black_check -black_check: - black --check setup.py examples zeroconf - -mypy: -# --no-warn-redundant-casts --no-warn-unused-ignores is needed since we support multiple python versions -# We should be able to drop this once python 3.6 goes away - mypy --no-warn-redundant-casts --no-warn-unused-ignores examples/*.py zeroconf test: - pytest --durations=20 --timeout=60 -v tests - -test_coverage: - pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - -autopep8: - autopep8 --max-line-length=$(MAX_LINE_LENGTH) -i setup.py examples zeroconf - + poetry run pytest --durations=20 --timeout=60 -v tests diff --git a/README.rst b/README.rst index d376d9a37..5cc5a91b7 100644 --- a/README.rst +++ b/README.rst @@ -1,17 +1,17 @@ python-zeroconf =============== -.. image:: https://github.com/jstasiak/python-zeroconf/workflows/CI/badge.svg - :target: https://github.com/jstasiak/python-zeroconf?query=workflow%3ACI+branch%3Amaster +.. image:: https://github.com/python-zeroconf/python-zeroconf/workflows/CI/badge.svg + :target: https://github.com/python-zeroconf/python-zeroconf?query=workflow%3ACI+branch%3Amaster .. image:: https://img.shields.io/pypi/v/zeroconf.svg :target: https://pypi.python.org/pypi/zeroconf -.. image:: https://codecov.io/gh/jstasiak/python-zeroconf/branch/master/graph/badge.svg - :target: https://codecov.io/gh/jstasiak/python-zeroconf +.. image:: https://codecov.io/gh/python-zeroconf/python-zeroconf/branch/master/graph/badge.svg + :target: https://codecov.io/gh/python-zeroconf/python-zeroconf `Documentation `_. - + This is fork of pyzeroconf, Multicast DNS Service Discovery for Python, originally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf), modified by William McBrine (https://github.com/wmcbrine/pyzeroconf). @@ -22,7 +22,7 @@ The original William McBrine's fork note:: (and therefore HME/VLC), Network Remote, Remote Proxy, and pyTivo. Before this, I was tracking the changes for zeroconf.py in three separate repos. I figured I should have an authoritative source. - + Although I make changes based on my experience with TiVos, I expect that they're generally applicable. This version also includes patches found on the now-defunct (?) Launchpad repo of pyzeroconf, and elsewhere @@ -40,6 +40,7 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: * doesn't force you to use particular event loop or Twisted (asyncio is used under the hood but not required) * is pip-installable * has PyPI distribution +* has an optional cython extension for performance (pure python is supported as well) Python compatibility -------------------- @@ -50,11 +51,7 @@ Python compatibility Versioning ---------- -This project's versions follow the following pattern: MAJOR.MINOR.PATCH. - -* MAJOR version has been 0 so far -* MINOR version is incremented on backward incompatible changes -* PATCH version is incremented on backward compatible changes +This project uses semantic versioning. Status ------ @@ -83,8 +80,8 @@ IPv6 support is relatively new and currently limited, specifically: How to get python-zeroconf? =========================== -* PyPI page https://pypi.python.org/pypi/zeroconf -* GitHub project https://github.com/jstasiak/python-zeroconf +* PyPI page https://pypi.org/project/zeroconf/ +* GitHub project https://github.com/python-zeroconf/python-zeroconf The easiest way to install python-zeroconf is using pip:: @@ -100,21 +97,21 @@ Here's an example of browsing for a service: .. code-block:: python from zeroconf import ServiceBrowser, ServiceListener, Zeroconf - - + + class MyListener(ServiceListener): - + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: print(f"Service {name} updated") - + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: print(f"Service {name} removed") - + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: info = zc.get_service_info(type_, name) print(f"Service {name} added, service info: {info}") - - + + zeroconf = Zeroconf() listener = MyListener() browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) @@ -141,1117 +138,7 @@ See examples directory for more. Changelog ========= -0.39.4 -====== - -* Fix IP changes being missed by ServiceInfo (#1102) @bdraco - - -0.39.3 -====== - -* Fix port changes not being seen by ServiceInfo (#1100) @bdraco - - -0.39.2 -====== - -* Performance improvements for parsing incoming packet data (#1095) (#1097) @bdraco - - -0.39.1 -====== - -* Performance improvements for constructing outgoing packet data (#1090) @bdraco - - -0.39.0 -====== - -Technically backwards incompatible: - -* Switch to using async_timeout for timeouts (#1081) @bdraco - - Significantly reduces the number of asyncio tasks that are created when - using `ServiceInfo` or `AsyncServiceInfo` - - -0.38.7 -====== - -* Performance improvements for parsing incoming packet data (#1076) @bdraco - - -0.38.6 -====== - -* Performance improvements for fetching ServiceInfo (#1068) @bdraco - - -0.38.5 -====== - -* Fix ServiceBrowsers not getting ServiceStateChange.Removed callbacks on PTR record expire (#1064) @bdraco - - ServiceBrowsers were only getting a `ServiceStateChange.Removed` callback - when the record was sent with a TTL of 0. ServiceBrowsers now correctly - get a `ServiceStateChange.Removed` callback when the record expires as well. -* Fix missing minimum version of python 3.7 (#1060) @stevencrader - - -0.38.4 -====== - -* Fix IP Address updates when hostname is uppercase (#1057) @bdraco - - ServiceBrowsers would not callback updates when the ip address changed - if the hostname contained uppercase characters - - -0.38.3 -====== - -Version bump only, no changes from 0.38.2 - -0.38.2 -====== - -* Make decode errors more helpful in finding the source of the bad data (#1052) @bdraco - -0.38.1 -====== - -* Improve performance of query scheduler (#1043) @bdraco -* Avoid linear type searches in ServiceBrowsers (#1044) @bdraco - -0.38.0 -====== - -* Handle Service types that end with another service type (#1041) @apworks1 - -Backwards incompatible: - -* Dropped Python 3.6 support (#1009) @bdraco - -0.37.0 -====== - -Technically backwards incompatible: - -* Adding a listener that does not inherit from RecordUpdateListener now logs an error (#1034) @bdraco -* The NotRunningException exception is now thrown when Zeroconf is not running (#1033) @bdraco - - Before this change the consumer would get a timeout or an EventLoopBlocked - exception when calling `ServiceInfo.*request` when the instance had already been shutdown - or had failed to startup. - -* The EventLoopBlocked exception is now thrown when a coroutine times out (#1032) @bdraco - - Previously `concurrent.futures.TimeoutError` would have been raised - instead. This is never expected to happen during normal operation. - -0.36.13 -======= - -* Unavailable interfaces are now skipped during socket bind (#1028) @bdraco -* Downgraded incoming corrupt packet logging to debug (#1029) @bdraco - - Warning about network traffic we have no control over is confusing - to users as they think there is something wrong with zeroconf - -0.36.12 -======= - -* Prevented service lookups from deadlocking if time abruptly moves backwards (#1006) @bdraco - - The typical reason time moves backwards is via an ntp update - -0.36.11 -======= - -No functional changes from 0.36.10. This release corrects an error in the README.rst file -that prevented the build from uploading to PyPI - -0.36.10 -======= - -* scope_id is now stripped from IPv6 addresses if given (#1020) @StevenLooman - - cpython 3.9 allows a suffix %scope_id in IPv6Address. This caused an error - with the existing code if it was not stripped -* Optimized decoding labels from incoming packets (#1019) @bdraco - -0.36.9 -====== - -* Ensure ServiceInfo orders newest addresses first (#1012) @bdraco - - This change effectively restored the behavior before 1s cache flush - expire behavior described in rfc6762 section 10.2 was added for callers that rely on this. - -0.36.8 -====== - -* Fixed ServiceBrowser infinite loop when zeroconf is closed before it is canceled (#1008) @bdraco - -0.36.7 -====== - -* Improved performance of responding to queries (#994) (#996) (#997) @bdraco -* Improved log message when receiving an invalid or corrupt packet (#998) @bdraco - -0.36.6 -====== - -* Improved performance of sending outgoing packets (#990) @bdraco - -0.36.5 -====== - -* Reduced memory usage for incoming and outgoing packets (#987) @bdraco - -0.36.4 -====== - -* Improved performance of constructing outgoing packets (#978) (#979) @bdraco -* Deferred parsing of incoming packets when it can be avoided (#983) @bdraco - -0.36.3 -====== - -* Improved performance of parsing incoming packets (#975) @bdraco - -0.36.2 -====== - -* Include NSEC records for non-existent types when responding with addresses (#972) (#971) @bdraco - Implements RFC6762 sec 6.2 (http://datatracker.ietf.org/doc/html/rfc6762#section-6.2) - -0.36.1 -====== - -* Skip goodbye packets for addresses when there is another service registered with the same name (#968) @bdraco - - If a ServiceInfo that used the same server name as another ServiceInfo - was unregistered, goodbye packets would be sent for the addresses and - would cause the other service to be seen as offline. -* Fixed equality and hash for dns records with the unique bit (#969) @bdraco - - These records should have the same hash and equality since - the unique bit (cache flush bit) is not considered when adding or removing - the records from the cache. - -0.36.0 -====== - -Technically backwards incompatible: - -* Fill incomplete IPv6 tuples to avoid WinError on windows (#965) @lokesh2019 - - Fixed #932 - -0.35.1 -====== - -* Only reschedule types if the send next time changes (#958) @bdraco - - When the PTR response was seen again, the timer was being canceled and - rescheduled even if the timer was for the same time. While this did - not cause any breakage, it is quite inefficient. -* Cache DNS record and question hashes (#960) @bdraco - - The hash was being recalculated every time the object - was being used in a set or dict. Since the hashes are - effectively immutable, we only calculate them once now. - -0.35.0 -====== - -* Reduced chance of accidental synchronization of ServiceInfo requests (#955) @bdraco -* Sort aggregated responses to increase chance of name compression (#954) @bdraco - -Technically backwards incompatible: - -* Send unicast replies on the same socket the query was received (#952) @bdraco - - When replying to a QU question, we do not know if the sending host is reachable - from all of the sending sockets. We now avoid this problem by replying via - the receiving socket. This was the existing behavior when `InterfaceChoice.Default` - is set. - - This change extends the unicast relay behavior to used with `InterfaceChoice.Default` - to apply when `InterfaceChoice.All` or interfaces are explicitly passed when - instantiating a `Zeroconf` instance. - - Fixes #951 - -0.34.3 -====== - -* Fix sending immediate multicast responses (#949) @bdraco - -0.34.2 -====== - -* Coalesce aggregated multicast answers (#945) @bdraco - - When the random delay is shorter than the last scheduled response, - answers are now added to the same outgoing time group. - - This reduces traffic when we already know we will be sending a group of answers - inside the random delay window described in - datatracker.ietf.org/doc/html/rfc6762#section-6.3 -* Ensure ServiceInfo requests can be answered inside the default timeout with network protection (#946) @bdraco - - Adjust the time windows to ensure responses that have triggered the - protection against against excessive packet flooding due to - software bugs or malicious attack described in RFC6762 section 6 - can respond in under 1350ms to ensure ServiceInfo can ask two - questions within the default timeout of 3000ms - -0.34.1 -====== - -* Ensure multicast aggregation sends responses within 620ms (#942) @bdraco - - Responses that trigger the protection against against excessive - packet flooding due to software bugs or malicious attack described - in RFC6762 section 6 could cause the multicast aggregation response - to be delayed longer than 620ms (The maximum random delay of 120ms - and 500ms additional for aggregation). - - Only responses that trigger the protection are delayed longer than 620ms - -0.34.0 -====== - -* Implemented Multicast Response Aggregation (#940) @bdraco - - Responses are now aggregated when possible per rules in RFC6762 - section 6.4 - - Responses that trigger the protection against against excessive - packet flooding due to software bugs or malicious attack described - in RFC6762 section 6 are delayed instead of discarding as it was - causing responders that implement Passive Observation Of Failures - (POOF) to evict the records. - - Probe responses are now always sent immediately as there were cases - where they would fail to be answered in time to defend a name. - -0.33.4 -====== - -* Ensure zeroconf can be loaded when the system disables IPv6 (#933) @che0 - -0.33.3 -====== - -* Added support for forward dns compression pointers (#934) @bdraco -* Provide sockname when logging a protocol error (#935) @bdraco - -0.33.2 -====== - -* Handle duplicate goodbye answers in the same packet (#928) @bdraco - - Solves an exception being thrown when we tried to remove the known answer - from the cache when the second goodbye answer in the same packet was processed - - Fixed #926 -* Skip ipv6 interfaces that return ENODEV (#930) @bdraco - -0.33.1 -====== - -* Version number change only with less restrictive directory permissions - - Fixed #923 - -0.33.0 -====== - -This release eliminates all threading locks as all non-threadsafe operations -now happen in the event loop. - -* Let connection_lost close the underlying socket (#918) @bdraco - - The socket was closed during shutdown before asyncio's connection_lost - handler had a chance to close it which resulted in a traceback on - windows. - - Fixed #917 - -Technically backwards incompatible: - -* Removed duplicate unregister_all_services code (#910) @bdraco - - Calling Zeroconf.close from same asyncio event loop zeroconf is running in - will now skip unregister_all_services and log a warning as this a blocking - operation and is not async safe and never has been. - - Use AsyncZeroconf instead, or for legacy code call async_unregister_all_services before Zeroconf.close - -0.32.1 -====== - -* Increased timeout in ServiceInfo.request to handle loaded systems (#895) @bdraco - - It can take a few seconds for a loaded system to run the `async_request` - coroutine when the event loop is busy, or the system is CPU bound (example being - Home Assistant startup). We now add an additional `_LOADED_SYSTEM_TIMEOUT` (10s) - to the `run_coroutine_threadsafe` calls to ensure the coroutine has the total - amount of time to run up to its internal timeout (default of 3000ms). - - Ten seconds is a bit large of a timeout; however, it is only used in cases - where we wrap other timeouts. We now expect the only instance the - `run_coroutine_threadsafe` result timeout will happen in a production - circumstance is when someone is running a `ServiceInfo.request()` in a thread and - another thread calls `Zeroconf.close()` at just the right moment that the future - is never completed unless the system is so loaded that it is nearly unresponsive. - - The timeout for `run_coroutine_threadsafe` is the maximum time a thread can - cleanly shut down when zeroconf is closed out in another thread, which should - always be longer than the underlying thread operation. - -0.32.0 -====== - -This release offers 100% line and branch coverage. - -* Made ServiceInfo first question QU (#852) @bdraco - - We want an immediate response when requesting with ServiceInfo - by asking a QU question; most responders will not delay the response - and respond right away to our question. This also improves compatibility - with split networks as we may not have been able to see the response - otherwise. If the responder has not multicast the record recently, - it may still choose to do so in addition to responding via unicast - - Reduces traffic when there are multiple zeroconf instances running - on the network running ServiceBrowsers - - If we don't get an answer on the first try, we ask a QM question - in the event, we can't receive a unicast response for some reason - - This change puts ServiceInfo inline with ServiceBrowser which - also asks the first question as QU since ServiceInfo is commonly - called from ServiceBrowser callbacks -* Limited duplicate packet suppression to 1s intervals (#841) @bdraco - - Only suppress duplicate packets that happen within the same - second. Legitimate queriers will retry the question if they - are suppressed. The limit was reduced to one second to be - in line with rfc6762 -* Made multipacket known answer suppression per interface (#836) @bdraco - - The suppression was happening per instance of Zeroconf instead - of per interface. Since the same network can be seen on multiple - interfaces (usually and wifi and ethernet), this would confuse the - multi-packet known answer supression since it was not expecting - to get the same data more than once -* New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) @bdraco - - https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 - When we start a ServiceBrowser and zeroconf has just started up, the known - answer list will be small. By asking a QU question first, it is likely - that we have a large known answer list by the time we ask the QM question - a second later (current default which is likely too low but would be - a breaking change to increase). This reduces the amount of traffic on - the network, and has the secondary advantage that most responders will - answer a QU question without the typical delay answering QM questions. -* IPv6 link-local addresses are now qualified with scope_id (#343) @ibygrave - - When a service is advertised on an IPv6 address where - the scope is link local, i.e. fe80::/64 (see RFC 4007) - the resolved IPv6 address must be extended with the - scope_id that identifies through the "%" symbol the - local interface to be used when routing to that address. - A new API `parsed_scoped_addresses()` is provided to - return qualified addresses to avoid breaking compatibility - on the existing parsed_addresses(). -* Network adapters that are disconnected are now skipped (#327) @ZLJasonG -* Fixed listeners missing initial packets if Engine starts too quickly (#387) @bdraco - - When manually creating a zeroconf.Engine object, it is no longer started automatically. - It must manually be started by calling .start() on the created object. - - The Engine thread is now started after all the listeners have been added to avoid a - race condition where packets could be missed at startup. -* Fixed answering matching PTR queries with the ANY query (#618) @bdraco -* Fixed lookup of uppercase names in the registry (#597) @bdraco - - If the ServiceInfo was registered with an uppercase name and the query was - for a lowercase name, it would not be found and vice-versa. -* Fixed unicast responses from any source port (#598) @bdraco - - Unicast responses were only being sent if the source port - was 53, this prevented responses when testing with dig: - - dig -p 5353 @224.0.0.251 media-12.local - - The above query will now see a response -* Fixed queries for AAAA records not being answered (#616) @bdraco -* Removed second level caching from ServiceBrowsers (#737) @bdraco - - The ServiceBrowser had its own cache of the last time it - saw a service that was reimplementing the DNSCache and - presenting a source of truth problem that lead to unexpected - queries when the two disagreed. -* Fixed server cache not being case-insensitive (#731) @bdraco - - If the server name had uppercase chars and any of the - matching records were lowercase, and the server would not be - found -* Fixed cache handling of records with different TTLs (#729) @bdraco - - There should only be one unique record in the cache at - a time as having multiple unique records will different - TTLs in the cache can result in unexpected behavior since - some functions returned all matching records and some - fetched from the right side of the list to return the - newest record. Instead we now store the records in a dict - to ensure that the newest record always replaces the same - unique record, and we never have a source of truth problem - determining the TTL of a record from the cache. -* Fixed ServiceInfo with multiple A records (#725) @bdraco - - If there were multiple A records for the host, ServiceInfo - would always return the last one that was in the incoming - packet, which was usually not the one that was wanted. -* Fixed stale unique records expiring too quickly (#706) @bdraco - - Records now expire 1s in the future instead of instant removal. - - tools.ietf.org/html/rfc6762#section-10.2 - Queriers receiving a Multicast DNS response with a TTL of zero SHOULD - NOT immediately delete the record from the cache, but instead record - a TTL of 1 and then delete the record one second later. In the case - of multiple Multicast DNS responders on the network described in - Section 6.6 above, if one of the responders shuts down and - incorrectly sends goodbye packets for its records, it gives the other - cooperating responders one second to send out their own response to - "rescue" the records before they expire and are deleted. -* Fixed exception when unregistering a service multiple times (#679) @bdraco -* Added an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.asyncio (#658) @bdraco -* Fixed interface_index_to_ip6_address not skiping ipv4 adapters (#651) @bdraco -* Added async_unregister_all_services to AsyncZeroconf (#649) @bdraco -* Fixed services not being removed from the registry when calling unregister_all_services (#644) @bdraco - - There was a race condition where a query could be answered for a service - in the registry, while goodbye packets which could result in a fresh record - being broadcast after the goodbye if a query came in at just the right - time. To avoid this, we now remove the services from the registry right - after we generate the goodbye packet -* Fixed zeroconf exception on load when the system disables IPv6 (#624) @bdraco -* Fixed the QU bit missing from for probe queries (#609) @bdraco - - The bit should be set per - datatracker.ietf.org/doc/html/rfc6762#section-8.1 - -* Fixed the TC bit missing for query packets where the known answers span multiple packets (#494) @bdraco -* Fixed packets not being properly separated when exceeding maximum size (#498) @bdraco - - Ensure that questions that exceed the max packet size are - moved to the next packet. This fixes DNSQuestions being - sent in multiple packets in violation of: - datatracker.ietf.org/doc/html/rfc6762#section-7.2 - - Ensure only one resource record is sent when a record - exceeds _MAX_MSG_TYPICAL - datatracker.ietf.org/doc/html/rfc6762#section-17 -* Fixed PTR questions asked in uppercase not being answered (#465) @bdraco -* Added Support for context managers in Zeroconf and AsyncZeroconf (#284) @shenek -* Implemented an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) @bdraco -* Added async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) @bdraco -* Implemented allowing passing in a sync Zeroconf instance to AsyncZeroconf (#406) @bdraco -* Fixed IPv6 setup under MacOS when binding to "" (#392) @bdraco -* Fixed ZeroconfServiceTypes.find not always cancels the ServiceBrowser (#389) @bdraco - - There was a short window where the ServiceBrowser thread - could be left running after Zeroconf is closed because - the .join() was never waited for when a new Zeroconf - object was created -* Fixed duplicate packets triggering duplicate updates (#376) @bdraco - - If TXT or SRV records update was already processed and then - received again, it was possible for a second update to be - called back in the ServiceBrowser -* Fixed ServiceStateChange.Updated event happening for IPs that already existed (#375) @bdraco -* Fixed RFC6762 Section 10.2 paragraph 2 compliance (#374) @bdraco -* Reduced length of ServiceBrowser thread name with many types (#373) @bdraco -* Fixed empty answers being added in ServiceInfo.request (#367) @bdraco -* Fixed ServiceInfo not populating all AAAA records (#366) @bdraco - - Use get_all_by_details to ensure all records are loaded - into addresses. - - Only load A/AAAA records from the cache once in load_from_cache - if there is a SRV record present - - Move duplicate code that checked if the ServiceInfo was complete - into its own function -* Fixed a case where the cache list can change during iteration (#363) @bdraco -* Return task objects created by AsyncZeroconf (#360) @nocarryr - -Traffic Reduction: - -* Added support for handling QU questions (#621) @bdraco - - Implements RFC 6762 sec 5.4: - Questions Requesting Unicast Responses - datatracker.ietf.org/doc/html/rfc6762#section-5.4 -* Implemented protect the network against excessive packet flooding (#619) @bdraco -* Additionals are now suppressed when they are already in the answers section (#617) @bdraco -* Additionals are no longer included when the answer is suppressed by known-answer suppression (#614) @bdraco -* Implemented multi-packet known answer supression (#687) @bdraco - - Implements datatracker.ietf.org/doc/html/rfc6762#section-7.2 -* Implemented efficient bucketing of queries with known answers (#698) @bdraco -* Implemented duplicate question suppression (#770) @bdraco - - http://datatracker.ietf.org/doc/html/rfc6762#section-7.3 - -Technically backwards incompatible: - -* Update internal version check to match docs (3.6+) (#491) @bdraco - - Python version earlier then 3.6 were likely broken with zeroconf - already, however, the version is now explicitly checked. -* Update python compatibility as PyPy3 7.2 is required (#523) @bdraco - -Backwards incompatible: - -* Drop oversize packets before processing them (#826) @bdraco - - Oversized packets can quickly overwhelm the system and deny - service to legitimate queriers. In practice, this is usually due to broken mDNS - implementations rather than malicious actors. -* Guard against excessive ServiceBrowser queries from PTR records significantly lowerthan recommended (#824) @bdraco - - We now enforce a minimum TTL for PTR records to avoid - ServiceBrowsers generating excessive queries refresh queries. - Apple uses a 15s minimum TTL, however, we do not have the same - level of rate limit and safeguards, so we use 1/4 of the recommended value. -* RecordUpdateListener now uses async_update_records instead of update_record (#419, #726) @bdraco - - This allows the listener to receive all the records that have - been updated in a single transaction such as a packet or - cache expiry. - - update_record has been deprecated in favor of async_update_records - A compatibility shim exists to ensure classes that use - RecordUpdateListener as a base class continue to have - update_record called, however, they should be updated - as soon as possible. - - A new method async_update_records_complete is now called on each - listener when all listeners have completed processing updates - and the cache has been updated. This allows ServiceBrowsers - to delay calling handlers until they are sure the cache - has been updated as its a common pattern to call for - ServiceInfo when a ServiceBrowser handler fires. - - The async\_ prefix was chosen to make it clear that these - functions run in the eventloop and should never do blocking - I/O. Before 0.32+ these functions ran in a select() loop and - should not have been doing any blocking I/O, but it was not - clear to implementors that I/O would block the loop. -* Pass both the new and old records to async_update_records (#792) @bdraco - - Pass the old_record (cached) as the value and the new_record (wire) - to async_update_records instead of forcing each consumer to - check the cache since we will always have the old_record - when generating the async_update_records call. This avoids - the overhead of multiple cache lookups for each listener. - -0.31.0 -====== - -* Separated cache loading from I/O in ServiceInfo and fixed cache lookup (#356), - thanks to J. Nick Koston. - - The ServiceInfo class gained a load_from_cache() method to only fetch information - from Zeroconf cache (if it exists) with no IO performed. Additionally this should - reduce IO in cases where cache lookups were previously incorrectly failing. - -0.30.0 -====== - -* Some nice refactoring work including removal of the Reaper thread, - thanks to J. Nick Koston. - -* Fixed a Windows-specific The requested address is not valid in its context regression, - thanks to Timothee ‘TTimo’ Besset and J. Nick Koston. - -* Provided an asyncio-compatible service registration layer (in the zeroconf.asyncio module), - thanks to J. Nick Koston. - -0.29.0 -====== - -* A single socket is used for listening on responding when `InterfaceChoice.Default` is chosen. - Thanks to J. Nick Koston. - -Backwards incompatible: - -* Dropped Python 3.5 support - -0.28.8 -====== - -* Fixed the packet generation when multiple packets are necessary, previously invalid - packets were generated sometimes. Patch thanks to J. Nick Koston. - -0.28.7 -====== - -* Fixed the IPv6 address rendering in the browser example, thanks to Alexey Vazhnov. -* Fixed a crash happening when a service is added or removed during handle_response - and improved exception handling, thanks to J. Nick Koston. - -0.28.6 -====== - -* Loosened service name validation when receiving from the network this lets us handle - some real world devices previously causing errors, thanks to J. Nick Koston. - -0.28.5 -====== - -* Enabled ignoring duplicated messages which decreases CPU usage, thanks to J. Nick Koston. -* Fixed spurious AttributeError: module 'unittest' has no attribute 'mock' in tests. - -0.28.4 -====== - -* Improved cache reaper performance significantly, thanks to J. Nick Koston. -* Added ServiceListener to __all__ as it's part of the public API, thanks to Justin Nesselrotte. - -0.28.3 -====== - -* Reduced a time an internal lock is held which should eliminate deadlocks in high-traffic networks, - thanks to J. Nick Koston. - -0.28.2 -====== - -* Stopped asking questions we already have answers for in cache, thanks to Paul Daumlechner. -* Removed initial delay before querying for service info, thanks to Erik Montnemery. - -0.28.1 -====== - -* Fixed a resource leak connected to using ServiceBrowser with multiple types, thanks to - J. Nick Koston. - -0.28.0 -====== - -* Improved Windows support when using socket errno checks, thanks to Sandy Patterson. -* Added support for passing text addresses to ServiceInfo. -* Improved logging (includes fixing an incorrect logging call) -* Improved Windows compatibility by using Adapter.index from ifaddr, thanks to PhilippSelenium. -* Improved Windows compatibility by stopping using socket.if_nameindex. -* Fixed an OS X edge case which should also eliminate a memory leak, thanks to Emil Styrke. - -Technically backwards incompatible: - -* ``ifaddr`` 0.1.7 or newer is required now. - -0.27.1 ------- - -* Improved the logging situation (includes fixing a false-positive "packets() made no progress - adding records", thanks to Greg Badros) - -0.27.0 ------- - -* Large multi-resource responses are now split into separate packets which fixes a bad - mdns-repeater/ChromeCast Audio interaction ending with ChromeCast Audio crash (and possibly - some others) and improves RFC 6762 compliance, thanks to Greg Badros -* Added a warning presented when the listener passed to ServiceBrowser lacks update_service() - callback -* Added support for finding all services available in the browser example, thanks to Perry Kunder - -Backwards incompatible: - -* Removed previously deprecated ServiceInfo address constructor parameter and property - -0.26.3 ------- - -* Improved readability of logged incoming data, thanks to Erik Montnemery -* Threads are given unique names now to aid debugging, thanks to Erik Montnemery -* Fixed a regression where get_service_info() called within a listener add_service method - would deadlock, timeout and incorrectly return None, fix thanks to Erik Montnemery, but - Matt Saxon and Hmmbob were also involved in debugging it. - -0.26.2 ------- - -* Added support for multiple types to ServiceBrowser, thanks to J. Nick Koston -* Fixed a race condition where a listener gets a message before the lock is created, thanks to - J. Nick Koston - -0.26.1 ------- - -* Fixed a performance regression introduced in 0.26.0, thanks to J. Nick Koston (this is close in - spirit to an optimization made in 0.24.5 by the same author) - -0.26.0 ------- - -* Fixed a regression where service update listener wasn't called on IP address change (it's called - on SRV/A/AAAA record changes now), thanks to Matt Saxon - -Technically backwards incompatible: - -* Service update hook is no longer called on service addition (service added hook is still called), - this is related to the fix above - -0.25.1 ------- - -* Eliminated 5s hangup when calling Zeroconf.close(), thanks to Erik Montnemery - -0.25.0 ------- - -* Reverted uniqueness assertions when browsing, they caused a regression - -Backwards incompatible: - -* Rationalized handling of TXT records. Non-bytes values are converted to str and encoded to bytes - using UTF-8 now, None values mean value-less attributes. When receiving TXT records no decoding - is performed now, keys are always bytes and values are either bytes or None in value-less - attributes. - -0.24.5 ------- - -* Fixed issues with shared records being used where they shouldn't be (TXT, SRV, A records are - unique now), thanks to Matt Saxon -* Stopped unnecessarily excluding host-only interfaces from InterfaceChoice.all as they don't - forbid multicast, thanks to Andreas Oberritter -* Fixed repr() of IPv6 DNSAddress, thanks to Aldo Hoeben -* Removed duplicate update messages sent to listeners, thanks to Matt Saxon -* Added support for cooperating responders, thanks to Matt Saxon -* Optimized handle_response cache check, thanks to J. Nick Koston -* Fixed memory leak in DNSCache, thanks to J. Nick Koston - -0.24.4 ------- - -* Fixed resetting TTL in DNSRecord.reset_ttl(), thanks to Matt Saxon -* Improved various DNS class' string representations, thanks to Jay Hogg - -0.24.3 ------- - -* Fixed import-time "TypeError: 'ellipsis' object is not iterable." on CPython 3.5.2 - -0.24.2 ------- - -* Added support for AWDL interface on macOS (needed and used by the opendrop project but should be - useful in general), thanks to Milan Stute -* Added missing type hints - -0.24.1 ------- - -* Applied some significant performance optimizations, thanks to Jaime van Kessel for the patch and - to Ghostkeeper for performance measurements -* Fixed flushing outdated cache entries when incoming record is unique, thanks to Michael Hu -* Fixed handling updates of TXT records (they'd not get recorded previously), thanks to Michael Hu - -0.24.0 ------- - -* Added IPv6 support, thanks to Dmitry Tantsur -* Added additional recommended records to PTR responses, thanks to Scott Mertz -* Added handling of ENOTCONN being raised during shutdown when using Eventlet, thanks to Tamás Nepusz -* Included the py.typed marker in the package so that type checkers know to use type hints from the - source code, thanks to Dmitry Tantsur - -0.23.0 ------- - -* Added support for MyListener call getting updates to service TXT records, thanks to Matt Saxon -* Added support for multiple addresses when publishing a service, getting/setting single address - has become deprecated. Change thanks to Dmitry Tantsur - -Backwards incompatible: - -* Dropped Python 3.4 support - -0.22.0 ------- - -* A lot of maintenance work (tooling, typing coverage and improvements, spelling) done, thanks to Ville Skyttä -* Provided saner defaults in ServiceInfo's constructor, thanks to Jorge Miranda -* Fixed service removal packets not being sent on shutdown, thanks to Andrew Bonney -* Added a way to define TTL-s through ServiceInfo contructor parameters, thanks to Andrew Bonney - -Technically backwards incompatible: - -* Adjusted query intervals to match RFC 6762, thanks to Andrew Bonney -* Made default TTL-s match RFC 6762, thanks to Andrew Bonney - - -0.21.3 ------- - -* This time really allowed incoming service names to contain underscores (patch released - as part of 0.21.0 was defective) - -0.21.2 ------- - -* Fixed import-time typing-related TypeError when older typing version is used - -0.21.1 ------- - -* Fixed installation on Python 3.4 (we use typing now but there was no explicit dependency on it) - -0.21.0 ------- - -* Added an error message when importing the package using unsupported Python version -* Fixed TTL handling for published service -* Implemented unicast support -* Fixed WSL (Windows Subsystem for Linux) compatibility -* Fixed occasional UnboundLocalError issue -* Fixed UTF-8 multibyte name compression -* Switched from netifaces to ifaddr (pure Python) -* Allowed incoming service names to contain underscores - -0.20.0 ------- - -* Dropped support for Python 2 (this includes PyPy) and 3.3 -* Fixed some class' equality operators -* ServiceBrowser entries are being refreshed when 'stale' now -* Cache returns new records first now instead of last - -0.19.1 ------- - -* Allowed installation with netifaces >= 0.10.6 (a bug that was concerning us - got fixed) - -0.19.0 ------- - -* Technically backwards incompatible - restricted netifaces dependency version to - work around a bug, see https://github.com/jstasiak/python-zeroconf/issues/84 for - details - -0.18.0 ------- - -* Dropped Python 2.6 support -* Improved error handling inside code executed when Zeroconf object is being closed - -0.17.7 ------- - -* Better Handling of DNS Incoming Packets parsing exceptions -* Many exceptions will now log a warning the first time they are seen -* Catch and log sendto() errors -* Fix/Implement duplicate name change -* Fix overly strict name validation introduced in 0.17.6 -* Greatly improve handling of oversized packets including: - - - Implement name compression per RFC1035 - - Limit size of generated packets to 9000 bytes as per RFC6762 - - Better handle over sized incoming packets - -* Increased test coverage to 95% - -0.17.6 ------- - -* Many improvements to address race conditions and exceptions during ZC() - startup and shutdown, thanks to: morpav, veawor, justingiorgi, herczy, - stephenrauch -* Added more test coverage: strahlex, stephenrauch -* Stephen Rauch contributed: - - - Speed up browser startup - - Add ZeroconfServiceTypes() query class to discover all advertised service types - - Add full validation for service names, types and subtypes - - Fix for subtype browsing - - Fix DNSHInfo support - -0.17.5 ------- - -* Fixed OpenBSD compatibility, thanks to Alessio Sergi -* Fixed race condition on ServiceBrowser startup, thanks to gbiddison -* Fixed installation on some Python 3 systems, thanks to Per Sandström -* Fixed "size change during iteration" bug on Python 3, thanks to gbiddison - -0.17.4 ------- - -* Fixed support for Linux kernel versions < 3.9 (thanks to Giovanni Harting - and Luckydonald, GitHub pull request #26) - -0.17.3 ------- - -* Fixed DNSText repr on Python 3 (it'd crash when the text was longer than - 10 bytes), thanks to Paulus Schoutsen for the patch, GitHub pull request #24 - -0.17.2 ------- - -* Fixed installation on Python 3.4.3+ (was failing because of enum34 dependency - which fails to install on 3.4.3+, changed to depend on enum-compat instead; - thanks to Michael Brennan for the original patch, GitHub pull request #22) - -0.17.1 ------- - -* Fixed EADDRNOTAVAIL when attempting to use dummy network interfaces on Windows, - thanks to daid - -0.17.0 ------- - -* Added some Python dependencies so it's not zero-dependencies anymore -* Improved exception handling (it'll be quieter now) -* Messages are listened to and sent using all available network interfaces - by default (configurable); thanks to Marcus Müller -* Started using logging more freely -* Fixed a bug with binary strings as property values being converted to False - (https://github.com/jstasiak/python-zeroconf/pull/10); thanks to Dr. Seuss -* Added new ``ServiceBrowser`` event handler interface (see the examples) -* PyPy3 now officially supported -* Fixed ServiceInfo repr on Python 3, thanks to Yordan Miladinov - -0.16.0 ------- - -* Set up Python logging and started using it -* Cleaned up code style (includes migrating from camel case to snake case) - -0.15.1 ------- - -* Fixed handling closed socket (GitHub #4) - -0.15 ----- - -* Forked by Jakub Stasiak -* Made Python 3 compatible -* Added setup script, made installable by pip and uploaded to PyPI -* Set up Travis build -* Reformatted the code and moved files around -* Stopped catching BaseException in several places, that could hide errors -* Marked threads as daemonic, they won't keep application alive now - -0.14 ----- - -* Fix for SOL_IP undefined on some systems - thanks Mike Erdely. -* Cleaned up examples. -* Lowercased module name. - -0.13 ----- - -* Various minor changes; see git for details. -* No longer compatible with Python 2.2. Only tested with 2.5-2.7. -* Fork by William McBrine. - -0.12 ----- - -* allow selection of binding interface -* typo fix - Thanks A. M. Kuchlingi -* removed all use of word 'Rendezvous' - this is an API change - -0.11 ----- - -* correction to comments for addListener method -* support for new record types seen from OS X - - IPv6 address - - hostinfo - -* ignore unknown DNS record types -* fixes to name decoding -* works alongside other processes using port 5353 (e.g. on Mac OS X) -* tested against Mac OS X 10.3.2's mDNSResponder -* corrections to removal of list entries for service browser - -0.10 ----- - -* Jonathon Paisley contributed these corrections: - - - always multicast replies, even when query is unicast - - correct a pointer encoding problem - - can now write records in any order - - traceback shown on failure - - better TXT record parsing - - server is now separate from name - - can cancel a service browser - -* modified some unit tests to accommodate these changes - -0.09 ----- - -* remove all records on service unregistration -* fix DOS security problem with readName - -0.08 ----- - -* changed licensing to LGPL - -0.07 ----- - -* faster shutdown on engine -* pointer encoding of outgoing names -* ServiceBrowser now works -* new unit tests - -0.06 ----- -* small improvements with unit tests -* added defined exception types -* new style objects -* fixed hostname/interface problem -* fixed socket timeout problem -* fixed add_service_listener() typo bug -* using select() for socket reads -* tested on Debian unstable with Python 2.2.2 - -0.05 ----- - -* ensure case insensitivty on domain names -* support for unicast DNS queries - -0.04 ----- - -* added some unit tests -* added __ne__ adjuncts where required -* ensure names end in '.local.' -* timeout on receiving socket for clean shutdown - +`Changelog `_ License ======= diff --git a/bench/incoming.py b/bench/incoming.py index fd4890662..02a1e602e 100644 --- a/bench/incoming.py +++ b/bench/incoming.py @@ -1,11 +1,12 @@ """Benchmark for DNSIncoming.""" import socket import timeit +from typing import List from zeroconf import DNSAddress, DNSIncoming, DNSOutgoing, DNSService, DNSText, const -def generate_packets(): +def generate_packets() -> List[bytes]: out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) address = socket.inet_pton(socket.AF_INET, "192.168.208.5") @@ -156,7 +157,7 @@ def generate_packets(): packets = generate_packets() -def parse_incoming_message(): +def parse_incoming_message() -> None: for packet in packets: DNSIncoming(packet).answers break diff --git a/build_ext.py b/build_ext.py new file mode 100644 index 000000000..3746fb255 --- /dev/null +++ b/build_ext.py @@ -0,0 +1,38 @@ +"""Build optional cython modules.""" + +import os +from distutils.command.build_ext import build_ext +from typing import Any + + +class BuildExt(build_ext): + def build_extensions(self) -> None: + try: + super().build_extensions() + except Exception: + pass + + +def build(setup_kwargs: Any) -> None: + if os.environ.get("SKIP_CYTHON", False): + return + try: + from Cython.Build import cythonize + + setup_kwargs.update( + dict( + ext_modules=cythonize( + [ + "src/zeroconf/_dns.py", + "src/zeroconf/_protocol/incoming.py", + "src/zeroconf/_protocol/outgoing.py", + ], + compiler_directives={"language_level": "3"}, # Python 3 + ), + cmdclass=dict(build_ext=BuildExt), + ) + ) + except Exception: + if os.environ.get("REQUIRE_CYTHON"): + raise + pass diff --git a/docs/conf.py b/docs/conf.py index b6a99cc52..afaa510e0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # This file is execfile()d with the current directory set to its containing dir. # @@ -8,6 +7,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. +from typing import Any, Dict + import zeroconf # If extensions (or modules to document with autodoc) are in another directory, @@ -172,14 +173,7 @@ # -- Options for LaTeX output -------------------------------------------------- -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - #'preamble': '', -} +latex_elements: Dict[str, Any] = {} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). @@ -240,11 +234,11 @@ intersphinx_mapping = {'http://docs.python.org/': None} -def setup(app): +def setup(app): # type: ignore[no-untyped-def] app.connect('autodoc-skip-member', skip_member) -def skip_member(app, what, name, obj, skip, options): +def skip_member(app, what, name, obj, skip, options): # type: ignore[no-untyped-def] return ( skip or getattr(obj, '__doc__', None) is None diff --git a/examples/async_browser.py b/examples/async_browser.py index 1cce5c205..71c5e670a 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -11,7 +11,12 @@ from typing import Any, Optional, cast from zeroconf import IPVersion, ServiceStateChange, Zeroconf -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes +from zeroconf.asyncio import ( + AsyncServiceBrowser, + AsyncServiceInfo, + AsyncZeroconf, + AsyncZeroconfServiceTypes, +) def async_on_service_state_change( diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index dd8265b7a..5276c122b 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -11,11 +11,9 @@ import logging from typing import Any, Optional, cast - from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf - HAP_TYPE = "_hap._tcp.local." diff --git a/examples/browser.py b/examples/browser.py index 8c50e4097..fc815e3f8 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -10,7 +10,13 @@ from time import sleep from typing import cast -from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf, ZeroconfServiceTypes +from zeroconf import ( + IPVersion, + ServiceBrowser, + ServiceStateChange, + Zeroconf, + ZeroconfServiceTypes, +) def on_service_state_change( diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..a51adab8e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,394 @@ +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cython" +version = "0.29.32" +description = "The Cython compiler for writing C extensions for the Python language." +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "exceptiongroup" +version = "1.0.4" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "ifaddr" +version = "0.2.0" +description = "Cross-platform network interface and IP address enumeration library" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "importlib-metadata" +version = "5.1.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "22.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "7.2.0" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.20.3" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=6.1.0" +typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-cov" +version = "4.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-timeout" +version = "2.1.0" +description = "pytest plugin to abort hanging tests" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytest = ">=5.0.0" + +[[package]] +name = "setuptools" +version = "65.6.3" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "zipp" +version = "3.11.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "e1ee5359a3695e10975e06463e681d6cdc3e2a2a7268bca9b44fa025a8e8980b" + +[metadata.files] +async-timeout = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +coverage = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] +cython = [ + {file = "Cython-0.29.32-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:39afb4679b8c6bf7ccb15b24025568f4f9b4d7f9bf3cbd981021f542acecd75b"}, + {file = "Cython-0.29.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dbee03b8d42dca924e6aa057b836a064c769ddfd2a4c2919e65da2c8a362d528"}, + {file = "Cython-0.29.32-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ba622326f2862f9c1f99ca8d47ade49871241920a352c917e16861e25b0e5c3"}, + {file = "Cython-0.29.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e6ffa08aa1c111a1ebcbd1cf4afaaec120bc0bbdec3f2545f8bb7d3e8e77a1cd"}, + {file = "Cython-0.29.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:97335b2cd4acebf30d14e2855d882de83ad838491a09be2011745579ac975833"}, + {file = "Cython-0.29.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:06be83490c906b6429b4389e13487a26254ccaad2eef6f3d4ee21d8d3a4aaa2b"}, + {file = "Cython-0.29.32-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:eefd2b9a5f38ded8d859fe96cc28d7d06e098dc3f677e7adbafda4dcdd4a461c"}, + {file = "Cython-0.29.32-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5514f3b4122cb22317122a48e175a7194e18e1803ca555c4c959d7dfe68eaf98"}, + {file = "Cython-0.29.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:656dc5ff1d269de4d11ee8542f2ffd15ab466c447c1f10e5b8aba6f561967276"}, + {file = "Cython-0.29.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:cdf10af3e2e3279dc09fdc5f95deaa624850a53913f30350ceee824dc14fc1a6"}, + {file = "Cython-0.29.32-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:3875c2b2ea752816a4d7ae59d45bb546e7c4c79093c83e3ba7f4d9051dd02928"}, + {file = "Cython-0.29.32-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:79e3bab19cf1b021b613567c22eb18b76c0c547b9bc3903881a07bfd9e7e64cf"}, + {file = "Cython-0.29.32-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0595aee62809ba353cebc5c7978e0e443760c3e882e2c7672c73ffe46383673"}, + {file = "Cython-0.29.32-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0ea8267fc373a2c5064ad77d8ff7bf0ea8b88f7407098ff51829381f8ec1d5d9"}, + {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:c8e8025f496b5acb6ba95da2fb3e9dacffc97d9a92711aacfdd42f9c5927e094"}, + {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:afbce249133a830f121b917f8c9404a44f2950e0e4f5d1e68f043da4c2e9f457"}, + {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:513e9707407608ac0d306c8b09d55a28be23ea4152cbd356ceaec0f32ef08d65"}, + {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e83228e0994497900af954adcac27f64c9a57cd70a9ec768ab0cb2c01fd15cf1"}, + {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea1dcc07bfb37367b639415333cfbfe4a93c3be340edf1db10964bc27d42ed64"}, + {file = "Cython-0.29.32-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8669cadeb26d9a58a5e6b8ce34d2c8986cc3b5c0bfa77eda6ceb471596cb2ec3"}, + {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:ed087eeb88a8cf96c60fb76c5c3b5fb87188adee5e179f89ec9ad9a43c0c54b3"}, + {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3f85eb2343d20d91a4ea9cf14e5748092b376a64b7e07fc224e85b2753e9070b"}, + {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:63b79d9e1f7c4d1f498ab1322156a0d7dc1b6004bf981a8abda3f66800e140cd"}, + {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e1958e0227a4a6a2c06fd6e35b7469de50adf174102454db397cec6e1403cce3"}, + {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:856d2fec682b3f31583719cb6925c6cdbb9aa30f03122bcc45c65c8b6f515754"}, + {file = "Cython-0.29.32-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:479690d2892ca56d34812fe6ab8f58e4b2e0129140f3d94518f15993c40553da"}, + {file = "Cython-0.29.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:67fdd2f652f8d4840042e2d2d91e15636ba2bcdcd92e7e5ffbc68e6ef633a754"}, + {file = "Cython-0.29.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:4a4b03ab483271f69221c3210f7cde0dcc456749ecf8243b95bc7a701e5677e0"}, + {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:40eff7aa26e91cf108fd740ffd4daf49f39b2fdffadabc7292b4b7dc5df879f0"}, + {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0bbc27abdf6aebfa1bce34cd92bd403070356f28b0ecb3198ff8a182791d58b9"}, + {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cddc47ec746a08603037731f5d10aebf770ced08666100bd2cdcaf06a85d4d1b"}, + {file = "Cython-0.29.32-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca3065a1279456e81c615211d025ea11bfe4e19f0c5650b859868ca04b3fcbd"}, + {file = "Cython-0.29.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d968ffc403d92addf20b68924d95428d523436adfd25cf505d427ed7ba3bee8b"}, + {file = "Cython-0.29.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f3fd44cc362eee8ae569025f070d56208908916794b6ab21e139cea56470a2b3"}, + {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b6da3063c5c476f5311fd76854abae6c315f1513ef7d7904deed2e774623bbb9"}, + {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:061e25151c38f2361bc790d3bcf7f9d9828a0b6a4d5afa56fbed3bd33fb2373a"}, + {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f9944013588a3543fca795fffb0a070a31a243aa4f2d212f118aa95e69485831"}, + {file = "Cython-0.29.32-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:07d173d3289415bb496e72cb0ddd609961be08fe2968c39094d5712ffb78672b"}, + {file = "Cython-0.29.32-py2.py3-none-any.whl", hash = "sha256:eeb475eb6f0ccf6c039035eb4f0f928eb53ead88777e0a760eccb140ad90930b"}, + {file = "Cython-0.29.32.tar.gz", hash = "sha256:8733cf4758b79304f2a4e39ebfac5e92341bce47bcceb26c1254398b2f8c1af7"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, + {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, +] +ifaddr = [ + {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, + {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, +] +importlib-metadata = [ + {file = "importlib_metadata-5.1.0-py3-none-any.whl", hash = "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313"}, + {file = "importlib_metadata-5.1.0.tar.gz", hash = "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +packaging = [ + {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, + {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +pytest = [ + {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, + {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, +] +pytest-cov = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] +pytest-timeout = [ + {file = "pytest-timeout-2.1.0.tar.gz", hash = "sha256:c07ca07404c612f8abbe22294b23c368e2e5104b521c1790195561f37e1ac3d9"}, + {file = "pytest_timeout-2.1.0-py3-none-any.whl", hash = "sha256:f6f50101443ce70ad325ceb4473c4255e9d74e3c7cd0ef827309dfa4c0d975c6"}, +] +setuptools = [ + {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, + {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +zipp = [ + {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, + {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, +] diff --git a/pyproject.toml b/pyproject.toml index cd79b3e20..96a7968cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,58 @@ +[tool.poetry] +name = "python-zeroconf" +version = "0.39.4" +description = "A pure python implementation of multicast DNS service discovery" +authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] +license = "LGPL" +readme = "README.rst" +repository = "https://github.com/python-zeroconf/python-zeroconf" +documentation = "https://python-zeroconf.readthedocs.io" +classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', + 'Operating System :: POSIX', + 'Operating System :: POSIX :: Linux', + 'Operating System :: MacOS :: MacOS X', + 'Topic :: Software Development :: Libraries', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', +] +build = "build_ext.py" +packages = [ + { include = "zeroconf", from = "src" }, +] + + +[tool.semantic_release] +branch = "master" +version_toml = "pyproject.toml:tool.poetry.version" +version_variable = "src/zeroconf/__init__.py:__version__" +build_command = "pip install poetry && poetry build" + + +[tool.poetry.dependencies] +python = "^3.7" +async-timeout = ">=3.0.1" +ifaddr = ">=0.1.7" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.2.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.20.3" +cython = "^0.29.32" +setuptools = "^65.6.3" +pytest-timeout = "^2.1.0" + [tool.black] line-length = 110 -target_version = ['py35', 'py36', 'py37', 'py38'] +target_version = ['py37', 'py38', 'py39', 'py310', 'py311'] skip_string_normalization = true [tool.pylint.BASIC] @@ -35,3 +87,61 @@ disable = [ "too-many-instance-attributes", "too-many-public-methods" ] + + +[tool.pytest.ini_options] +addopts = "-v -Wdefault --cov=zeroconf --cov-report=term-missing:skip-covered" +pythonpath = ["src"] + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "@overload", + "if TYPE_CHECKING", + "raise NotImplementedError", +] + + +[tool.isort] +profile = "black" +known_first_party = ["zeroconf", "tests"] + +[tool.mypy] +check_untyped_defs = true +disallow_any_generics = false # turn this on when we drop 3.7/3.8 support +disallow_incomplete_defs = true +disallow_untyped_defs = true +mypy_path = "src/" +no_implicit_optional = true +show_error_codes = true +warn_unreachable = true +warn_unused_ignores = true +exclude = [ + 'docs/*', + 'bench/*', +] + +[[tool.mypy.overrides]] +module = "tests.*" +allow_untyped_defs = true + +[[tool.mypy.overrides]] +module = "docs.*" +ignore_errors = true +allow_untyped_defs = true + +[[tool.mypy.overrides]] +module = "bench.*" +ignore_errors = true + +[build-system] +requires = ['setuptools>=65.4.1', 'wheel', 'Cython', "poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.codespell] +skip = '*.po,*.ts,./tests,./bench' +count = '' +quiet-level = 3 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index cfddb847e..000000000 --- a/setup.cfg +++ /dev/null @@ -1,35 +0,0 @@ -[bumpversion] -current_version = 0.39.4 -commit = True -tag = True -tag_name = {new_version} - -[bumpversion:file:zeroconf/__init__.py] -search = __version__ = '{current_version}' -replace = __version__ = '{new_version}' - -[tool:pytest] -testpaths = tests - -[flake8] -show-source = 1 -application-import-names = zeroconf -max-line-length = 110 -ignore = E203,W503,N818 - -[mypy] -ignore_missing_imports = true -follow_imports = skip -check_untyped_defs = true -no_implicit_optional = true -warn_incomplete_stub = true -warn_no_return = true -warn_redundant_casts = true -warn_unused_configs = true -warn_unused_ignores = true -warn_return_any = true -disallow_untyped_calls = false -disallow_untyped_defs = true - -[mypy-zeroconf.test] -disallow_untyped_defs = false diff --git a/setup.py b/setup.py deleted file mode 100755 index a7f9aa0e7..000000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -from io import open -from os.path import abspath, dirname, join - -from setuptools import setup - -PROJECT_ROOT = abspath(dirname(__file__)) -with open(join(PROJECT_ROOT, 'README.rst'), encoding='utf-8') as f: - readme = f.read() - -version = ( - [ln for ln in open(join(PROJECT_ROOT, 'zeroconf', '__init__.py')) if '__version__' in ln][0] - .split('=')[-1] - .strip() - .strip('\'"') -) - -setup( - name='zeroconf', - version=version, - description='Pure Python Multicast DNS Service Discovery Library ' '(Bonjour/Avahi compatible)', - long_description=readme, - author='Paul Scott-Murphy, William McBrine, Jakub Stasiak', - url='https://github.com/jstasiak/python-zeroconf', - package_data={"zeroconf": ["py.typed"]}, - packages=["zeroconf", "zeroconf._protocol", "zeroconf._services", "zeroconf._utils"], - platforms=['unix', 'linux', 'osx'], - license='LGPL', - zip_safe=False, - python_requires='>=3.7', - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Operating System :: MacOS :: MacOS X', - 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - ], - keywords=['Bonjour', 'Avahi', 'Zeroconf', 'Multicast DNS', 'Service Discovery', 'mDNS'], - install_requires=['async_timeout>=4.0.1', 'ifaddr>=0.1.7'], -) diff --git a/zeroconf/__init__.py b/src/zeroconf/__init__.py similarity index 91% rename from zeroconf/__init__.py rename to src/zeroconf/__init__.py index c82e4f6b6..9cda19480 100644 --- a/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -31,10 +31,10 @@ DNSNsec, DNSPointer, DNSQuestion, + DNSQuestionType, DNSRecord, DNSService, DNSText, - DNSQuestionType, ) from ._exceptions import ( AbstractMethodException, @@ -51,31 +51,36 @@ from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat from ._services import ( # noqa # import needed for backwards compat - Signal, - SignalRegistrationInterface, ServiceListener, ServiceStateChange, + Signal, + SignalRegistrationInterface, ) from ._services.browser import ServiceBrowser from ._services.info import ( # noqa # import needed for backwards compat - instance_name_from_service_info, ServiceInfo, + instance_name_from_service_info, +) +from ._services.registry import ( # noqa # import needed for backwards compat + ServiceRegistry, ) -from ._services.registry import ServiceRegistry # noqa # import needed for backwards compat from ._services.types import ZeroconfServiceTypes from ._updates import RecordUpdate, RecordUpdateListener from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat - add_multicast_member, - autodetect_ip_version, - create_sockets, - get_all_addresses_v6, InterfaceChoice, InterfacesType, IPVersion, + add_multicast_member, + autodetect_ip_version, + create_sockets, get_all_addresses, + get_all_addresses_v6, +) +from ._utils.time import ( # noqa # import needed for backwards compat + current_time_millis, + millis_to_seconds, ) -from ._utils.time import current_time_millis, millis_to_seconds # noqa # import needed for backwards compat __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' diff --git a/zeroconf/_cache.py b/src/zeroconf/_cache.py similarity index 100% rename from zeroconf/_cache.py rename to src/zeroconf/_cache.py diff --git a/zeroconf/_core.py b/src/zeroconf/_core.py similarity index 100% rename from zeroconf/_core.py rename to src/zeroconf/_core.py index 48813f7f6..958b34688 100644 --- a/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -58,9 +58,9 @@ ) from ._utils.name import service_type_name from ._utils.net import ( - IPVersion, InterfaceChoice, InterfacesType, + IPVersion, autodetect_ip_version, can_send_to, create_sockets, diff --git a/zeroconf/_dns.py b/src/zeroconf/_dns.py similarity index 98% rename from zeroconf/_dns.py rename to src/zeroconf/_dns.py index a3a48594d..72b563770 100644 --- a/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -22,18 +22,12 @@ import enum import socket -from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address from ._utils.time import current_time_millis, millis_to_seconds -from .const import ( - _CLASSES, - _CLASS_MASK, - _CLASS_UNIQUE, - _TYPES, - _TYPE_ANY, -) +from .const import _CLASS_MASK, _CLASS_UNIQUE, _CLASSES, _TYPE_ANY, _TYPES _LEN_BYTE = 1 _LEN_SHORT = 2 diff --git a/zeroconf/_exceptions.py b/src/zeroconf/_exceptions.py similarity index 100% rename from zeroconf/_exceptions.py rename to src/zeroconf/_exceptions.py diff --git a/zeroconf/_handlers.py b/src/zeroconf/_handlers.py similarity index 99% rename from zeroconf/_handlers.py rename to src/zeroconf/_handlers.py index 0ad95c228..767cd65f9 100644 --- a/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -23,10 +23,21 @@ import itertools import random from collections import deque -from typing import Dict, Iterable, List, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import ( + TYPE_CHECKING, + Dict, + Iterable, + List, + NamedTuple, + Optional, + Set, + Tuple, + Union, + cast, +) from ._cache import DNSCache, _UniqueRecordsType -from ._dns import DNSAddress, DNSNsec, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord +from ._dns import DNSAddress, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet from ._history import QuestionHistory from ._logger import log from ._protocol.incoming import DNSIncoming @@ -495,7 +506,7 @@ def async_add_listener( This function is not threadsafe and must be called in the eventloop. """ if not isinstance(listener, RecordUpdateListener): - log.error( + log.error( # type: ignore[unreachable] "listeners passed to async_add_listener must inherit from RecordUpdateListener;" " In the future this will fail" ) diff --git a/zeroconf/_history.py b/src/zeroconf/_history.py similarity index 100% rename from zeroconf/_history.py rename to src/zeroconf/_history.py diff --git a/zeroconf/_logger.py b/src/zeroconf/_logger.py similarity index 100% rename from zeroconf/_logger.py rename to src/zeroconf/_logger.py diff --git a/zeroconf/_protocol/__init__.py b/src/zeroconf/_protocol/__init__.py similarity index 93% rename from zeroconf/_protocol/__init__.py rename to src/zeroconf/_protocol/__init__.py index 360b599db..561619b7d 100644 --- a/zeroconf/_protocol/__init__.py +++ b/src/zeroconf/_protocol/__init__.py @@ -20,12 +20,7 @@ USA """ -from ..const import ( - _FLAGS_QR_MASK, - _FLAGS_QR_QUERY, - _FLAGS_QR_RESPONSE, - _FLAGS_TC, -) +from ..const import _FLAGS_QR_MASK, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, _FLAGS_TC class DNSMessage: diff --git a/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py similarity index 98% rename from zeroconf/_protocol/incoming.py rename to src/zeroconf/_protocol/incoming.py index 6fea2e83c..200d9f664 100644 --- a/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -23,13 +23,20 @@ import struct from typing import Callable, Dict, List, Optional, Set, Tuple, cast -from . import DNSMessage -from .._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText +from .._dns import ( + DNSAddress, + DNSHinfo, + DNSNsec, + DNSPointer, + DNSQuestion, + DNSRecord, + DNSService, + DNSText, +) from .._exceptions import IncomingDecodeError from .._logger import QuietLogger, log from .._utils.time import current_time_millis from ..const import ( - _TYPES, _TYPE_A, _TYPE_AAAA, _TYPE_CNAME, @@ -38,7 +45,9 @@ _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, + _TYPES, ) +from . import DNSMessage DNS_COMPRESSION_HEADER_LEN = 1 DNS_COMPRESSION_POINTER_LEN = 2 diff --git a/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py similarity index 100% rename from zeroconf/_protocol/outgoing.py rename to src/zeroconf/_protocol/outgoing.py index 00e8a11c8..ad9295ba0 100644 --- a/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -23,8 +23,6 @@ import enum from typing import Dict, List, Optional, Sequence, Tuple, Union -from . import DNSMessage -from .incoming import DNSIncoming from .._cache import DNSCache from .._dns import DNSPointer, DNSQuestion, DNSRecord from .._exceptions import NamePartTooLongException @@ -36,6 +34,8 @@ _MAX_MSG_ABSOLUTE, _MAX_MSG_TYPICAL, ) +from . import DNSMessage +from .incoming import DNSIncoming class DNSOutgoing(DNSMessage): diff --git a/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py similarity index 97% rename from zeroconf/_services/__init__.py rename to src/zeroconf/_services/__init__.py index 5b9fbf014..18b2cf30a 100644 --- a/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -21,8 +21,7 @@ """ import enum -from typing import Any, Callable, List, TYPE_CHECKING - +from typing import TYPE_CHECKING, Any, Callable, List if TYPE_CHECKING: from .._core import Zeroconf diff --git a/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py similarity index 98% rename from zeroconf/_services/browser.py rename to src/zeroconf/_services/browser.py index 27d7c301f..cc6c54e40 100644 --- a/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -26,7 +26,18 @@ import threading import warnings from collections import OrderedDict -from typing import Callable, Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, + Union, + cast, +) from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log @@ -104,7 +115,7 @@ def _group_ptr_queries_with_known_answers( # goal of this algorithm is to quickly bucket the query + known answers without # the overhead of actually constructing the packets. query_by_size: Dict[DNSQuestion, int] = { - question: (question.max_size + sum([answer.max_size_compressed for answer in known_answers])) + question: (question.max_size + sum(answer.max_size_compressed for answer in known_answers)) for question, known_answers in question_with_known_answers.items() } max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN diff --git a/zeroconf/_services/info.py b/src/zeroconf/_services/info.py similarity index 98% rename from zeroconf/_services/info.py rename to src/zeroconf/_services/info.py index fc5e93741..eca5320bb 100644 --- a/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -23,20 +23,23 @@ import ipaddress import random import socket -from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union, cast - -from .._dns import DNSAddress, DNSPointer, DNSQuestionType, DNSRecord, DNSService, DNSText +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast + +from .._dns import ( + DNSAddress, + DNSPointer, + DNSQuestionType, + DNSRecord, + DNSService, + DNSText, +) from .._exceptions import BadTypeInNameException from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener from .._utils.asyncio import get_running_loop, run_coro_with_timeout from .._utils.name import service_type_name -from .._utils.net import ( - IPVersion, - _encode_address, - _is_v6_address, -) +from .._utils.net import IPVersion, _encode_address, _is_v6_address from .._utils.time import current_time_millis from ..const import ( _CLASS_IN, @@ -52,7 +55,6 @@ _TYPE_TXT, ) - # https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 # The most common case for calling ServiceInfo is from a # ServiceBrowser. After the first request we add a few random @@ -542,7 +544,7 @@ def __repr__(self) -> str: return '{}({})'.format( type(self).__name__, ', '.join( - '{}={!r}'.format(name, getattr(self, name)) + f'{name}={getattr(self, name)!r}' for name in ( 'type', 'name', diff --git a/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py similarity index 99% rename from zeroconf/_services/registry.py rename to src/zeroconf/_services/registry.py index 203b3b396..d4f7d51a9 100644 --- a/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -22,9 +22,8 @@ from typing import Dict, List, Optional, Union - -from .info import ServiceInfo from .._exceptions import ServiceNameAlreadyRegistered +from .info import ServiceInfo class ServiceRegistry: diff --git a/zeroconf/_services/types.py b/src/zeroconf/_services/types.py similarity index 97% rename from zeroconf/_services/types.py rename to src/zeroconf/_services/types.py index 34b000f19..70db2d609 100644 --- a/zeroconf/_services/types.py +++ b/src/zeroconf/_services/types.py @@ -23,11 +23,11 @@ import time from typing import Optional, Set, Tuple, Union -from .browser import ServiceBrowser from .._core import Zeroconf from .._services import ServiceListener -from .._utils.net import IPVersion, InterfaceChoice, InterfacesType +from .._utils.net import InterfaceChoice, InterfacesType, IPVersion from ..const import _SERVICE_TYPE_ENUMERATION_NAME +from .browser import ServiceBrowser class ZeroconfServiceTypes(ServiceListener): diff --git a/zeroconf/_updates.py b/src/zeroconf/_updates.py similarity index 97% rename from zeroconf/_updates.py rename to src/zeroconf/_updates.py index e10c01eab..1a1e028d7 100644 --- a/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -20,12 +20,10 @@ USA """ -from typing import List, NamedTuple, Optional, TYPE_CHECKING - +from typing import TYPE_CHECKING, List, NamedTuple, Optional from ._dns import DNSRecord - if TYPE_CHECKING: from ._core import Zeroconf diff --git a/zeroconf/_utils/__init__.py b/src/zeroconf/_utils/__init__.py similarity index 100% rename from zeroconf/_utils/__init__.py rename to src/zeroconf/_utils/__init__.py diff --git a/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py similarity index 100% rename from zeroconf/_utils/asyncio.py rename to src/zeroconf/_utils/asyncio.py index d8101afdf..3d3b7c110 100644 --- a/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -27,9 +27,9 @@ import async_timeout -from .time import millis_to_seconds from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT +from .time import millis_to_seconds # The combined timeouts should be lower than _CLOSE_TIMEOUT + _WAIT_FOR_LOOP_TASKS_TIMEOUT _TASK_AWAIT_TIMEOUT = 1 diff --git a/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py similarity index 100% rename from zeroconf/_utils/name.py rename to src/zeroconf/_utils/name.py index 367dfb18a..5eb58957b 100644 --- a/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -24,8 +24,8 @@ from .._exceptions import BadTypeInNameException from ..const import ( - _HAS_ASCII_CONTROL_CHARS, _HAS_A_TO_Z, + _HAS_ASCII_CONTROL_CHARS, _HAS_ONLY_A_TO_Z_NUM_HYPHEN, _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE, _LOCAL_TRAILER, diff --git a/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py similarity index 98% rename from zeroconf/_utils/net.py rename to src/zeroconf/_utils/net.py index c361a8e7b..cc4754abc 100644 --- a/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -26,7 +26,7 @@ import socket import struct import sys -from typing import Any, List, Optional, Tuple, Union, cast +from typing import Any, List, Optional, Sequence, Tuple, Union, cast import ifaddr @@ -40,7 +40,7 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[List[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] +InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] @enum.unique @@ -107,7 +107,7 @@ def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str def ip6_addresses_to_indexes( - interfaces: List[Union[str, int, Tuple[Tuple[str, int, int], int]]] + interfaces: Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]] ) -> List[Tuple[Tuple[str, int, int], int]]: """Convert IPv6 interface addresses to interface indexes. diff --git a/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py similarity index 100% rename from zeroconf/_utils/time.py rename to src/zeroconf/_utils/time.py diff --git a/zeroconf/asyncio.py b/src/zeroconf/asyncio.py similarity index 98% rename from zeroconf/asyncio.py rename to src/zeroconf/asyncio.py index c5c9457b1..97fdac43c 100644 --- a/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -30,13 +30,8 @@ from ._services.browser import _ServiceBrowserBase from ._services.info import ServiceInfo from ._services.types import ZeroconfServiceTypes -from ._utils.net import IPVersion, InterfaceChoice, InterfacesType -from .const import ( - _BROWSER_TIME, - _MDNS_PORT, - _SERVICE_TYPE_ENUMERATION_NAME, -) - +from ._utils.net import InterfaceChoice, InterfacesType, IPVersion +from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME __all__ = [ "AsyncZeroconf", diff --git a/zeroconf/const.py b/src/zeroconf/const.py similarity index 100% rename from zeroconf/const.py rename to src/zeroconf/const.py diff --git a/zeroconf/py.typed b/src/zeroconf/py.typed similarity index 100% rename from zeroconf/py.typed rename to src/zeroconf/py.typed diff --git a/tests/__init__.py b/tests/__init__.py index 9c29693e4..8f216c990 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -27,7 +27,6 @@ import ifaddr - from zeroconf import DNSIncoming, Zeroconf diff --git a/tests/conftest.py b/tests/conftest.py index f900e0946..7fde48349 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,10 @@ """ conftest for zeroconf tests. """ import threading +import unittest import pytest -import unittest - from zeroconf import _core, const diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 66e3194af..4fdd28dfa 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -4,26 +4,34 @@ """ Unit tests for zeroconf._services.browser. """ import logging +import os import socket import time -import os import unittest from threading import Event +from typing import Iterable, Set from unittest.mock import patch import pytest import zeroconf as r -from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis, millis_to_seconds import zeroconf._services.browser as _services_browser -from zeroconf import _core, _handlers, Zeroconf +from zeroconf import ( + DNSPointer, + DNSQuestion, + Zeroconf, + _core, + _handlers, + const, + current_time_millis, + millis_to_seconds, +) from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo from zeroconf.asyncio import AsyncZeroconf -from .. import has_working_ipv6, _inject_response, _wait_for_start - +from .. import _inject_response, _wait_for_start, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -98,7 +106,7 @@ class MyServiceListener(r.ServiceListener): zc.close() with pytest.raises(RuntimeError): - browser = r.ServiceBrowser(zc, type_, None, listener) + r.ServiceBrowser(zc, type_, None, listener) def test_multiple_instances_running_close(): @@ -145,17 +153,17 @@ def test_update_record(self): service_updated_event = Event() class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal service_added_count service_added_count += 1 service_add_event.set() - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal service_removed_count service_removed_count += 1 service_removed_event.set() - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal service_updated_count service_updated_count += 1 service_info = zc.get_service_info(type_, name) @@ -333,13 +341,13 @@ def test_update_record(self): service_removed_event = Event() class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal service_added_count service_added_count += 1 if service_added_count == 3: service_add_event.set() - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal service_removed_count service_removed_count += 1 if service_removed_count == 3: @@ -494,6 +502,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): else: assert not got_query.is_set() time_offset += initial_query_interval + assert zeroconf_browser.loop is not None zeroconf_browser.loop.call_soon_threadsafe(browser._async_send_ready_queries_schedule_next) finally: @@ -555,8 +564,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): """Sends an outgoing packet.""" nonlocal first_outgoing nonlocal second_outgoing - if first_outgoing is not None and second_outgoing is None: - second_outgoing = out + if first_outgoing is not None and second_outgoing is None: # type: ignore[unreachable] + second_outgoing = out # type: ignore[unreachable] if first_outgoing is None: first_outgoing = out old_send(out, addr=addr, port=port) @@ -570,8 +579,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change], delay=5) time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 120 + 5)) try: - assert first_outgoing.questions[0].unicast == True - assert second_outgoing.questions[0].unicast == False + assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] + assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] finally: browser.cancel() zeroconf_browser.close() @@ -605,7 +614,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): ) time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: - assert first_outgoing.questions[0].unicast == False + assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] finally: browser.cancel() zeroconf_browser.close() @@ -639,7 +648,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): ) time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: - assert first_outgoing.questions[0].unicast == True + assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] finally: browser.cancel() zeroconf_browser.close() @@ -715,8 +724,10 @@ def test_service_browser_is_aware_of_port_changes(): registration_name = "xxxyyy.%s" % type_ callbacks = [] + # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): + """Dummy callback.""" nonlocal callbacks if name == registration_name: callbacks.append((service_type, state_change, name)) @@ -728,7 +739,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -741,7 +752,9 @@ def mock_incoming_msg(records) -> r.DNSIncoming: time.sleep(0.1) assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] - assert zc.get_service_info(type_, registration_name).port == 80 + service_info = zc.get_service_info(type_, registration_name) + assert service_info is not None + assert service_info.port == 80 info.port = 400 _inject_response( @@ -754,7 +767,9 @@ def mock_incoming_msg(records) -> r.DNSIncoming: ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), ('_hap._tcp.local.', ServiceStateChange.Updated, 'xxxyyy._hap._tcp.local.'), ] - assert zc.get_service_info(type_, registration_name).port == 400 + service_info = zc.get_service_info(type_, registration_name) + assert service_info is not None + assert service_info.port == 400 browser.cancel() zc.close() @@ -771,17 +786,17 @@ def test_service_browser_listeners_update_service(): callbacks = [] class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -795,7 +810,7 @@ def update_service(self, zc, type_, name) -> None: address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -832,13 +847,13 @@ def test_service_browser_listeners_no_update_service(): registration_name = "xxxyyy.%s" % type_ callbacks = [] - class MyServiceListener: - def add_service(self, zc, type_, name) -> None: + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) @@ -852,7 +867,7 @@ def remove_service(self, zc, type_, name) -> None: address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -935,7 +950,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): 10000, f'known-to-other.{name}', ) - other_known_answers = {answer} + other_known_answers: Set[r.DNSRecord] = {answer} zc.question_history.add_question_at_time(question, now, other_known_answers) assert zc.question_history.suppresses(question, now, other_known_answers) @@ -1031,17 +1046,17 @@ def test_service_browser_matching(): callbacks = [] class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1058,7 +1073,7 @@ def update_service(self, zc, type_, name) -> None: not_match_type_, not_match_registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] ) - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) @@ -1113,17 +1128,17 @@ def test_service_browser_expire_callbacks(): callbacks = [] class MyServiceListener(r.ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1148,7 +1163,7 @@ def update_service(self, zc, type_, name) -> None: addresses=[address], ) - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: generated.add_answer_at_time(record, 0) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 258080c6b..2dec272a9 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -5,25 +5,23 @@ import asyncio import logging +import os import socket import threading -import os import unittest -from unittest.mock import patch from threading import Event -from typing import List +from typing import Iterable, List, Optional +from unittest.mock import patch import pytest import zeroconf as r from zeroconf import DNSAddress, const -from zeroconf._services import types from zeroconf._services.info import ServiceInfo from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf -from .. import has_working_ipv6, _inject_response - +from .. import _inject_response, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -224,7 +222,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send with patch.object(zc, "async_send", send): - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -275,7 +273,7 @@ def get_service_info_helper(zc, type, name): ) send_event.wait(wait_time) assert last_sent is not None - assert len(last_sent.questions) == 3 + assert len(last_sent.questions) == 3 # type: ignore[unreachable] assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions @@ -369,7 +367,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send with patch.object(zc, "async_send", send): - def mock_incoming_msg(records) -> r.DNSIncoming: + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -614,7 +612,7 @@ def test_filter_address_by_type_from_service_info(): ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) - def dns_addresses_to_addresses(dns_address: List[DNSAddress]): + def dns_addresses_to_addresses(dns_address: List[DNSAddress]) -> List[bytes]: return [address.address for address in dns_address] assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] @@ -740,7 +738,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) - assert first_outgoing.questions[0].unicast == True + assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] zeroconf.close() @@ -764,7 +762,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) - assert first_outgoing.questions[0].unicast == False + assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] zeroconf.close() @@ -956,7 +954,6 @@ async def test_ip_changes_are_seen(): """Test that ip changes are seen by async_request.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) host = "multahost.local." diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index d5fd43e8c..3207b14e0 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -3,8 +3,8 @@ """Unit tests for zeroconf._services.registry.""" -import unittest import socket +import unittest import zeroconf as r from zeroconf import ServiceInfo diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 33940434a..e1062b862 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -5,13 +5,13 @@ import logging import os -import unittest import socket import sys +import unittest from unittest.mock import patch import zeroconf as r -from zeroconf import Zeroconf, ServiceInfo, ZeroconfServiceTypes +from zeroconf import ServiceInfo, Zeroconf, ZeroconfServiceTypes from .. import _clear_cache, has_working_ipv6 diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index db27c9adb..9a0bc0d6e 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -7,34 +7,41 @@ import logging import os import socket -import time import threading -from unittest.mock import ANY, call, patch, MagicMock - +import time +from unittest.mock import ANY, call, patch import pytest -from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf, AsyncZeroconfServiceTypes +import zeroconf._services.browser as _services_browser from zeroconf import ( + DNSAddress, DNSIncoming, DNSOutgoing, - DNSQuestion, DNSPointer, + DNSQuestion, DNSService, - DNSAddress, DNSText, NotRunningException, ServiceStateChange, Zeroconf, const, ) -from zeroconf.const import _LISTENER_TIME -from zeroconf._core import AsyncListener -from zeroconf._exceptions import BadTypeInNameException, NonUniqueNameException, ServiceNameAlreadyRegistered +from zeroconf._exceptions import ( + BadTypeInNameException, + NonUniqueNameException, + ServiceNameAlreadyRegistered, +) from zeroconf._services import ServiceListener -import zeroconf._services.browser as _services_browser from zeroconf._services.info import ServiceInfo from zeroconf._utils.time import current_time_millis +from zeroconf.asyncio import ( + AsyncServiceBrowser, + AsyncServiceInfo, + AsyncZeroconf, + AsyncZeroconfServiceTypes, +) +from zeroconf.const import _LISTENER_TIME from . import _clear_cache, has_working_ipv6 @@ -597,13 +604,13 @@ async def test_async_service_browser() -> None: calls = [] class MyListener(ServiceListener): - def add_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + def add_service(self, aiozc: Zeroconf, type: str, name: str) -> None: calls.append(("add", type, name)) - def remove_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + def remove_service(self, aiozc: Zeroconf, type: str, name: str) -> None: calls.append(("remove", type, name)) - def update_service(self, aiozc: AsyncZeroconf, type: str, name: str) -> None: + def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: calls.append(("update", type, name)) listener = MyListener() @@ -786,17 +793,17 @@ async def test_service_browser_instantiation_generates_add_events_from_cache(): callbacks = [] class MyServiceListener(ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -967,8 +974,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): nonlocal first_outgoing nonlocal second_outgoing if out.questions: - if first_outgoing is not None and second_outgoing is None: - second_outgoing = out + if first_outgoing is not None and second_outgoing is None: # type: ignore[unreachable] + second_outgoing = out # type: ignore[unreachable] if first_outgoing is None: first_outgoing = out old_send(out, addr=addr, port=port) @@ -980,8 +987,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): await aiosinfo.async_request(aiozc.zeroconf, 1200) try: - assert first_outgoing.questions[0].unicast == True - assert second_outgoing.questions[0].unicast == False + assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] + assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] finally: await aiozc.async_close() @@ -998,17 +1005,17 @@ async def test_service_browser_ignores_unrelated_updates(): callbacks = [] class MyServiceListener(ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1143,15 +1150,15 @@ async def test_update_with_uppercase_names(run_isolated): callbacks = [] class MyServiceListener(ServiceListener): - def add_service(self, zc, type_, name) -> None: + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks callbacks.append(("add", type_, name)) - def remove_service(self, zc, type_, name) -> None: + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks callbacks.append(("remove", type_, name)) - def update_service(self, zc, type_, name) -> None: + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] nonlocal callbacks callbacks.append(("update", type_, name)) @@ -1159,12 +1166,12 @@ def update_service(self, zc, type_, name) -> None: browser = AsyncServiceBrowser(aiozc.zeroconf, "_http._tcp.local.", None, listener) protocol = aiozc.zeroconf.engine.protocols[0] - packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbc=\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' + packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbc=\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' # noqa: E501 protocol.datagram_received(packet, ('127.0.0.1', 6503)) await asyncio.sleep(0) - packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbcA\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' + packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbcA\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' # noqa: E501 protocol.datagram_received(packet, ('127.0.0.1', 6503)) - + await browser.async_cancel() await aiozc.async_close() assert callbacks == [ diff --git a/tests/test_cache.py b/tests/test_cache.py index 559b43573..aac7e0ca2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,6 +3,7 @@ """ Unit tests for zeroconf._cache. """ + import logging import unittest import unittest.mock @@ -35,7 +36,7 @@ def test_order(self): cached_record = cache.get(entry) assert cached_record == record2 - def test_adding_same_record_to_cache_different_ttls(self): + def test_adding_same_record_to_cache_different_ttls_with_get(self): """We should always get back the last entry we added if there are different TTLs. This ensures we only have one source of truth for TTLs as a record cannot @@ -45,11 +46,11 @@ def test_adding_same_record_to_cache_different_ttls(self): record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') cache = r.DNSCache() cache.async_add_records([record1, record2]) - entry = r.DNSEntry(record2) + entry = r.DNSEntry(record2.name, const._TYPE_A, const._CLASS_IN) cached_record = cache.get(entry) assert cached_record == record2 - def test_adding_same_record_to_cache_different_ttls(self): + def test_adding_same_record_to_cache_different_ttls_with_get_all(self): """Verify we only get one record back. The last record added should replace the previous since two diff --git a/tests/test_core.py b/tests/test_core.py index 1fa2c76fa..2927374ba 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,22 +7,23 @@ import itertools import logging import os -import pytest import socket import sys -import time import threading +import time import unittest import unittest.mock -from typing import cast +from typing import Set, cast from unittest.mock import patch +import pytest + import zeroconf as r -from zeroconf import _core, const, Zeroconf, current_time_millis, NotRunningException -from zeroconf.asyncio import AsyncZeroconf +from zeroconf import NotRunningException, Zeroconf, _core, const, current_time_millis from zeroconf._protocol import outgoing +from zeroconf.asyncio import AsyncZeroconf -from . import has_working_ipv6, _clear_cache, _inject_response, _wait_for_start +from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -61,7 +62,7 @@ async def test_reaper(): zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = { + other_known_answers: Set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, @@ -114,7 +115,7 @@ def test_launch_and_close_context_manager(self): assert rv.done is False assert rv.done is True - with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: + with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: # type: ignore[unreachable] assert rv.done is False assert rv.done is True @@ -285,6 +286,7 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS # service removed _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + assert dns_text is not None assert dns_text.is_expired(current_time_millis() + 1000) finally: @@ -394,7 +396,9 @@ def test_register_service_with_custom_ttl(): ) zc.register_service(info_service, ttl=3000) - assert zc.cache.get(info_service.dns_pointer()).ttl == 3000 + record = zc.cache.get(info_service.dns_pointer()) + assert record is not None + assert record.ttl == 3000 zc.close() @@ -422,7 +426,9 @@ def test_logging_packets(caplog): caplog.clear() zc.register_service(info_service, ttl=3000) assert "Sending to" in caplog.text - assert zc.cache.get(info_service.dns_pointer()).ttl == 3000 + record = zc.cache.get(info_service.dns_pointer()) + assert record is not None + assert record.ttl == 3000 logging.getLogger('zeroconf').setLevel(logging.INFO) caplog.clear() zc.unregister_service(info_service) @@ -609,8 +615,7 @@ def test_tc_bit_defers_last_response_missing(): threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) assert protocol._deferred[source_ip] == expected_deferred timer2 = protocol._timers[source_ip] - if sys.version_info >= (3, 7): - assert timer1.cancelled() + assert timer1.cancelled() assert timer2 != timer1 # Send the same packet again to similar multi interfaces @@ -618,8 +623,7 @@ def test_tc_bit_defers_last_response_missing(): assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer3 = protocol._timers[source_ip] - if sys.version_info >= (3, 7): - assert not timer3.cancelled() + assert not timer3.cancelled() assert timer3 == timer2 next_packet = r.DNSIncoming(packets.pop(0)) @@ -628,8 +632,7 @@ def test_tc_bit_defers_last_response_missing(): assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer4 = protocol._timers[source_ip] - if sys.version_info >= (3, 7): - assert timer3.cancelled() + assert timer3.cancelled() assert timer4 != timer3 for _ in range(8): @@ -672,9 +675,10 @@ async def test_multiple_sync_instances_stared_from_async_close(): # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) zc2 = Zeroconf(interfaces=['127.0.0.1']) + assert zc.loop is not None + assert zc2.loop is not None assert zc.loop == zc2.loop - zc.close() assert zc.loop.is_running() zc2.close() diff --git a/tests/test_dns.py b/tests/test_dns.py index 7de7fa99e..59b4932aa 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -11,13 +11,8 @@ import unittest.mock import zeroconf as r -from zeroconf import const, current_time_millis +from zeroconf import DNSHinfo, DNSText, ServiceInfo, const, current_time_millis from zeroconf._dns import DNSRRSet -from zeroconf import ( - DNSHinfo, - DNSText, - ServiceInfo, -) from . import has_working_ipv6 diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 28a63ce8c..aa5086c58 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -8,11 +8,7 @@ import unittest.mock import zeroconf as r -from zeroconf import ( - ServiceInfo, - Zeroconf, -) - +from zeroconf import ServiceInfo, Zeroconf log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 1b630a1b5..cadbbaa35 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -6,21 +6,23 @@ import asyncio import logging import os -import pytest import socket import time import unittest import unittest.mock -from typing import List +from typing import List, cast + +import pytest import zeroconf as r -from zeroconf import _handlers, ServiceInfo, Zeroconf, current_time_millis -from zeroconf import const -from zeroconf._handlers import construct_outgoing_multicast_answers, MulticastOutgoingQueue +from zeroconf import ServiceInfo, Zeroconf, _handlers, const, current_time_millis +from zeroconf._handlers import ( + MulticastOutgoingQueue, + construct_outgoing_multicast_answers, +) from zeroconf._utils.time import millis_to_seconds from zeroconf.asyncio import AsyncZeroconf - from . import _clear_cache, _inject_response, has_working_ipv6 log = logging.getLogger('zeroconf') @@ -306,7 +308,7 @@ def test_any_query_for_ptr(): question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) mcast_answers = list(question_answers.mcast_aggregate) assert mcast_answers[0].name == type_ - assert mcast_answers[0].alias == registration_name + assert mcast_answers[0].alias == registration_name # type: ignore[attr-defined] # unregister zc.registry.async_remove(info) zc.close() @@ -332,7 +334,7 @@ def test_aaaa_query(): packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) mcast_answers = list(question_answers.mcast_now) - assert mcast_answers[0].address == ipv6_address + assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] # unregister zc.registry.async_remove(info) zc.close() @@ -660,7 +662,7 @@ def test_known_answer_supression(): packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert not question_answers.ucast - expected_nsec_record: r.DNSNsec = list(question_answers.mcast_now)[0] + expected_nsec_record = cast(r.DNSNsec, list(question_answers.mcast_now)[0]) assert const._TYPE_A not in expected_nsec_record.rdtypes assert const._TYPE_AAAA in expected_nsec_record.rdtypes assert not question_answers.mcast_aggregate @@ -1019,6 +1021,7 @@ async def test_cache_flush_bit(): for packet in out.packets(): zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) assert zc.cache.async_get_unique(a_record) is original_a_record + assert original_a_record is not None assert original_a_record.ttl != 1 for record in new_records: assert zc.cache.async_get_unique(record) is not None @@ -1036,8 +1039,9 @@ async def test_cache_flush_bit(): assert zc.cache.async_get_unique(record) is not None cached_records = [zc.cache.async_get_unique(record) for record in new_records] - for record in cached_records: - record.created = current_time_millis() - 1001 + for cached_record in cached_records: + assert cached_record is not None + cached_record.created = current_time_millis() - 1001 fresh_address = socket.inet_aton("4.4.4.4") info.addresses = [fresh_address] @@ -1047,10 +1051,12 @@ async def test_cache_flush_bit(): out.add_answer_at_time(answer, 0) for packet in out.packets(): zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) - for record in cached_records: - assert record.ttl == 1 + for cached_record in cached_records: + assert cached_record is not None + assert cached_record.ttl == 1 for entry in zc.cache.async_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): + assert isinstance(entry, r.DNSAddress) if entry.address == fresh_address: assert entry.ttl > 1 else: @@ -1211,8 +1217,10 @@ async def test_guard_against_low_ptr_ttl(): zc.record_manager.async_updates_from_response(incoming) incoming_answer_low = zc.cache.async_get_unique(answer_with_low_ttl) + assert incoming_answer_low is not None assert incoming_answer_low.ttl == const._DNS_PTR_MIN_TTL incoming_answer_normal = zc.cache.async_get_unique(answer_with_normal_ttl) + assert incoming_answer_normal is not None assert incoming_answer_normal.ttl == const._DNS_OTHER_TTL assert zc.cache.async_get_unique(good_bye_answer) is None await aiozc.async_close() @@ -1555,7 +1563,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor """Update multiple records in one shot.""" updated.extend(records) - zc.add_listener(MyListener(), None) + zc.add_listener(MyListener(), None) # type: ignore[arg-type] await asyncio.sleep(0) # flush out any call soons assert "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text diff --git a/tests/test_history.py b/tests/test_history.py index 9da6b5679..a8b8ae146 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -3,9 +3,11 @@ """Unit tests for _history.py.""" -from zeroconf._history import QuestionHistory +from typing import Set + import zeroconf as r import zeroconf.const as const +from zeroconf._history import QuestionHistory def test_question_suppression(): @@ -13,12 +15,12 @@ def test_question_suppression(): question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = { + other_known_answers: Set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' ) } - our_known_answers = { + our_known_answers: Set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' ) @@ -47,7 +49,7 @@ def test_question_expire(): question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers = { + other_known_answers: Set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' ) diff --git a/tests/test_logger.py b/tests/test_logger.py index 9413f252c..84a46f89d 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -5,6 +5,7 @@ import logging from unittest.mock import call, patch + from zeroconf._logger import QuietLogger, set_logger_level_if_unset diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 55dbbe4d7..79f327559 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -13,11 +13,7 @@ from typing import cast import zeroconf as r -from zeroconf import DNSIncoming, const, current_time_millis -from zeroconf import ( - DNSHinfo, - DNSText, -) +from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis from . import has_working_ipv6 @@ -258,7 +254,7 @@ def test_many_questions_with_many_known_answers(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for _ in range(30): - question = r.DNSQuestion(f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) questions.append(question) assert len(generated.questions) == 30 @@ -297,12 +293,11 @@ def test_massive_probe_packet_split(self): questions = [] for _ in range(30): question = r.DNSQuestion( - f"_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE ) generated.add_question(question) questions.append(question) assert len(generated.questions) == 30 - now = current_time_millis() for _ in range(200): authorative_answer = r.DNSPointer( "myservice{i}_tcp._tcp.local.", @@ -753,7 +748,10 @@ def test_qm_packet_parser(): # 389951 1450.577370 192.168.107.111 224.0.0.251 MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT def test_qu_packet_parser(): """Test we can parse a query packet with the QU bit.""" - qu_packet = b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' + qu_packet = ( + b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local' + b'\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' + ) parsed = DNSIncoming(qu_packet) assert parsed.questions[0].unicast is True assert ",QU," in str(parsed.questions[0]) @@ -769,7 +767,7 @@ def test_parse_packet_with_nsec_record(): b"\x00\x00\x80\x00@" ) parsed = DNSIncoming(nsec_packet) - nsec_record = parsed.answers[3] + nsec_record = cast(r.DNSNsec, parsed.answers[3]) assert "nsec," in str(nsec_record) assert nsec_record.rdtypes == [16, 33] assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local." @@ -1015,8 +1013,9 @@ def test_txt_after_invalid_nsec_name_still_usable(): b'ce=0' ) parsed = r.DNSIncoming(packet) + txt_record = cast(r.DNSText, parsed.answers[4]) # The NSEC record with the invalid name compression should be skipped - assert parsed.answers[4].text == ( + assert txt_record.text == ( b'2info=/api/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers' b'=1.24.1\nbootseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn' b'9K9DLXJe0f3LP9747lbvFvh.Q45RuMaeC07rfXh7OJGm None: We make sure we handle RuntimeError here as this is not thread safe under PyPy """ - await aioutils._async_get_all_tasks(aioutils.get_running_loop()) + loop = aioutils.get_running_loop() + assert loop is not None + await aioutils._async_get_all_tasks(loop) if not hasattr(asyncio, 'all_tasks'): return with patch("zeroconf._utils.asyncio.asyncio.all_tasks", side_effect=RuntimeError): - await aioutils._async_get_all_tasks(aioutils.get_running_loop()) + await aioutils._async_get_all_tasks(loop) @pytest.mark.asyncio @@ -85,6 +88,7 @@ async def _still_running(): def _run_coro() -> None: runcoro_thread_ready.set() + assert loop is not None with contextlib.suppress(concurrent.futures.TimeoutError): asyncio.run_coroutine_threadsafe(_still_running(), loop).result(1) @@ -93,6 +97,7 @@ def _run_coro() -> None: runcoro_thread_ready.wait() time.sleep(0.1) + assert loop is not None aioutils.shutdown_loop(loop) for _ in range(5): if not loop.is_running(): @@ -118,11 +123,12 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() - task = None + task: Optional[asyncio.Task] = None async def _saved_sleep_task(): nonlocal task task = asyncio.create_task(asyncio.sleep(0.2)) + assert task is not None await task def _run_in_loop(): @@ -131,6 +137,7 @@ def _run_in_loop(): with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): await loop.run_in_executor(None, _run_in_loop) + assert task is not None # ensure the thread is shutdown task.cancel() await asyncio.sleep(0) diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 07feccb75..3df73f5aa 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -5,8 +5,8 @@ import pytest -from zeroconf._utils import name as nameutils from zeroconf import BadTypeInNameException +from zeroconf._utils import name as nameutils def test_service_type_name_overlong_type(): diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 367bd93b3..29844d575 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -2,16 +2,16 @@ """Unit tests for zeroconf._utils.net.""" +import errno +import socket +import unittest from unittest.mock import MagicMock, Mock, patch -import errno import ifaddr import pytest -import socket -import unittest -from zeroconf._utils import net as netutils import zeroconf as r +from zeroconf._utils import net as netutils def _generate_mock_adapters(): @@ -63,9 +63,9 @@ def test_ip6_addresses_to_indexes(): with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] - interfaces = ['2001:db8::'] + interfaces_2 = ['2001:db8::'] with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): - assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] + assert netutils.ip6_addresses_to_indexes(interfaces_2) == [(('2001:db8::', 1, 1), 1)] def test_normalize_interface_choice_errors(): @@ -188,11 +188,11 @@ def test_add_multicast_member(): # ENODEV should return False for ipv6 with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): - assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False + assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False # type: ignore[arg-type] # No IPv6 support should return False for IPv6 with patch("socket.inet_pton", side_effect=OSError()): - assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False + assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False # type: ignore[arg-type] # No error should return True with patch("socket.socket.setsockopt"): @@ -205,18 +205,18 @@ def test_bind_raises_skips_address(): def _mock_socket(*args, **kwargs): sock = MagicMock() - sock.bind = MagicMock(side_effect=OSError(err, "Error: {}".format(err))) + sock.bind = MagicMock(side_effect=OSError(err, f"Error: {err}")) return sock with patch("socket.socket", _mock_socket): - assert netutils.new_socket(("0.0.0.0", 0)) is None + assert netutils.new_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] err = errno.EAGAIN with pytest.raises(OSError), patch("socket.socket", _mock_socket): - netutils.new_socket(("0.0.0.0", 0)) + netutils.new_socket(("0.0.0.0", 0)) # type: ignore[arg-type] def test_new_respond_socket_new_socket_returns_none(): """Test new_respond_socket returns None if new_socket returns None.""" with patch.object(netutils, "new_socket", return_value=None): - assert netutils.new_respond_socket(("0.0.0.0", 0)) is None + assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] From 2be6fbfe3d10b185096814d2d0de322733d273cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 11:58:59 -1000 Subject: [PATCH 0745/1433] 0.39.5 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7e0cf60..fb7c66d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +# 0.39.5 + + - This is a stub version to initialize python-semantic-release + + This version will not be published + # 0.39.4 - Fix IP changes being missed by ServiceInfo (\#1102) @bdraco diff --git a/pyproject.toml b/pyproject.toml index 96a7968cc..4cfd22ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-zeroconf" -version = "0.39.4" +version = "0.39.5" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 9cda19480..173c8f81d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.4' +__version__ = '0.39.5' __license__ = 'LGPL' From 6a03f2ffe05ff8d7bbd7bc3d6efb70c8b567d7f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 12:19:14 -1000 Subject: [PATCH 0746/1433] chore: additional release automation prep (#1106) --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7773fc22..a2bee13a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: # - Publish to PyPI - name: Python Semantic Release uses: relekang/python-semantic-release@v7.32.2 + env: + REPOSITORY_URL: https://test.pypi.org/legacy/ + TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ with: github_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} From 804c9e5d497995763e930bfdbb81efd1a9f2c9c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 12:34:28 -1000 Subject: [PATCH 0747/1433] chore: fix ciwheel build tags (#1108) --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2bee13a4..ee59be471 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: - uses: actions/checkout@v3 with: - ref: "v${{ steps.release_tag.outputs.newest_release_tag }}" + ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 - name: Install cibuildwheel diff --git a/pyproject.toml b/pyproject.toml index 4cfd22ae1..26ee1c6eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ branch = "master" version_toml = "pyproject.toml:tool.poetry.version" version_variable = "src/zeroconf/__init__.py:__version__" build_command = "pip install poetry && poetry build" - +tag_format = "{version}" [tool.poetry.dependencies] python = "^3.7" From bb6f42631e532e6b1c9f81fc4de11d4003c6571e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 12:55:32 -1000 Subject: [PATCH 0748/1433] chore: more ci fixes for macos/windows (#1109) --- .github/workflows/ci.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee59be471..6bbc697f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: uses: actions/setup-python@v4 - name: Install python-semantic-release - run: python -m pip install python-semantic-release + run: pipx install python-semantic-release - name: Get Release Tag id: release_tag @@ -143,11 +143,8 @@ jobs: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 - - name: Install cibuildwheel - run: python -m pip install cibuildwheel==2.11.3 - - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v2.11.3 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* From 294ea13953bc83065be3fc4511409ba6ea8dcfe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 13:45:56 -1000 Subject: [PATCH 0749/1433] chore: re-enable uploads after CI renovations (#1110) --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bbc697f5..fd3a50d65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,9 +103,9 @@ jobs: # - Publish to PyPI - name: Python Semantic Release uses: relekang/python-semantic-release@v7.32.2 - env: - REPOSITORY_URL: https://test.pypi.org/legacy/ - TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ + # env: + # REPOSITORY_URL: https://test.pypi.org/legacy/ + # TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ with: github_token: ${{ secrets.GITHUB_TOKEN }} pypi_token: ${{ secrets.PYPI_TOKEN }} @@ -173,6 +173,5 @@ jobs: with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} - repository_url: https://test.pypi.org/legacy/ # To test: repository_url: https://test.pypi.org/legacy/ From 1f4224ef122299235013cb81b501f8ff9a30dea1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 13:46:10 -1000 Subject: [PATCH 0750/1433] feat: drop async_timeout requirement for python 3.11+ (#1107) --- poetry.lock | 2 +- pyproject.toml | 2 +- src/zeroconf/_utils/asyncio.py | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index a51adab8e..032c4ced4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -227,7 +227,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e1ee5359a3695e10975e06463e681d6cdc3e2a2a7268bca9b44fa025a8e8980b" +content-hash = "1b871ae566e35d2aa05a22a4ff564eaec72807a4c37a012e41f8287831435b74" [metadata.files] async-timeout = [ diff --git a/pyproject.toml b/pyproject.toml index 26ee1c6eb..67cd029e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ tag_format = "{version}" [tool.poetry.dependencies] python = "^3.7" -async-timeout = ">=3.0.1" +async-timeout = {version = ">=3.0.0", python = "<3.11"} ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index 3d3b7c110..3a5beb5a0 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -23,9 +23,13 @@ import asyncio import concurrent.futures import contextlib +import sys from typing import Any, Awaitable, Coroutine, Optional, Set -import async_timeout +if sys.version_info[:2] < (3, 11): + from async_timeout import timeout as asyncio_timeout +else: + from asyncio import timeout as asyncio_timeout from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT @@ -40,7 +44,7 @@ async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: """Wait for an event or timeout.""" with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(timeout): + async with asyncio_timeout(timeout): await event.wait() From 36bc03c07fb470a2d760fe6e5d2e943282e50c8d Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 17 Dec 2022 23:59:06 +0000 Subject: [PATCH 0751/1433] 0.40.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 + pyproject.toml | 2 +- src/zeroconf/__init__.py | 248 +++++++++++++++++++-------------------- 3 files changed, 129 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7c66d01..898c9a865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.40.0 (2022-12-17) +### Feature +* Drop async_timeout requirement for python 3.11+ ([#1107](https://github.com/python-zeroconf/python-zeroconf/issues/1107)) ([`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) + # 0.39.5 - This is a stub version to initialize python-semantic-release diff --git a/pyproject.toml b/pyproject.toml index 67cd029e3..eb792ff00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-zeroconf" -version = "0.39.5" +version = "0.40.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 173c8f81d..3de88a88f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -1,124 +1,124 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA -""" - -import sys - -from ._cache import DNSCache # noqa # import needed for backwards compat -from ._core import Zeroconf -from ._dns import ( # noqa # import needed for backwards compat - DNSAddress, - DNSEntry, - DNSHinfo, - DNSNsec, - DNSPointer, - DNSQuestion, - DNSQuestionType, - DNSRecord, - DNSService, - DNSText, -) -from ._exceptions import ( - AbstractMethodException, - BadTypeInNameException, - Error, - EventLoopBlocked, - IncomingDecodeError, - NamePartTooLongException, - NonUniqueNameException, - NotRunningException, - ServiceNameAlreadyRegistered, -) -from ._logger import QuietLogger, log # noqa # import needed for backwards compat -from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat -from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat -from ._services import ( # noqa # import needed for backwards compat - ServiceListener, - ServiceStateChange, - Signal, - SignalRegistrationInterface, -) -from ._services.browser import ServiceBrowser -from ._services.info import ( # noqa # import needed for backwards compat - ServiceInfo, - instance_name_from_service_info, -) -from ._services.registry import ( # noqa # import needed for backwards compat - ServiceRegistry, -) -from ._services.types import ZeroconfServiceTypes -from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.name import service_type_name # noqa # import needed for backwards compat -from ._utils.net import ( # noqa # import needed for backwards compat - InterfaceChoice, - InterfacesType, - IPVersion, - add_multicast_member, - autodetect_ip_version, - create_sockets, - get_all_addresses, - get_all_addresses_v6, -) -from ._utils.time import ( # noqa # import needed for backwards compat - current_time_millis, - millis_to_seconds, -) - -__author__ = 'Paul Scott-Murphy, William McBrine' -__maintainer__ = 'Jakub Stasiak ' -__version__ = '0.39.5' -__license__ = 'LGPL' - - -__all__ = [ - "__version__", - "Zeroconf", - "ServiceInfo", - "ServiceBrowser", - "ServiceListener", - "DNSQuestionType", - "InterfaceChoice", - "ServiceStateChange", - "IPVersion", - "ZeroconfServiceTypes", - "RecordUpdate", - "RecordUpdateListener", - "current_time_millis", - # Exceptions - "Error", - "AbstractMethodException", - "BadTypeInNameException", - "EventLoopBlocked", - "IncomingDecodeError", - "NamePartTooLongException", - "NonUniqueNameException", - "NotRunningException", - "ServiceNameAlreadyRegistered", -] - -if sys.version_info <= (3, 6): # pragma: no cover - raise ImportError( # pragma: no cover - ''' -Python version > 3.6 required for python-zeroconf. -If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 -If you need support for Python 3.5 please use version 0.28.0 - ''' - ) +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import sys + +from ._cache import DNSCache # noqa # import needed for backwards compat +from ._core import Zeroconf +from ._dns import ( # noqa # import needed for backwards compat + DNSAddress, + DNSEntry, + DNSHinfo, + DNSNsec, + DNSPointer, + DNSQuestion, + DNSQuestionType, + DNSRecord, + DNSService, + DNSText, +) +from ._exceptions import ( + AbstractMethodException, + BadTypeInNameException, + Error, + EventLoopBlocked, + IncomingDecodeError, + NamePartTooLongException, + NonUniqueNameException, + NotRunningException, + ServiceNameAlreadyRegistered, +) +from ._logger import QuietLogger, log # noqa # import needed for backwards compat +from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat +from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat +from ._services import ( # noqa # import needed for backwards compat + ServiceListener, + ServiceStateChange, + Signal, + SignalRegistrationInterface, +) +from ._services.browser import ServiceBrowser +from ._services.info import ( # noqa # import needed for backwards compat + ServiceInfo, + instance_name_from_service_info, +) +from ._services.registry import ( # noqa # import needed for backwards compat + ServiceRegistry, +) +from ._services.types import ZeroconfServiceTypes +from ._updates import RecordUpdate, RecordUpdateListener +from ._utils.name import service_type_name # noqa # import needed for backwards compat +from ._utils.net import ( # noqa # import needed for backwards compat + InterfaceChoice, + InterfacesType, + IPVersion, + add_multicast_member, + autodetect_ip_version, + create_sockets, + get_all_addresses, + get_all_addresses_v6, +) +from ._utils.time import ( # noqa # import needed for backwards compat + current_time_millis, + millis_to_seconds, +) + +__author__ = 'Paul Scott-Murphy, William McBrine' +__maintainer__ = 'Jakub Stasiak ' +__version__ = '0.40.0' +__license__ = 'LGPL' + + +__all__ = [ + "__version__", + "Zeroconf", + "ServiceInfo", + "ServiceBrowser", + "ServiceListener", + "DNSQuestionType", + "InterfaceChoice", + "ServiceStateChange", + "IPVersion", + "ZeroconfServiceTypes", + "RecordUpdate", + "RecordUpdateListener", + "current_time_millis", + # Exceptions + "Error", + "AbstractMethodException", + "BadTypeInNameException", + "EventLoopBlocked", + "IncomingDecodeError", + "NamePartTooLongException", + "NonUniqueNameException", + "NotRunningException", + "ServiceNameAlreadyRegistered", +] + +if sys.version_info <= (3, 6): # pragma: no cover + raise ImportError( # pragma: no cover + ''' +Python version > 3.6 required for python-zeroconf. +If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 +If you need support for Python 3.5 please use version 0.28.0 + ''' + ) From a330f62040475257c4a983044e1675aeb95e030a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 14:12:44 -1000 Subject: [PATCH 0752/1433] fix: fix project name in pyproject.toml (#1112) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eb792ff00..a70efdeaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "python-zeroconf" +name = "zeroconf" version = "0.40.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] From 007b428d9868a382a003ba2b1634696efe50bcab Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Dec 2022 00:22:10 +0000 Subject: [PATCH 0753/1433] 0.40.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898c9a865..41f371d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.40.1 (2022-12-18) +### Fix +* Fix project name in pyproject.toml ([#1112](https://github.com/python-zeroconf/python-zeroconf/issues/1112)) ([`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) + ## v0.40.0 (2022-12-17) ### Feature * Drop async_timeout requirement for python 3.11+ ([#1107](https://github.com/python-zeroconf/python-zeroconf/issues/1107)) ([`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) diff --git a/pyproject.toml b/pyproject.toml index a70efdeaf..34c872abd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.40.0" +version = "0.40.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3de88a88f..fa9908d2f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.40.0' +__version__ = '0.40.1' __license__ = 'LGPL' From 8d84ebb643075f290abb74878c0e4d0e688aea91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 15:19:14 -1000 Subject: [PATCH 0754/1433] chore: add urls for PyPI links (#1114) --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 34c872abd..c91b38dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ packages = [ { include = "zeroconf", from = "src" }, ] +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" +"Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md" [tool.semantic_release] branch = "master" From 26efeb09783050266242542228f34eb4dd83e30c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 15:19:49 -1000 Subject: [PATCH 0755/1433] feat: optimize incoming parser by adding pxd files (#1111) --- src/zeroconf/_protocol/__init__.py | 25 ------ src/zeroconf/_protocol/incoming.pxd | 80 +++++++++++++++++++ src/zeroconf/_protocol/incoming.py | 115 ++++++++++++++++++---------- src/zeroconf/_protocol/outgoing.py | 17 +++- 4 files changed, 167 insertions(+), 70 deletions(-) create mode 100644 src/zeroconf/_protocol/incoming.pxd diff --git a/src/zeroconf/_protocol/__init__.py b/src/zeroconf/_protocol/__init__.py index 561619b7d..2ef4b15b1 100644 --- a/src/zeroconf/_protocol/__init__.py +++ b/src/zeroconf/_protocol/__init__.py @@ -19,28 +19,3 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ - -from ..const import _FLAGS_QR_MASK, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, _FLAGS_TC - - -class DNSMessage: - """A base class for DNS messages.""" - - __slots__ = ('flags',) - - def __init__(self, flags: int) -> None: - """Construct a DNS message.""" - self.flags = flags - - def is_query(self) -> bool: - """Returns true if this is a query.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY - - def is_response(self) -> bool: - """Returns true if this is a response.""" - return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE - - @property - def truncated(self) -> bool: - """Returns true if this is a truncated.""" - return (self.flags & _FLAGS_TC) == _FLAGS_TC diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd new file mode 100644 index 000000000..511c789e3 --- /dev/null +++ b/src/zeroconf/_protocol/incoming.pxd @@ -0,0 +1,80 @@ + +import cython + + +cdef cython.uint DNS_COMPRESSION_HEADER_LEN +cdef cython.uint MAX_DNS_LABELS +cdef cython.uint DNS_COMPRESSION_POINTER_LEN +cdef cython.uint MAX_NAME_LENGTH + + +cdef cython.uint _TYPE_A +cdef cython.uint _TYPE_CNAME +cdef cython.uint _TYPE_PTR +cdef cython.uint _TYPE_TXT +cdef cython.uint _TYPE_SRV +cdef cython.uint _TYPE_HINFO +cdef cython.uint _TYPE_AAAA +cdef cython.uint _TYPE_NSEC +cdef cython.uint _FLAGS_QR_MASK +cdef cython.uint _FLAGS_QR_MASK +cdef cython.uint _FLAGS_TC +cdef cython.uint _FLAGS_QR_QUERY +cdef cython.uint _FLAGS_QR_RESPONSE + +cdef object UNPACK_3H +cdef object UNPACK_6H +cdef object UNPACK_HH +cdef object UNPACK_HHiH + +cdef object DECODE_EXCEPTIONS + +cdef object IncomingDecodeError + +cdef class DNSIncoming: + + cdef bint _did_read_others + cdef public unsigned int flags + cdef unsigned int offset + cdef public bytes data + cdef unsigned int _data_len + cdef public object name_cache + cdef public object questions + cdef object _answers + cdef public object id + cdef public object num_questions + cdef public object num_answers + cdef public object num_authorities + cdef public object num_additionals + cdef public object valid + cdef public object now + cdef public object scope_id + cdef public object source + + @cython.locals( + off=cython.uint, + label_idx=cython.uint, + length=cython.uint, + link=cython.uint + ) + cdef _decode_labels_at_offset(self, unsigned int off, cython.list labels, object seen_pointers) + + cdef _read_header(self) + + cdef _read_questions(self) + + @cython.locals( + length=cython.uint + ) + cdef bytes _read_character_string(self) + + cdef _read_string(self, unsigned int length) + + @cython.locals( + name_start=cython.uint + ) + cdef _read_record(self, object domain, unsigned int type_, object class_, object ttl, object length) + + cdef _read_bitmap(self, unsigned int end) + + cdef _read_name(self) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 200d9f664..0fcfaba91 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -21,7 +21,8 @@ """ import struct -from typing import Callable, Dict, List, Optional, Set, Tuple, cast +import sys +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast from .._dns import ( DNSAddress, @@ -34,9 +35,13 @@ DNSText, ) from .._exceptions import IncomingDecodeError -from .._logger import QuietLogger, log +from .._logger import log from .._utils.time import current_time_millis from ..const import ( + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, + _FLAGS_TC, _TYPE_A, _TYPE_AAAA, _TYPE_CNAME, @@ -47,7 +52,6 @@ _TYPE_TXT, _TYPES, ) -from . import DNSMessage DNS_COMPRESSION_HEADER_LEN = 1 DNS_COMPRESSION_POINTER_LEN = 2 @@ -61,15 +65,18 @@ UNPACK_HH = struct.Struct(b'!HH').unpack UNPACK_HHiH = struct.Struct(b'!HHiH').unpack +_seen_logs: Dict[str, Union[int, tuple]] = {} -class DNSIncoming(DNSMessage, QuietLogger): +class DNSIncoming: """Object representation of an incoming DNS packet""" __slots__ = ( + "_did_read_others", + 'flags', 'offset', 'data', - 'data_len', + '_data_len', 'name_cache', 'questions', '_answers', @@ -92,10 +99,10 @@ def __init__( now: Optional[float] = None, ) -> None: """Constructor from string holding bytes of packet""" - super().__init__(0) + self.flags = 0 self.offset = 0 self.data = data - self.data_len = len(data) + self._data_len = len(data) self.name_cache: Dict[int, List[str]] = {} self.questions: List[DNSQuestion] = [] self._answers: List[DNSRecord] = [] @@ -105,18 +112,31 @@ def __init__( self.num_authorities = 0 self.num_additionals = 0 self.valid = False - self._read_others = False + self._did_read_others = False self.now = now or current_time_millis() self.source = source self.scope_id = scope_id self._parse_data(self._initial_parse) + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + + @property + def truncated(self) -> bool: + """Returns true if this is a truncated.""" + return (self.flags & _FLAGS_TC) == _FLAGS_TC + def _initial_parse(self) -> None: """Parse the data needed to initalize the packet object.""" - self.read_header() - self.read_questions() + self._read_header() + self._read_questions() if not self.num_questions: - self.read_others() + self._read_others() self.valid = True def _parse_data(self, parser_call: Callable) -> None: @@ -124,18 +144,29 @@ def _parse_data(self, parser_call: Callable) -> None: try: parser_call() except DECODE_EXCEPTIONS: - self.log_exception_debug( + self._log_exception_debug( 'Received invalid packet from %s at offset %d while unpacking %r', self.source, self.offset, self.data, ) + @classmethod + def _log_exception_debug(cls, *logger_data: Any) -> None: + log_exc_info = False + exc_info = sys.exc_info() + exc_str = str(exc_info[1]) + if exc_str not in _seen_logs: + # log the trace only on the first time + _seen_logs[exc_str] = exc_info + log_exc_info = True + log.debug(*(logger_data or ['Exception occurred']), exc_info=log_exc_info) + @property def answers(self) -> List[DNSRecord]: """Answers in the packet.""" - if not self._read_others: - self._parse_data(self.read_others) + if not self._did_read_others: + self._parse_data(self._read_others) return self._answers def __repr__(self) -> str: @@ -153,11 +184,11 @@ def __repr__(self) -> str: ] ) - def unpack(self, unpacker: Callable[[bytes], tuple], length: int) -> tuple: + def _unpack(self, unpacker: Callable[[bytes], tuple], length: int) -> tuple: self.offset += length return unpacker(self.data[self.offset - length : self.offset]) - def read_header(self) -> None: + def _read_header(self) -> None: """Reads header portion of packet""" ( self.id, @@ -166,38 +197,38 @@ def read_header(self) -> None: self.num_answers, self.num_authorities, self.num_additionals, - ) = self.unpack(UNPACK_6H, 12) + ) = self._unpack(UNPACK_6H, 12) - def read_questions(self) -> None: + def _read_questions(self) -> None: """Reads questions section of packet""" self.questions = [ - DNSQuestion(self.read_name(), *self.unpack(UNPACK_HH, 4)) for _ in range(self.num_questions) + DNSQuestion(self._read_name(), *self._unpack(UNPACK_HH, 4)) for _ in range(self.num_questions) ] - def read_character_string(self) -> bytes: + def _read_character_string(self) -> bytes: """Reads a character string from the packet""" length = self.data[self.offset] self.offset += 1 - return self.read_string(length) + return self._read_string(length) - def read_string(self, length: int) -> bytes: + def _read_string(self, length: int) -> bytes: """Reads a string of a given length from the packet""" info = self.data[self.offset : self.offset + length] self.offset += length return info - def read_others(self) -> None: + def _read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" - self._read_others = True + self._did_read_others = True n = self.num_answers + self.num_authorities + self.num_additionals for _ in range(n): - domain = self.read_name() - type_, class_, ttl, length = self.unpack(UNPACK_HHiH, 10) + domain = self._read_name() + type_, class_, ttl, length = self._unpack(UNPACK_HHiH, 10) end = self.offset + length rec = None try: - rec = self.read_record(domain, type_, class_, ttl, length) + rec = self._read_record(domain, type_, class_, ttl, length) except DECODE_EXCEPTIONS: # Skip records that fail to decode if we know the length # If the packet is really corrupt read_name and the unpack @@ -214,22 +245,22 @@ def read_others(self) -> None: if rec is not None: self._answers.append(rec) - def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: + def _read_record(self, domain, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: # type: ignore[no-untyped-def] """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - return DNSAddress(domain, type_, class_, ttl, self.read_string(4), created=self.now) + return DNSAddress(domain, type_, class_, ttl, self._read_string(4), created=self.now) if type_ in (_TYPE_CNAME, _TYPE_PTR): - return DNSPointer(domain, type_, class_, ttl, self.read_name(), self.now) + return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) if type_ == _TYPE_TXT: - return DNSText(domain, type_, class_, ttl, self.read_string(length), self.now) + return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) if type_ == _TYPE_SRV: return DNSService( domain, type_, class_, ttl, - *cast(Tuple[int, int, int], self.unpack(UNPACK_3H, 6)), - self.read_name(), + *cast(Tuple[int, int, int], self._unpack(UNPACK_3H, 6)), + self._read_name(), self.now, ) if type_ == _TYPE_HINFO: @@ -238,13 +269,13 @@ def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: in type_, class_, ttl, - self.read_character_string().decode('utf-8'), - self.read_character_string().decode('utf-8'), + self._read_character_string().decode('utf-8'), + self._read_character_string().decode('utf-8'), self.now, ) if type_ == _TYPE_AAAA: return DNSAddress( - domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id + domain, type_, class_, ttl, self._read_string(16), created=self.now, scope_id=self.scope_id ) if type_ == _TYPE_NSEC: name_start = self.offset @@ -253,8 +284,8 @@ def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: in type_, class_, ttl, - self.read_name(), - self.read_bitmap(name_start + length), + self._read_name(), + self._read_bitmap(name_start + length), self.now, ) # Try to ignore types we don't know about @@ -263,7 +294,7 @@ def read_record(self, domain: str, type_: int, class_: int, ttl: int, length: in self.offset += length return None - def read_bitmap(self, end: int) -> List[int]: + def _read_bitmap(self, end: int) -> List[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] while self.offset < end: @@ -276,7 +307,7 @@ def read_bitmap(self, end: int) -> List[int]: self.offset += 2 + bitmap_length return rdtypes - def read_name(self) -> str: + def _read_name(self) -> str: """Reads a domain name from the packet.""" labels: List[str] = [] seen_pointers: Set[int] = set() @@ -292,7 +323,7 @@ def read_name(self) -> str: def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. - while off < self.data_len: + while off < self._data_len: length = self.data[off] if length == 0: return off + DNS_COMPRESSION_HEADER_LEN @@ -310,7 +341,7 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S # We have a DNS compression pointer link = (length & 0x3F) * 256 + self.data[off + 1] - if link > self.data_len: + if link > self._data_len: raise IncomingDecodeError( f"DNS compression pointer at {off} points to {link} beyond packet from {self.source}" ) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index ad9295ba0..3c486bf8a 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -30,19 +30,22 @@ from ..const import ( _CLASS_UNIQUE, _DNS_PACKET_HEADER_LEN, + _FLAGS_QR_MASK, + _FLAGS_QR_QUERY, + _FLAGS_QR_RESPONSE, _FLAGS_TC, _MAX_MSG_ABSOLUTE, _MAX_MSG_TYPICAL, ) -from . import DNSMessage from .incoming import DNSIncoming -class DNSOutgoing(DNSMessage): +class DNSOutgoing: """Object representation of an outgoing packet""" __slots__ = ( + 'flags', 'finished', 'id', 'multicast', @@ -59,7 +62,7 @@ class DNSOutgoing(DNSMessage): ) def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: - super().__init__(flags) + self.flags = flags self.finished = False self.id = id_ self.multicast = multicast @@ -78,6 +81,14 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.authorities: List[DNSPointer] = [] self.additionals: List[DNSRecord] = [] + def is_query(self) -> bool: + """Returns true if this is a query.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_QUERY + + def is_response(self) -> bool: + """Returns true if this is a response.""" + return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + def _reset_for_next_packet(self) -> None: self.names = {} self.data = [] From f41fcc32d3971ee898819dcf0c420bb17dddbc1c Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Dec 2022 01:35:11 +0000 Subject: [PATCH 0756/1433] 0.41.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f371d7b..3f7ac8a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.41.0 (2022-12-18) +### Feature +* Optimize incoming parser by adding pxd files ([#1111](https://github.com/python-zeroconf/python-zeroconf/issues/1111)) ([`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) + ## v0.40.1 (2022-12-18) ### Fix * Fix project name in pyproject.toml ([#1112](https://github.com/python-zeroconf/python-zeroconf/issues/1112)) ([`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) diff --git a/pyproject.toml b/pyproject.toml index c91b38dd2..25343fbc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.40.1" +version = "0.41.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index fa9908d2f..7b49a4202 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.40.1' +__version__ = '0.41.0' __license__ = 'LGPL' From a7d50baab362eadd2d292df08a39de6836b41ea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 15:56:45 -1000 Subject: [PATCH 0757/1433] feat: optimize incoming parser by using unpack_from (#1115) --- src/zeroconf/_protocol/incoming.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 0fcfaba91..998ddbd20 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -60,10 +60,10 @@ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) -UNPACK_3H = struct.Struct(b'!3H').unpack -UNPACK_6H = struct.Struct(b'!6H').unpack -UNPACK_HH = struct.Struct(b'!HH').unpack -UNPACK_HHiH = struct.Struct(b'!HHiH').unpack +UNPACK_3H = struct.Struct(b'!3H').unpack_from +UNPACK_6H = struct.Struct(b'!6H').unpack_from +UNPACK_HH = struct.Struct(b'!HH').unpack_from +UNPACK_HHiH = struct.Struct(b'!HHiH').unpack_from _seen_logs: Dict[str, Union[int, tuple]] = {} @@ -184,9 +184,9 @@ def __repr__(self) -> str: ] ) - def _unpack(self, unpacker: Callable[[bytes], tuple], length: int) -> tuple: + def _unpack(self, unpacker: Callable[[bytes, int], tuple], length: int) -> tuple: self.offset += length - return unpacker(self.data[self.offset - length : self.offset]) + return unpacker(self.data, self.offset - length) def _read_header(self) -> None: """Reads header portion of packet""" @@ -224,7 +224,8 @@ def _read_others(self) -> None: n = self.num_answers + self.num_authorities + self.num_additionals for _ in range(n): domain = self._read_name() - type_, class_, ttl, length = self._unpack(UNPACK_HHiH, 10) + type_, class_, ttl, length = UNPACK_HHiH(self.data, self.offset) + self.offset += 10 end = self.offset + length rec = None try: From bef91946178a42878b06163743530fd2f62396f0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Dec 2022 02:06:46 +0000 Subject: [PATCH 0758/1433] 0.42.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7ac8a23..a4b355e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.42.0 (2022-12-18) +### Feature +* Optimize incoming parser by using unpack_from ([#1115](https://github.com/python-zeroconf/python-zeroconf/issues/1115)) ([`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) + ## v0.41.0 (2022-12-18) ### Feature * Optimize incoming parser by adding pxd files ([#1111](https://github.com/python-zeroconf/python-zeroconf/issues/1111)) ([`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) diff --git a/pyproject.toml b/pyproject.toml index 25343fbc3..c7ae39e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.41.0" +version = "0.42.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7b49a4202..7868fca89 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.41.0' +__version__ = '0.42.0' __license__ = 'LGPL' From 11f3f0e699e00c1ee3d6d8ab5e30f62525510589 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 16:38:37 -1000 Subject: [PATCH 0759/1433] feat: optimize incoming parser by reducing call stack (#1116) --- src/zeroconf/_protocol/incoming.pxd | 18 +++++++++++----- src/zeroconf/_protocol/incoming.py | 32 ++++++++++++++++------------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 511c789e3..79130d8a2 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -42,10 +42,10 @@ cdef class DNSIncoming: cdef public object questions cdef object _answers cdef public object id - cdef public object num_questions - cdef public object num_answers - cdef public object num_authorities - cdef public object num_additionals + cdef public cython.uint num_questions + cdef public cython.uint num_answers + cdef public cython.uint num_authorities + cdef public cython.uint num_additionals cdef public object valid cdef public object now cdef public object scope_id @@ -61,6 +61,14 @@ cdef class DNSIncoming: cdef _read_header(self) + cdef _initial_parse(self) + + @cython.locals( + end=cython.uint, + length=cython.uint + ) + cdef _read_others(self) + cdef _read_questions(self) @cython.locals( @@ -73,7 +81,7 @@ cdef class DNSIncoming: @cython.locals( name_start=cython.uint ) - cdef _read_record(self, object domain, unsigned int type_, object class_, object ttl, object length) + cdef _read_record(self, object domain, unsigned int type_, object class_, object ttl, unsigned int length) cdef _read_bitmap(self, unsigned int end) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 998ddbd20..c9e04e122 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -116,7 +116,15 @@ def __init__( self.now = now or current_time_millis() self.source = source self.scope_id = scope_id - self._parse_data(self._initial_parse) + try: + self._initial_parse() + except DECODE_EXCEPTIONS: + self._log_exception_debug( + 'Received invalid packet from %s at offset %d while unpacking %r', + self.source, + self.offset, + self.data, + ) def is_query(self) -> bool: """Returns true if this is a query.""" @@ -139,18 +147,6 @@ def _initial_parse(self) -> None: self._read_others() self.valid = True - def _parse_data(self, parser_call: Callable) -> None: - """Parse part of the packet and catch exceptions.""" - try: - parser_call() - except DECODE_EXCEPTIONS: - self._log_exception_debug( - 'Received invalid packet from %s at offset %d while unpacking %r', - self.source, - self.offset, - self.data, - ) - @classmethod def _log_exception_debug(cls, *logger_data: Any) -> None: log_exc_info = False @@ -166,7 +162,15 @@ def _log_exception_debug(cls, *logger_data: Any) -> None: def answers(self) -> List[DNSRecord]: """Answers in the packet.""" if not self._did_read_others: - self._parse_data(self._read_others) + try: + self._read_others() + except DECODE_EXCEPTIONS: + self._log_exception_debug( + 'Received invalid packet from %s at offset %d while unpacking %r', + self.source, + self.offset, + self.data, + ) return self._answers def __repr__(self) -> str: From a42e2d723729b9812682077d74e0b57496a5bcb0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Dec 2022 02:45:49 +0000 Subject: [PATCH 0760/1433] 0.43.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b355e9f..c75e31efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.43.0 (2022-12-18) +### Feature +* Optimize incoming parser by reducing call stack ([#1116](https://github.com/python-zeroconf/python-zeroconf/issues/1116)) ([`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) + ## v0.42.0 (2022-12-18) ### Feature * Optimize incoming parser by using unpack_from ([#1115](https://github.com/python-zeroconf/python-zeroconf/issues/1115)) ([`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) diff --git a/pyproject.toml b/pyproject.toml index c7ae39e37..bfb0ce318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.42.0" +version = "0.43.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7868fca89..492cb4511 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.42.0' +__version__ = '0.43.0' __license__ = 'LGPL' From 919d4d875747b4fa68e25bccd5aae7f304d8a36d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Dec 2022 17:13:42 -1000 Subject: [PATCH 0761/1433] feat: optimize dns objects by adding pxd files (#1113) --- src/zeroconf/_dns.pxd | 76 ++++++++++++++++++++++++++++++++++ tests/services/test_browser.py | 3 ++ 2 files changed, 79 insertions(+) create mode 100644 src/zeroconf/_dns.pxd diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd new file mode 100644 index 000000000..8246326b6 --- /dev/null +++ b/src/zeroconf/_dns.pxd @@ -0,0 +1,76 @@ + + + +cdef object _LEN_BYTE +cdef object _LEN_SHORT +cdef object _LEN_INT + +cdef object _NAME_COMPRESSION_MIN_SIZE +cdef object _BASE_MAX_SIZE + +cdef object _EXPIRE_FULL_TIME_MS +cdef object _EXPIRE_STALE_TIME_MS +cdef object _RECENT_TIME_MS + + +cdef class DNSEntry: + + cdef public key + cdef public name + cdef public type + cdef public class_ + cdef public unique + +cdef class DNSQuestion(DNSEntry): + + cdef public _hash + +cdef class DNSRecord(DNSEntry): + + cdef public ttl + cdef public created + +cdef class DNSAddress(DNSRecord): + + cdef public _hash + cdef public address + cdef public scope_id + + +cdef class DNSHinfo(DNSRecord): + + cdef public _hash + cdef public cpu + cdef public os + + +cdef class DNSPointer(DNSRecord): + + cdef public _hash + cdef public alias + +cdef class DNSText(DNSRecord): + + cdef public _hash + cdef public text + +cdef class DNSService(DNSRecord): + + cdef public _hash + cdef public priority + cdef public weight + cdef public port + cdef public server + cdef public server_key + +cdef class DNSNsec(DNSRecord): + + cdef public _hash + cdef public next_name + cdef public rdtypes + + +cdef class DNSRRSet: + + cdef _records + cdef _lookup diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 4fdd28dfa..fd588648d 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -427,6 +427,9 @@ def _mock_get_expiration_time(self, percent): service_removed_event.wait(wait_time) assert service_added_count == 3 assert service_removed_count == 3 + except TypeError: + # Cannot be patched with cython as get_expiration_time is immutable + pass finally: assert len(zeroconf.listeners) == 1 From 1e9de3c88a0c16e2b37944c7db1ef1fd2e4c265c Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Dec 2022 03:21:20 +0000 Subject: [PATCH 0762/1433] 0.44.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c75e31efd..6347438db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.44.0 (2022-12-18) +### Feature +* Optimize dns objects by adding pxd files ([#1113](https://github.com/python-zeroconf/python-zeroconf/issues/1113)) ([`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) + ## v0.43.0 (2022-12-18) ### Feature * Optimize incoming parser by reducing call stack ([#1116](https://github.com/python-zeroconf/python-zeroconf/issues/1116)) ([`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) diff --git a/pyproject.toml b/pyproject.toml index bfb0ce318..a60412307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.43.0" +version = "0.44.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 492cb4511..66761ab20 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.43.0' +__version__ = '0.44.0' __license__ = 'LGPL' From 81e186d365c018381f9b486a4dbe4e2e4b8bacbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Dec 2022 10:20:32 -1000 Subject: [PATCH 0763/1433] feat: optimize construction of outgoing packets (#1118) --- bench/outgoing.py | 168 ++++++++++++++++++++++++++++ src/zeroconf/_protocol/outgoing.pxd | 68 +++++++++++ src/zeroconf/_protocol/outgoing.py | 63 ++++++----- tests/test_core.py | 17 ++- 4 files changed, 282 insertions(+), 34 deletions(-) create mode 100644 bench/outgoing.py create mode 100644 src/zeroconf/_protocol/outgoing.pxd diff --git a/bench/outgoing.py b/bench/outgoing.py new file mode 100644 index 000000000..bb5d99ced --- /dev/null +++ b/bench/outgoing.py @@ -0,0 +1,168 @@ +"""Benchmark for DNSOutgoing.""" +import socket +import timeit + +from zeroconf import DNSAddress, DNSOutgoing, DNSService, DNSText, const +from zeroconf._protocol.outgoing import State + + +def generate_packets() -> DNSOutgoing: + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + return out + + +out = generate_packets() + + +def make_outgoing_message() -> None: + out.state = State.init + out.finished = False + out.packets() + + +count = 100000 +time = timeit.Timer(make_outgoing_message).timeit(count) +print(f"Construction {count} outgoing messages took {time} seconds") diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd new file mode 100644 index 000000000..061fbfade --- /dev/null +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -0,0 +1,68 @@ + +import cython + +from .incoming cimport DNSIncoming + + +cdef cython.uint _CLASS_UNIQUE +cdef cython.uint _DNS_PACKET_HEADER_LEN +cdef cython.uint _FLAGS_QR_MASK +cdef cython.uint _FLAGS_QR_QUERY +cdef cython.uint _FLAGS_QR_RESPONSE +cdef cython.uint _FLAGS_TC +cdef cython.uint _MAX_MSG_ABSOLUTE +cdef cython.uint _MAX_MSG_TYPICAL + + +cdef class DNSOutgoing: + + cdef public unsigned int flags + cdef public object finished + cdef public object id + cdef public bint multicast + cdef public cython.list packets_data + cdef public object names + cdef public cython.list data + cdef public unsigned int size + cdef public object allow_long + cdef public object state + cdef public cython.list questions + cdef public cython.list answers + cdef public cython.list authorities + cdef public cython.list additionals + + cdef _reset_for_next_packet(self) + + cdef _write_byte(self, object value) + + cdef _insert_short_at_start(self, object value) + + cdef _replace_short(self, object index, object value) + + cdef _write_int(self, object value) + + cdef _write_question(self, object question) + + cdef _write_record_class(self, object record) + + cdef _check_data_limit_or_rollback(self, object start_data_length, object start_size) + + cdef _write_questions_from_offset(self, object questions_offset) + + cdef _write_answers_from_offset(self, object answer_offset) + + cdef _write_records_from_offset(self, object records, object offset) + + cdef _has_more_to_add(self, object questions_offset, object answer_offset, object authority_offset, object additional_offset) + + @cython.locals( + questions_offset=cython.uint, + answer_offset=cython.uint, + authority_offset=cython.uint, + additional_offset=cython.uint, + questions_written=cython.uint, + answers_written=cython.uint, + authorities_written=cython.uint, + additionals_written=cython.uint, + ) + cdef _packets(self) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 3c486bf8a..46d7434cd 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -21,6 +21,7 @@ """ import enum +import logging from typing import Dict, List, Optional, Sequence, Tuple, Union from .._cache import DNSCache @@ -40,6 +41,11 @@ from .incoming import DNSIncoming +class State(enum.Enum): + init = 0 + finished = 1 + + class DNSOutgoing: """Object representation of an outgoing packet""" @@ -74,7 +80,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.size: int = _DNS_PACKET_HEADER_LEN self.allow_long: bool = True - self.state = self.State.init + self.state = State.init self.questions: List[DNSQuestion] = [] self.answers: List[Tuple[DNSRecord, float]] = [] @@ -107,10 +113,6 @@ def __repr__(self) -> str: ] ) - class State(enum.Enum): - init = 0 - finished = 1 - def add_question(self, record: DNSQuestion) -> None: """Adds a question""" self.questions.append(record) @@ -373,8 +375,10 @@ def packets(self) -> List[bytes]: will be written out to a single oversized packet no more than _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP fragmentation potentially).""" + return self._packets() - if self.state == self.State.finished: + def _packets(self) -> List[bytes]: + if self.state == State.finished: return self.packets_data questions_offset = 0 @@ -383,25 +387,27 @@ def packets(self) -> List[bytes]: additional_offset = 0 # we have to at least write out the question first_time = True + debug_enable = log.isEnabledFor(logging.DEBUG) while first_time or self._has_more_to_add( questions_offset, answer_offset, authority_offset, additional_offset ): first_time = False - log.debug( - "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) - log.debug( - "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", - len(self.questions), - len(self.answers), - len(self.authorities), - len(self.additionals), - ) + if debug_enable: + log.debug( + "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) + log.debug( + "lengths = questions=%d, answers=%d, authorities=%d, additionals=%d", + len(self.questions), + len(self.answers), + len(self.authorities), + len(self.additionals), + ) questions_written = self._write_questions_from_offset(questions_offset) answers_written = self._write_answers_from_offset(answer_offset) @@ -417,13 +423,14 @@ def packets(self) -> List[bytes]: answer_offset += answers_written authority_offset += authorities_written additional_offset += additionals_written - log.debug( - "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", - questions_offset, - answer_offset, - authority_offset, - additional_offset, - ) + if debug_enable: + log.debug( + "now offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", + questions_offset, + answer_offset, + authority_offset, + additional_offset, + ) if self.is_query() and self._has_more_to_add( questions_offset, answer_offset, authority_offset, additional_offset @@ -447,5 +454,5 @@ def packets(self) -> List[bytes]: ) > 0: log.warning("packets() made no progress adding records; returning") break - self.state = self.State.finished + self.state = State.finished return self.packets_data diff --git a/tests/test_core.py b/tests/test_core.py index 2927374ba..299690d90 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -714,12 +714,17 @@ def test_guard_against_oversized_packets(): 0, ) - # We are patching to generate an oversized packet - with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( - outgoing, "_MAX_MSG_TYPICAL", 100000 - ): - over_sized_packet = generated.packets()[0] - assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE + try: + # We are patching to generate an oversized packet + with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( + outgoing, "_MAX_MSG_TYPICAL", 100000 + ): + over_sized_packet = generated.packets()[0] + assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE + except AttributeError: + # cannot patch with cython + zc.close() + return generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) okpacket_record = r.DNSText( From f57d9f15161ddd8563e80d53f1f48d0488985e64 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 20 Dec 2022 21:20:13 +0000 Subject: [PATCH 0764/1433] 0.45.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6347438db..4bdc66287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.45.0 (2022-12-20) +### Feature +* Optimize construction of outgoing packets ([#1118](https://github.com/python-zeroconf/python-zeroconf/issues/1118)) ([`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) + ## v0.44.0 (2022-12-18) ### Feature * Optimize dns objects by adding pxd files ([#1113](https://github.com/python-zeroconf/python-zeroconf/issues/1113)) ([`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) diff --git a/pyproject.toml b/pyproject.toml index a60412307..f3460a5bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.44.0" +version = "0.45.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 66761ab20..350d654b4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.44.0' +__version__ = '0.45.0' __license__ = 'LGPL' From e80fcef967024f8e846e44b464a82a25f5550edf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Dec 2022 10:38:43 -1000 Subject: [PATCH 0765/1433] feat: optimize the dns cache (#1119) --- build_ext.py | 1 + src/zeroconf/_cache.pxd | 28 ++++++++++++++++++++ src/zeroconf/_cache.py | 42 ++++++++++++++++++------------ src/zeroconf/_dns.pxd | 2 ++ src/zeroconf/_dns.py | 28 +++++++++++--------- src/zeroconf/_protocol/incoming.py | 5 +++- 6 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 src/zeroconf/_cache.pxd diff --git a/build_ext.py b/build_ext.py index 3746fb255..430ad9f1e 100644 --- a/build_ext.py +++ b/build_ext.py @@ -23,6 +23,7 @@ def build(setup_kwargs: Any) -> None: dict( ext_modules=cythonize( [ + "src/zeroconf/_cache.py", "src/zeroconf/_dns.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd new file mode 100644 index 000000000..acf4029e8 --- /dev/null +++ b/src/zeroconf/_cache.pxd @@ -0,0 +1,28 @@ +import cython +from ._dns cimport ( + DNSAddress, + DNSEntry, + DNSHinfo, + DNSPointer, + DNSRecord, + DNSService, + DNSText, +) + + +cdef object _TYPE_PTR + +cdef _remove_key(cython.dict cache, object key, DNSRecord record) + + +cdef class DNSCache: + + cdef public cython.dict cache + cdef public cython.dict service_cache + + cdef _async_add(self, DNSRecord record) + + cdef _async_remove(self, DNSRecord record) + + +cdef _dns_record_matches(DNSRecord record, object key, object type_, object class_) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index cb485eaf6..f022c2cbe 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -32,7 +32,6 @@ DNSRecord, DNSService, DNSText, - dns_entry_matches, ) from ._utils.time import current_time_millis from .const import _TYPE_PTR @@ -40,14 +39,16 @@ _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) _UniqueRecordsType = Union[DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService] _DNSRecordCacheType = Dict[str, Dict[DNSRecord, DNSRecord]] +_DNSRecord = DNSRecord +_str = str -def _remove_key(cache: _DNSRecordCacheType, key: str, entry: DNSRecord) -> None: +def _remove_key(cache: _DNSRecordCacheType, key: _str, record: _DNSRecord) -> None: """Remove a key from a DNSRecord cache This function must be run in from event loop. """ - del cache[key][entry] + del cache[key][record] if not cache[key]: del cache[key] @@ -62,7 +63,7 @@ def __init__(self) -> None: # Functions prefixed with async_ are NOT threadsafe and must # be run in the event loop. - def _async_add(self, entry: DNSRecord) -> bool: + def _async_add(self, record: _DNSRecord) -> bool: """Adds an entry. Returns true if the entry was not already in the cache. @@ -75,11 +76,11 @@ def _async_add(self, entry: DNSRecord) -> bool: # replaces any existing records that are __eq__ to each other which # removes the risk that accessing the cache from the wrong # direction would return the old incorrect entry. - store = self.cache.setdefault(entry.key, {}) - new = entry not in store and not isinstance(entry, DNSNsec) - store[entry] = entry - if isinstance(entry, DNSService): - self.service_cache.setdefault(entry.server_key, {})[entry] = entry + store = self.cache.setdefault(record.key, {}) + new = record not in store and not isinstance(record, DNSNsec) + store[record] = record + if isinstance(record, DNSService): + self.service_cache.setdefault(record.server_key, {})[record] = record return new def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: @@ -95,14 +96,14 @@ def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: new = True return new - def _async_remove(self, entry: DNSRecord) -> None: + def _async_remove(self, record: _DNSRecord) -> None: """Removes an entry. This function must be run in from event loop. """ - if isinstance(entry, DNSService): - _remove_key(self.service_cache, entry.server_key, entry) - _remove_key(self.cache, entry.key, entry) + if isinstance(record, DNSService): + _remove_key(self.service_cache, record.server_key, record) + _remove_key(self.cache, record.key, record) def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: """Remove multiple records. @@ -128,7 +129,10 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: This function is not threadsafe and must be called from the event loop. """ - return self.cache.get(entry.key, {}).get(entry) + store = self.cache.get(entry.key) + if store is None: + return None + return store.get(entry) def async_all_by_details(self, name: str, type_: int, class_: int) -> Iterator[DNSRecord]: """Gets all matching entries by details. @@ -138,7 +142,7 @@ def async_all_by_details(self, name: str, type_: int, class_: int) -> Iterator[D """ key = name.lower() for entry in self.cache.get(key, []): - if dns_entry_matches(entry, key, type_, class_): + if _dns_record_matches(entry, key, type_, class_): yield entry def async_entries_with_name(self, name: str) -> Dict[DNSRecord, DNSRecord]: @@ -185,7 +189,7 @@ def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSReco """ key = name.lower() for cached_entry in reversed(list(self.cache.get(key, []))): - if dns_entry_matches(cached_entry, key, type_, class_): + if _dns_record_matches(cached_entry, key, type_, class_): return cached_entry return None @@ -193,7 +197,7 @@ def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSReco """Gets all matching entries by details.""" key = name.lower() return [ - entry for entry in list(self.cache.get(key, [])) if dns_entry_matches(entry, key, type_, class_) + entry for entry in list(self.cache.get(key, [])) if _dns_record_matches(entry, key, type_, class_) ] def entries_with_server(self, server: str) -> List[DNSRecord]: @@ -218,3 +222,7 @@ def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[D def names(self) -> List[str]: """Return a copy of the list of current cache names.""" return list(self.cache) + + +def _dns_record_matches(record: _DNSRecord, key: _str, type_: int, class_: int) -> bool: + return key == record.key and type_ == record.type and class_ == record.class_ diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 8246326b6..c83269eff 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -74,3 +74,5 @@ cdef class DNSRRSet: cdef _records cdef _lookup + +cdef _dns_entry_matches(DNSEntry entry, object key, object type_, object class_) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 72b563770..4d46263e8 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -59,10 +59,6 @@ class DNSQuestionType(enum.Enum): QM = 2 -def dns_entry_matches(record: 'DNSEntry', key: str, type_: int, class_: int) -> bool: - return key == record.key and type_ == record.type and class_ == record.class_ - - class DNSEntry: """A DNS entry""" @@ -78,7 +74,7 @@ def __init__(self, name: str, type_: int, class_: int) -> None: def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" - return dns_entry_matches(other, self.key, self.type, self.class_) and isinstance(other, DNSEntry) + return _dns_entry_matches(other, self.key, self.type, self.class_) and isinstance(other, DNSEntry) @staticmethod def get_class_(class_: int) -> str: @@ -121,7 +117,7 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Tests equality on dns question.""" - return isinstance(other, DNSQuestion) and dns_entry_matches(other, self.key, self.type, self.class_) + return isinstance(other, DNSQuestion) and _dns_entry_matches(other, self.key, self.type, self.class_) @property def max_size(self) -> int: @@ -254,7 +250,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSAddress) and self.address == other.address and self.scope_id == other.scope_id - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -298,7 +294,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSHinfo) and self.cpu == other.cpu and self.os == other.os - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -342,7 +338,7 @@ def __eq__(self, other: Any) -> bool: return ( isinstance(other, DNSPointer) and self.alias == other.alias - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -381,7 +377,7 @@ def __eq__(self, other: Any) -> bool: return ( isinstance(other, DNSText) and self.text == other.text - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __repr__(self) -> str: @@ -432,7 +428,7 @@ def __eq__(self, other: Any) -> bool: and self.weight == other.weight and self.port == other.port and self.server == other.server - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -487,7 +483,7 @@ def __eq__(self, other: Any) -> bool: isinstance(other, DNSNsec) and self.next_name == other.next_name and self.rdtypes == other.rdtypes - and dns_entry_matches(other, self.key, self.type, self.class_) + and _dns_entry_matches(other, self.key, self.type, self.class_) ) def __hash__(self) -> int: @@ -527,3 +523,11 @@ def suppresses(self, record: DNSRecord) -> bool: def __contains__(self, record: DNSRecord) -> bool: """Returns true if the rrset contains the record.""" return record in self.lookup + + +_DNSEntry = DNSEntry +_str = str + + +def _dns_entry_matches(entry: _DNSEntry, key: _str, type_: int, class_: int) -> bool: + return key == entry.key and type_ == entry.type and class_ == entry.class_ diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index c9e04e122..aaf0340bc 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -66,6 +66,7 @@ UNPACK_HHiH = struct.Struct(b'!HHiH').unpack_from _seen_logs: Dict[str, Union[int, tuple]] = {} +_str = str class DNSIncoming: @@ -250,7 +251,9 @@ def _read_others(self) -> None: if rec is not None: self._answers.append(rec) - def _read_record(self, domain, type_: int, class_: int, ttl: int, length: int) -> Optional[DNSRecord]: # type: ignore[no-untyped-def] + def _read_record( + self, domain: _str, type_: int, class_: int, ttl: int, length: int + ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: return DNSAddress(domain, type_, class_, ttl, self._read_string(4), created=self.now) From 255a884f91ee0a959f1c943eca6d81e3d63ab5d1 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 21 Dec 2022 20:48:41 +0000 Subject: [PATCH 0766/1433] 0.46.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdc66287..95574dca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.46.0 (2022-12-21) +### Feature +* Optimize the dns cache ([#1119](https://github.com/python-zeroconf/python-zeroconf/issues/1119)) ([`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) + ## v0.45.0 (2022-12-20) ### Feature * Optimize construction of outgoing packets ([#1118](https://github.com/python-zeroconf/python-zeroconf/issues/1118)) ([`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) diff --git a/pyproject.toml b/pyproject.toml index f3460a5bd..69ecbf31f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.45.0" +version = "0.46.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 350d654b4..af7feb219 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.45.0' +__version__ = '0.46.0' __license__ = 'LGPL' From 3a25ff74bea83cd7d50888ce1ebfd7650d704bfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Dec 2022 16:49:11 -1000 Subject: [PATCH 0767/1433] feat: optimize equality checks for DNS records (#1120) --- src/zeroconf/_dns.pxd | 82 ++++++++++++++++++++++++------------- src/zeroconf/_dns.py | 94 +++++++++++++++++++++++-------------------- 2 files changed, 104 insertions(+), 72 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index c83269eff..762e93192 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -1,4 +1,5 @@ +import cython cdef object _LEN_BYTE @@ -12,67 +13,90 @@ cdef object _EXPIRE_FULL_TIME_MS cdef object _EXPIRE_STALE_TIME_MS cdef object _RECENT_TIME_MS +cdef object _CLASS_UNIQUE +cdef object _CLASS_MASK cdef class DNSEntry: - cdef public key - cdef public name - cdef public type - cdef public class_ - cdef public unique + cdef public object key + cdef public object name + cdef public object type + cdef public object class_ + cdef public object unique + + cdef _dns_entry_matches(self, DNSEntry other) cdef class DNSQuestion(DNSEntry): - cdef public _hash + cdef public cython.int _hash cdef class DNSRecord(DNSEntry): - cdef public ttl - cdef public created + cdef public object ttl + cdef public object created + + cdef _suppressed_by_answer(self, DNSRecord answer) + cdef class DNSAddress(DNSRecord): - cdef public _hash - cdef public address - cdef public scope_id + cdef public cython.int _hash + cdef public object address + cdef public object scope_id + + cdef _eq(self, DNSAddress other) cdef class DNSHinfo(DNSRecord): - cdef public _hash - cdef public cpu - cdef public os + cdef public cython.int _hash + cdef public object cpu + cdef public object os + + cdef _eq(self, DNSHinfo other) cdef class DNSPointer(DNSRecord): - cdef public _hash - cdef public alias + cdef public cython.int _hash + cdef public object alias + + cdef _eq(self, DNSPointer other) + cdef class DNSText(DNSRecord): - cdef public _hash - cdef public text + cdef public cython.int _hash + cdef public object text + + cdef _eq(self, DNSText other) + cdef class DNSService(DNSRecord): - cdef public _hash - cdef public priority - cdef public weight - cdef public port - cdef public server - cdef public server_key + cdef public cython.int _hash + cdef public object priority + cdef public object weight + cdef public object port + cdef public object server + cdef public object server_key + + cdef _eq(self, DNSService other) + cdef class DNSNsec(DNSRecord): - cdef public _hash - cdef public next_name - cdef public rdtypes + cdef public cython.int _hash + cdef public object next_name + cdef public cython.list rdtypes + + cdef _eq(self, DNSNsec other) cdef class DNSRRSet: cdef _records - cdef _lookup + cdef cython.dict _lookup -cdef _dns_entry_matches(DNSEntry entry, object key, object type_, object class_) + @cython.locals(other=DNSRecord) + cpdef suppresses(self, DNSRecord record) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 4d46263e8..f9e33541c 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -72,9 +72,12 @@ def __init__(self, name: str, type_: int, class_: int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 + def _dns_entry_matches(self, other) -> bool: # type: ignore[no-untyped-def] + return self.key == other.key and self.type == other.type and self.class_ == other.class_ + def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" - return _dns_entry_matches(other, self.key, self.type, self.class_) and isinstance(other, DNSEntry) + return isinstance(other, DNSEntry) and self._dns_entry_matches(other) @staticmethod def get_class_(class_: int) -> str: @@ -117,7 +120,7 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Tests equality on dns question.""" - return isinstance(other, DNSQuestion) and _dns_entry_matches(other, self.key, self.type, self.class_) + return isinstance(other, DNSQuestion) and self._dns_entry_matches(other) @property def max_size(self) -> int: @@ -169,9 +172,9 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" - return any(self.suppressed_by_answer(record) for record in msg.answers) + return any(self._suppressed_by_answer(record) for record in msg.answers) - def suppressed_by_answer(self, other: 'DNSRecord') -> bool: + def _suppressed_by_answer(self, other) -> bool: # type: ignore[no-untyped-def] """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) @@ -246,11 +249,13 @@ def write(self, out: 'DNSOutgoing') -> None: def __eq__(self, other: Any) -> bool: """Tests equality on address""" + return isinstance(other, DNSAddress) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] return ( - isinstance(other, DNSAddress) - and self.address == other.address + self.address == other.address and self.scope_id == other.scope_id - and _dns_entry_matches(other, self.key, self.type, self.class_) + and self._dns_entry_matches(other) ) def __hash__(self) -> int: @@ -289,13 +294,12 @@ def write(self, out: 'DNSOutgoing') -> None: out.write_character_string(self.os.encode('utf-8')) def __eq__(self, other: Any) -> bool: - """Tests equality on cpu and os""" - return ( - isinstance(other, DNSHinfo) - and self.cpu == other.cpu - and self.os == other.os - and _dns_entry_matches(other, self.key, self.type, self.class_) - ) + """Tests equality on cpu and os.""" + return isinstance(other, DNSHinfo) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + """Tests equality on cpu and os.""" + return self.cpu == other.cpu and self.os == other.os and self._dns_entry_matches(other) def __hash__(self) -> int: """Hash to compare like DNSHinfo.""" @@ -334,12 +338,12 @@ def write(self, out: 'DNSOutgoing') -> None: out.write_name(self.alias) def __eq__(self, other: Any) -> bool: - """Tests equality on alias""" - return ( - isinstance(other, DNSPointer) - and self.alias == other.alias - and _dns_entry_matches(other, self.key, self.type, self.class_) - ) + """Tests equality on alias.""" + return isinstance(other, DNSPointer) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + """Tests equality on alias.""" + return self.alias == other.alias and self._dns_entry_matches(other) def __hash__(self) -> int: """Hash to compare like DNSPointer.""" @@ -373,12 +377,12 @@ def __hash__(self) -> int: return self._hash def __eq__(self, other: Any) -> bool: - """Tests equality on text""" - return ( - isinstance(other, DNSText) - and self.text == other.text - and _dns_entry_matches(other, self.key, self.type, self.class_) - ) + """Tests equality on text.""" + return isinstance(other, DNSText) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + """Tests equality on text.""" + return self.text == other.text and self._dns_entry_matches(other) def __repr__(self) -> str: """String representation""" @@ -422,13 +426,16 @@ def write(self, out: 'DNSOutgoing') -> None: def __eq__(self, other: Any) -> bool: """Tests equality on priority, weight, port and server""" + return isinstance(other, DNSService) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + """Tests equality on priority, weight, port and server.""" return ( - isinstance(other, DNSService) - and self.priority == other.priority + self.priority == other.priority and self.weight == other.weight and self.port == other.port and self.server == other.server - and _dns_entry_matches(other, self.key, self.type, self.class_) + and self._dns_entry_matches(other) ) def __hash__(self) -> int: @@ -478,12 +485,15 @@ def write(self, out: 'DNSOutgoing') -> None: out.write_string(out_bytes) def __eq__(self, other: Any) -> bool: - """Tests equality on cpu and os""" + """Tests equality on next_name and rdtypes.""" + return isinstance(other, DNSNsec) and self._eq(other) + + def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + """Tests equality on next_name and rdtypes.""" return ( - isinstance(other, DNSNsec) - and self.next_name == other.next_name + self.next_name == other.next_name and self.rdtypes == other.rdtypes - and _dns_entry_matches(other, self.key, self.type, self.class_) + and self._dns_entry_matches(other) ) def __hash__(self) -> int: @@ -497,6 +507,9 @@ def __repr__(self) -> str: ) +_DNSRecord = DNSRecord + + class DNSRRSet: """A set of dns records independent of the ttl.""" @@ -514,20 +527,15 @@ def lookup(self) -> Dict[DNSRecord, DNSRecord]: self._lookup = {record: record for record in self._records} return self._lookup - def suppresses(self, record: DNSRecord) -> bool: + def suppresses(self, record: _DNSRecord) -> bool: """Returns true if any answer in the rrset can suffice for the information held in this record.""" - other = self.lookup.get(record) + if self._lookup is None: + other = self.lookup.get(record) + else: + other = self._lookup.get(record) return bool(other and other.ttl > (record.ttl / 2)) def __contains__(self, record: DNSRecord) -> bool: """Returns true if the rrset contains the record.""" return record in self.lookup - - -_DNSEntry = DNSEntry -_str = str - - -def _dns_entry_matches(entry: _DNSEntry, key: _str, type_: int, class_: int) -> bool: - return key == entry.key and type_ == entry.type and class_ == entry.class_ From d6115c813ad0170de34bc0e3a27cd22bdf2bf307 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 22 Dec 2022 02:57:24 +0000 Subject: [PATCH 0768/1433] 0.47.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95574dca6..6516d2962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.47.0 (2022-12-22) +### Feature +* Optimize equality checks for DNS records ([#1120](https://github.com/python-zeroconf/python-zeroconf/issues/1120)) ([`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) + ## v0.46.0 (2022-12-21) ### Feature * Optimize the dns cache ([#1119](https://github.com/python-zeroconf/python-zeroconf/issues/1119)) ([`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) diff --git a/pyproject.toml b/pyproject.toml index 69ecbf31f..426ac5ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.46.0" +version = "0.47.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index af7feb219..3c968e604 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.46.0' +__version__ = '0.47.0' __license__ = 'LGPL' From 48ae77f026a96e2ca475b0ff80cb6d22207ce52f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Dec 2022 17:27:52 -1000 Subject: [PATCH 0769/1433] fix: the equality checks for DNSPointer and DNSService should be case insensitive (#1122) --- src/zeroconf/_dns.pxd | 1 + src/zeroconf/_dns.py | 11 ++++++----- tests/services/test_browser.py | 2 +- tests/test_dns.py | 19 +++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 762e93192..14c7fb703 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -60,6 +60,7 @@ cdef class DNSPointer(DNSRecord): cdef public cython.int _hash cdef public object alias + cdef public object alias_key cdef _eq(self, DNSPointer other) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index f9e33541c..5727d83a8 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -314,14 +314,15 @@ class DNSPointer(DNSRecord): """A DNS pointer record""" - __slots__ = ('_hash', 'alias') + __slots__ = ('_hash', 'alias', 'alias_key') def __init__( self, name: str, type_: int, class_: int, ttl: int, alias: str, created: Optional[float] = None ) -> None: super().__init__(name, type_, class_, ttl, created) self.alias = alias - self._hash = hash((self.key, type_, self.class_, alias)) + self.alias_key = self.alias.lower() + self._hash = hash((self.key, type_, self.class_, self.alias_key)) @property def max_size_compressed(self) -> int: @@ -343,7 +344,7 @@ def __eq__(self, other: Any) -> bool: def _eq(self, other) -> bool: # type: ignore[no-untyped-def] """Tests equality on alias.""" - return self.alias == other.alias and self._dns_entry_matches(other) + return self.alias_key == other.alias_key and self._dns_entry_matches(other) def __hash__(self) -> int: """Hash to compare like DNSPointer.""" @@ -415,7 +416,7 @@ def __init__( self.port = port self.server = server self.server_key = server.lower() - self._hash = hash((self.key, type_, self.class_, priority, weight, port, server)) + self._hash = hash((self.key, type_, self.class_, priority, weight, port, self.server_key)) def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet""" @@ -434,7 +435,7 @@ def _eq(self, other) -> bool: # type: ignore[no-untyped-def] self.priority == other.priority and self.weight == other.weight and self.port == other.port - and self.server == other.server + and self.server_key == other.server_key and self._dns_entry_matches(other) ) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index fd588648d..a3121e6d7 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -176,7 +176,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de socket.AF_INET6, service_v6_second_address ) in service_info.addresses_by_version(r.IPVersion.V6Only) assert service_info.text == service_text - assert service_info.server == service_server + assert service_info.server.lower() == service_server.lower() service_updated_event.set() def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: diff --git a/tests/test_dns.py b/tests/test_dns.py index 59b4932aa..08f805f03 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -283,6 +283,14 @@ def test_dns_pointer_record_hashablity(): assert len(record_set) == 2 +def test_dns_pointer_comparison_is_case_insensitive(): + """Test DNSPointer comparison is case insensitive.""" + ptr1 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') + ptr2 = r.DNSPointer('irrelevant'.upper(), const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') + + assert ptr1 == ptr2 + + def test_dns_text_record_hashablity(): """Test DNSText are hashable.""" text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') @@ -340,6 +348,17 @@ def test_dns_service_server_key(): assert srv1.server_key == 'x.local.' +def test_dns_service_server_comparison_is_case_insensitive(): + """Test DNSService server comparison is case insensitive.""" + srv1 = r.DNSService( + 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'X.local.' + ) + srv2 = r.DNSService( + 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'x.local.' + ) + assert srv1 == srv2 + + def test_dns_nsec_record_hashablity(): """Test DNSNsec are hashable.""" nsec1 = r.DNSNsec( From ef09683b5bfb88ac7bdd14e9ac8b45165846c275 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 24 Dec 2022 03:35:15 +0000 Subject: [PATCH 0770/1433] 0.47.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6516d2962..f528da632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.47.1 (2022-12-24) +### Fix +* The equality checks for DNSPointer and DNSService should be case insensitive ([#1122](https://github.com/python-zeroconf/python-zeroconf/issues/1122)) ([`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) + ## v0.47.0 (2022-12-22) ### Feature * Optimize equality checks for DNS records ([#1120](https://github.com/python-zeroconf/python-zeroconf/issues/1120)) ([`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) diff --git a/pyproject.toml b/pyproject.toml index 426ac5ffd..da05300a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.47.0" +version = "0.47.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3c968e604..aa978d89f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.47.0' +__version__ = '0.47.1' __license__ = 'LGPL' From 55458ffbeab03785dcc7c00a2e43a5506be37fb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Feb 2023 18:27:11 -0600 Subject: [PATCH 0771/1433] chore: bump isort to 5.12.0 to fix ci (#1130) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a2e0df94..b7ae9294c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/psf/black From 961c406e0d0539b58f9d4e90b1d36d12353b80d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Feb 2023 18:27:22 -0600 Subject: [PATCH 0772/1433] chore: bump python-semantic-release and commitlint-github-action to fix CI (#1131) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd3a50d65..addd97502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@v4.1.11 + - uses: wagoid/commitlint-github-action@v5 test: strategy: @@ -102,7 +102,7 @@ jobs: # - Create GitHub release # - Publish to PyPI - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.32.2 + uses: relekang/python-semantic-release@v7.33.1 # env: # REPOSITORY_URL: https://test.pypi.org/legacy/ # TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ From 44d7fc6483485102f60c91d591d0d697872f8865 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Feb 2023 18:39:21 -0600 Subject: [PATCH 0773/1433] fix: missing c extensions with newer poetry (#1129) --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index da05300a6..4a1a670a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers=[ 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] -build = "build_ext.py" packages = [ { include = "zeroconf", from = "src" }, ] @@ -33,6 +32,10 @@ packages = [ "Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" "Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md" +[tool.poetry.build] +generate-setup-file = true +script = "build_ext.py" + [tool.semantic_release] branch = "master" version_toml = "pyproject.toml:tool.poetry.version" From b130a45ab0e45bc33f416cf3d0d0b383416935b0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 14 Feb 2023 00:51:47 +0000 Subject: [PATCH 0774/1433] 0.47.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f528da632..f80bb501d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.47.2 (2023-02-14) +### Fix +* Missing c extensions with newer poetry ([#1129](https://github.com/python-zeroconf/python-zeroconf/issues/1129)) ([`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) + ## v0.47.1 (2022-12-24) ### Fix * The equality checks for DNSPointer and DNSService should be case insensitive ([#1122](https://github.com/python-zeroconf/python-zeroconf/issues/1122)) ([`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) diff --git a/pyproject.toml b/pyproject.toml index 4a1a670a3..eaed35473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.47.1" +version = "0.47.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index aa978d89f..3044fd79f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.47.1' +__version__ = '0.47.2' __license__ = 'LGPL' From 808c3b2194a7f499a469a9893102d328ccee83db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Feb 2023 18:56:40 -0600 Subject: [PATCH 0775/1433] fix: hold a strong reference to the query sender start task (#1128) --- src/zeroconf/_services/browser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index cc6c54e40..3766d0d2c 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -307,6 +307,7 @@ def __init__( self.done = False self._first_request: bool = True self._next_send_timer: Optional[asyncio.TimerHandle] = None + self._query_sender_task: Optional[asyncio.Task] = None if hasattr(handlers, 'add_service'): listener = cast('ServiceListener', handlers) @@ -329,7 +330,7 @@ def _async_start(self) -> None: self.query_scheduler.start(current_time_millis()) self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) # Only start queries after the listener is installed - asyncio.ensure_future(self._async_start_query_sender()) + self._query_sender_task = asyncio.ensure_future(self._async_start_query_sender()) @property def service_state_changed(self) -> SignalRegistrationInterface: @@ -436,6 +437,8 @@ def _async_cancel(self) -> None: self.done = True self._cancel_send_timer() self.zc.async_remove_listener(self) + assert self._query_sender_task is not None, "Attempted to cancel a browser that was not started" + self._query_sender_task.cancel() def _generate_ready_queries(self, first_request: bool, now: float) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" From 18b3cd7176631069e3203f966c0addac75503345 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 14 Feb 2023 01:07:19 +0000 Subject: [PATCH 0776/1433] 0.47.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f80bb501d..ba11d6565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.47.3 (2023-02-14) +### Fix +* Hold a strong reference to the query sender start task ([#1128](https://github.com/python-zeroconf/python-zeroconf/issues/1128)) ([`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) + ## v0.47.2 (2023-02-14) ### Fix * Missing c extensions with newer poetry ([#1129](https://github.com/python-zeroconf/python-zeroconf/issues/1129)) ([`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) diff --git a/pyproject.toml b/pyproject.toml index eaed35473..4c67cf5d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.47.2" +version = "0.47.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3044fd79f..07f22e8c2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.47.2' +__version__ = '0.47.3' __license__ = 'LGPL' From a43055d3fa258cd762c3e9394b01f8bdcb24f97e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 17:47:32 -1000 Subject: [PATCH 0777/1433] fix: correct duplicate record entries in windows wheels by updating poetry-core (#1134) --- poetry.lock | 376 +++++++++++++++++++++++++------------------------ pyproject.toml | 3 +- 2 files changed, 191 insertions(+), 188 deletions(-) diff --git a/poetry.lock b/poetry.lock index 032c4ced4..19ba55bd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,5 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + [[package]] name = "async-timeout" version = "4.0.2" @@ -5,23 +7,32 @@ description = "Timeout context manager for asyncio programs" category = "main" optional = false python-versions = ">=3.6" +files = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] [package.dependencies] typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} [[package]] name = "attrs" -version = "22.1.0" +version = "22.2.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" +files = [ + {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, + {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, +] [package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] +cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] +tests = ["attrs[tests-no-zope]", "zope.interface"] +tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] [[package]] name = "colorama" @@ -30,14 +41,71 @@ description = "Cross-platform colored terminal text." category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] [[package]] name = "coverage" -version = "6.5.0" +version = "7.2.2" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7"}, + {file = "coverage-7.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d"}, + {file = "coverage-7.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5"}, + {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169"}, + {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6"}, + {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137"}, + {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90"}, + {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2"}, + {file = "coverage-7.2.2-cp310-cp310-win32.whl", hash = "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292"}, + {file = "coverage-7.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab"}, + {file = "coverage-7.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b"}, + {file = "coverage-7.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5"}, + {file = "coverage-7.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731"}, + {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd"}, + {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d"}, + {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212"}, + {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54"}, + {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57"}, + {file = "coverage-7.2.2-cp311-cp311-win32.whl", hash = "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d"}, + {file = "coverage-7.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512"}, + {file = "coverage-7.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9"}, + {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e"}, + {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69"}, + {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0"}, + {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f"}, + {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67"}, + {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9"}, + {file = "coverage-7.2.2-cp37-cp37m-win32.whl", hash = "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8"}, + {file = "coverage-7.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25"}, + {file = "coverage-7.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6"}, + {file = "coverage-7.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5"}, + {file = "coverage-7.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4"}, + {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd"}, + {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84"}, + {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540"}, + {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88"}, + {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2"}, + {file = "coverage-7.2.2-cp38-cp38-win32.whl", hash = "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3"}, + {file = "coverage-7.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8"}, + {file = "coverage-7.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d"}, + {file = "coverage-7.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005"}, + {file = "coverage-7.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988"}, + {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149"}, + {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8"}, + {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140"}, + {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016"}, + {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be"}, + {file = "coverage-7.2.2-cp39-cp39-win32.whl", hash = "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc"}, + {file = "coverage-7.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef"}, + {file = "coverage-7.2.2-pp37.pp38.pp39-none-any.whl", hash = "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968"}, + {file = "coverage-7.2.2.tar.gz", hash = "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2"}, +] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} @@ -47,19 +115,65 @@ toml = ["tomli"] [[package]] name = "cython" -version = "0.29.32" +version = "0.29.33" description = "The Cython compiler for writing C extensions for the Python language." category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "Cython-0.29.33-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:286cdfb193e23799e113b7bd5ac74f58da5e9a77c70e3b645b078836b896b165"}, + {file = "Cython-0.29.33-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8507279a4f86ed8365b96603d5ad155888d4d01b72a9bbf0615880feda5a11d4"}, + {file = "Cython-0.29.33-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf5ffd96957a595441cca2fc78470d93fdc40dfe5449881b812ea6045d7e9be"}, + {file = "Cython-0.29.33-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d2019a7e54ba8b253f44411863b8f8c0b6cd623f7a92dc0ccb83892358c4283a"}, + {file = "Cython-0.29.33-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:190e60b7505d3b9b60130bcc2251c01b9ef52603420829c19d3c3ede4ac2763a"}, + {file = "Cython-0.29.33-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0168482495b75fea1c97a9641a95bac991f313e85f378003f9a4909fdeb3d454"}, + {file = "Cython-0.29.33-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:090556e41f2b30427dd3a1628d3613177083f47567a30148b6b7b8c7a5862187"}, + {file = "Cython-0.29.33-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:19c9913e9304bf97f1d2c357438895466f99aa2707d3c7a5e9de60c259e1ca1d"}, + {file = "Cython-0.29.33-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:afc9b6ab20889676c76e700ae6967aa6886a7efe5b05ef6d5b744a6ca793cc43"}, + {file = "Cython-0.29.33-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:49fb45b2bf12d6e2060bbd64506c06ac90e254f3a4bceb32c717f4964a1ae812"}, + {file = "Cython-0.29.33-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5430f38d3d01c4715ec2aef5c41e02a2441c1c3a0149359c7a498e4c605b8e6c"}, + {file = "Cython-0.29.33-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4d315443c7f4c61180b6c3ea9a9717ee7c901cc9db8d1d46fdf6556613840ed"}, + {file = "Cython-0.29.33-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b4e6481e3e7e4d345640fe2fdc6dc57c94369b467f3dc280949daa8e9fd13b9"}, + {file = "Cython-0.29.33-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:060a2568ef80116a0a9dcaf3218a61c6007be0e0b77c5752c094ce5187a4d63c"}, + {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b67ddd32eaa2932a66bf8121accc36a7b3078593805519b0f00040f2b10a6a52"}, + {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1b507236ba3ca94170ce0a504dd03acf77307d4bfbc5a010a8031673f6b213a9"}, + {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:581efc0622a9be05714222f2b4ac96a5419de58d5949517282d8df38155c8b9d"}, + {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b8bcbf8f1c3c46d6184be1e559e3a3fb8cdf27c6d507d8bc8ae04cfcbfd75f5"}, + {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1ca93bbe584aee92094fd4fb6acc5cb6500acf98d4f57cc59244f0a598b0fcf6"}, + {file = "Cython-0.29.33-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:da490129e1e4ffaf3f88bfb46d338549a2150f60f809a63d385b83e00960d11a"}, + {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4cadf5250eda0c5cdaf4c3a29b52be3e0695f4a2bf1ccd49b638d239752ea513"}, + {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bcb1a84fd2bd7885d572adc180e24fd8a7d4b0c104c144e33ccf84a1ab4eb2b8"}, + {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d78147ad8a3417ae6b371bbc5bfc6512f6ad4ad3fb71f5eef42e136e4ed14970"}, + {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dd96b06b93c0e5fa4fc526c5be37c13a93e2fe7c372b5f358277ebe9e1620957"}, + {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:959f0092d58e7fa00fd3434f7ff32fb78be7c2fa9f8e0096326343159477fe45"}, + {file = "Cython-0.29.33-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0455d5b92f461218bcf173a149a88b7396c3a109066274ccab5eff58db0eae32"}, + {file = "Cython-0.29.33-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:a9b0b890656e9d18a18e1efe26ea3d2d0f3e525a07a2a853592b0afc56a15c89"}, + {file = "Cython-0.29.33-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b5e8ce3039ff64000d58cd45b3f6f83e13f032dde7f27bb1ab96070d9213550b"}, + {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:e8922fa3d7e76b7186bbd0810e170ca61f83661ab1b29dc75e88ff2327aaf49d"}, + {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f67b7306fd00d55f271009335cecadc506d144205c7891070aad889928d85750"}, + {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f271f90005064c49b47a93f456dc6cf0a21d21ef835bd33ac1e0db10ad51f84f"}, + {file = "Cython-0.29.33-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4457d417ffbb94abc42adcd63a03b24ff39cf090f3e9eca5e10cfb90766cbe3"}, + {file = "Cython-0.29.33-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0b53e017522feb8dcc2189cf1d2d344bab473c5bba5234390b5666d822992c7c"}, + {file = "Cython-0.29.33-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:4f88c2dc0653eef6468848eb8022faf64115b39734f750a1c01a7ba7eb04d89f"}, + {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:1900d862a4a537d2125706740e9f3b016e80f7bbf7b54db6b3cc3d0bdf0f5c3a"}, + {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37bfca4f9f26361343d8c678f8178321e4ae5b919523eed05d2cd8ddbe6b06ec"}, + {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a9863f8238642c0b1ef8069d99da5ade03bfe2225a64b00c5ae006d95f142a73"}, + {file = "Cython-0.29.33-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1dd503408924723b0bb10c0013b76e324eeee42db6deced9b02b648f1415d94c"}, + {file = "Cython-0.29.33-py2.py3-none-any.whl", hash = "sha256:8b99252bde8ff51cd06a3fe4aeacd3af9b4ff4a4e6b701ac71bddc54f5da61d6"}, + {file = "Cython-0.29.33.tar.gz", hash = "sha256:5040764c4a4d2ce964a395da24f0d1ae58144995dab92c6b96f44c3f4d72286a"}, +] [[package]] name = "exceptiongroup" -version = "1.0.4" +version = "1.1.1" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, +] [package.extras] test = ["pytest (>=6)"] @@ -71,39 +185,55 @@ description = "Cross-platform network interface and IP address enumeration libra category = "main" optional = false python-versions = "*" +files = [ + {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, + {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, +] [[package]] name = "importlib-metadata" -version = "5.1.0" +version = "6.1.0" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.1.0-py3-none-any.whl", hash = "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"}, + {file = "importlib_metadata-6.1.0.tar.gz", hash = "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20"}, +] [package.dependencies] typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] [[package]] name = "packaging" -version = "22.0" +version = "23.0" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, +] [[package]] name = "pluggy" @@ -112,6 +242,10 @@ description = "plugin and hook calling mechanisms for python" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] [package.dependencies] importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -122,11 +256,15 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.2.0" +version = "7.2.2" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"}, + {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"}, +] [package.dependencies] attrs = ">=19.2.0" @@ -148,6 +286,10 @@ description = "Pytest support for asyncio" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, + {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, +] [package.dependencies] pytest = ">=6.1.0" @@ -164,6 +306,10 @@ description = "Pytest plugin for measuring coverage." category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} @@ -179,20 +325,28 @@ description = "pytest plugin to abort hanging tests" category = "dev" optional = false python-versions = ">=3.6" +files = [ + {file = "pytest-timeout-2.1.0.tar.gz", hash = "sha256:c07ca07404c612f8abbe22294b23c368e2e5104b521c1790195561f37e1ac3d9"}, + {file = "pytest_timeout-2.1.0-py3-none-any.whl", hash = "sha256:f6f50101443ce70ad325ceb4473c4255e9d74e3c7cd0ef827309dfa4c0d975c6"}, +] [package.dependencies] pytest = ">=5.0.0" [[package]] name = "setuptools" -version = "65.6.3" +version = "65.7.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "setuptools-65.7.0-py3-none-any.whl", hash = "sha256:8ab4f1dbf2b4a65f7eec5ad0c620e84c34111a68d3349833494b9088212214dd"}, + {file = "setuptools-65.7.0.tar.gz", hash = "sha256:4d3c92fac8f1118bb77a22181355e29c239cabfe2b9effdaa665c66b711136d7"}, +] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] @@ -203,192 +357,40 @@ description = "A lil' TOML parser" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] [[package]] name = "typing-extensions" -version = "4.4.0" +version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, +] [[package]] name = "zipp" -version = "3.11.0" +version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] -lock-version = "1.1" +lock-version = "2.0" python-versions = "^3.7" content-hash = "1b871ae566e35d2aa05a22a4ff564eaec72807a4c37a012e41f8287831435b74" - -[metadata.files] -async-timeout = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, -] -attrs = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] -colorama = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -coverage = [ - {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, - {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, - {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, - {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, - {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, - {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, - {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, - {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, - {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, - {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, - {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, - {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, - {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, - {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, - {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, - {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, - {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, - {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, - {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, - {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, - {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, - {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, - {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, - {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, - {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, - {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, - {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, - {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, - {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, - {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, -] -cython = [ - {file = "Cython-0.29.32-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:39afb4679b8c6bf7ccb15b24025568f4f9b4d7f9bf3cbd981021f542acecd75b"}, - {file = "Cython-0.29.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dbee03b8d42dca924e6aa057b836a064c769ddfd2a4c2919e65da2c8a362d528"}, - {file = "Cython-0.29.32-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ba622326f2862f9c1f99ca8d47ade49871241920a352c917e16861e25b0e5c3"}, - {file = "Cython-0.29.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e6ffa08aa1c111a1ebcbd1cf4afaaec120bc0bbdec3f2545f8bb7d3e8e77a1cd"}, - {file = "Cython-0.29.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:97335b2cd4acebf30d14e2855d882de83ad838491a09be2011745579ac975833"}, - {file = "Cython-0.29.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:06be83490c906b6429b4389e13487a26254ccaad2eef6f3d4ee21d8d3a4aaa2b"}, - {file = "Cython-0.29.32-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:eefd2b9a5f38ded8d859fe96cc28d7d06e098dc3f677e7adbafda4dcdd4a461c"}, - {file = "Cython-0.29.32-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5514f3b4122cb22317122a48e175a7194e18e1803ca555c4c959d7dfe68eaf98"}, - {file = "Cython-0.29.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:656dc5ff1d269de4d11ee8542f2ffd15ab466c447c1f10e5b8aba6f561967276"}, - {file = "Cython-0.29.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:cdf10af3e2e3279dc09fdc5f95deaa624850a53913f30350ceee824dc14fc1a6"}, - {file = "Cython-0.29.32-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:3875c2b2ea752816a4d7ae59d45bb546e7c4c79093c83e3ba7f4d9051dd02928"}, - {file = "Cython-0.29.32-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:79e3bab19cf1b021b613567c22eb18b76c0c547b9bc3903881a07bfd9e7e64cf"}, - {file = "Cython-0.29.32-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0595aee62809ba353cebc5c7978e0e443760c3e882e2c7672c73ffe46383673"}, - {file = "Cython-0.29.32-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0ea8267fc373a2c5064ad77d8ff7bf0ea8b88f7407098ff51829381f8ec1d5d9"}, - {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:c8e8025f496b5acb6ba95da2fb3e9dacffc97d9a92711aacfdd42f9c5927e094"}, - {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:afbce249133a830f121b917f8c9404a44f2950e0e4f5d1e68f043da4c2e9f457"}, - {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:513e9707407608ac0d306c8b09d55a28be23ea4152cbd356ceaec0f32ef08d65"}, - {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e83228e0994497900af954adcac27f64c9a57cd70a9ec768ab0cb2c01fd15cf1"}, - {file = "Cython-0.29.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea1dcc07bfb37367b639415333cfbfe4a93c3be340edf1db10964bc27d42ed64"}, - {file = "Cython-0.29.32-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8669cadeb26d9a58a5e6b8ce34d2c8986cc3b5c0bfa77eda6ceb471596cb2ec3"}, - {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:ed087eeb88a8cf96c60fb76c5c3b5fb87188adee5e179f89ec9ad9a43c0c54b3"}, - {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3f85eb2343d20d91a4ea9cf14e5748092b376a64b7e07fc224e85b2753e9070b"}, - {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:63b79d9e1f7c4d1f498ab1322156a0d7dc1b6004bf981a8abda3f66800e140cd"}, - {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e1958e0227a4a6a2c06fd6e35b7469de50adf174102454db397cec6e1403cce3"}, - {file = "Cython-0.29.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:856d2fec682b3f31583719cb6925c6cdbb9aa30f03122bcc45c65c8b6f515754"}, - {file = "Cython-0.29.32-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:479690d2892ca56d34812fe6ab8f58e4b2e0129140f3d94518f15993c40553da"}, - {file = "Cython-0.29.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:67fdd2f652f8d4840042e2d2d91e15636ba2bcdcd92e7e5ffbc68e6ef633a754"}, - {file = "Cython-0.29.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:4a4b03ab483271f69221c3210f7cde0dcc456749ecf8243b95bc7a701e5677e0"}, - {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:40eff7aa26e91cf108fd740ffd4daf49f39b2fdffadabc7292b4b7dc5df879f0"}, - {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0bbc27abdf6aebfa1bce34cd92bd403070356f28b0ecb3198ff8a182791d58b9"}, - {file = "Cython-0.29.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cddc47ec746a08603037731f5d10aebf770ced08666100bd2cdcaf06a85d4d1b"}, - {file = "Cython-0.29.32-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca3065a1279456e81c615211d025ea11bfe4e19f0c5650b859868ca04b3fcbd"}, - {file = "Cython-0.29.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d968ffc403d92addf20b68924d95428d523436adfd25cf505d427ed7ba3bee8b"}, - {file = "Cython-0.29.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f3fd44cc362eee8ae569025f070d56208908916794b6ab21e139cea56470a2b3"}, - {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b6da3063c5c476f5311fd76854abae6c315f1513ef7d7904deed2e774623bbb9"}, - {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:061e25151c38f2361bc790d3bcf7f9d9828a0b6a4d5afa56fbed3bd33fb2373a"}, - {file = "Cython-0.29.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f9944013588a3543fca795fffb0a070a31a243aa4f2d212f118aa95e69485831"}, - {file = "Cython-0.29.32-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:07d173d3289415bb496e72cb0ddd609961be08fe2968c39094d5712ffb78672b"}, - {file = "Cython-0.29.32-py2.py3-none-any.whl", hash = "sha256:eeb475eb6f0ccf6c039035eb4f0f928eb53ead88777e0a760eccb140ad90930b"}, - {file = "Cython-0.29.32.tar.gz", hash = "sha256:8733cf4758b79304f2a4e39ebfac5e92341bce47bcceb26c1254398b2f8c1af7"}, -] -exceptiongroup = [ - {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, - {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, -] -ifaddr = [ - {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, - {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, -] -importlib-metadata = [ - {file = "importlib_metadata-5.1.0-py3-none-any.whl", hash = "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313"}, - {file = "importlib_metadata-5.1.0.tar.gz", hash = "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] -packaging = [ - {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, - {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -pytest = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, -] -pytest-asyncio = [ - {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, - {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, -] -pytest-cov = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, -] -pytest-timeout = [ - {file = "pytest-timeout-2.1.0.tar.gz", hash = "sha256:c07ca07404c612f8abbe22294b23c368e2e5104b521c1790195561f37e1ac3d9"}, - {file = "pytest_timeout-2.1.0-py3-none-any.whl", hash = "sha256:f6f50101443ce70ad325ceb4473c4255e9d74e3c7cd0ef827309dfa4c0d975c6"}, -] -setuptools = [ - {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, - {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, -] -tomli = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] -typing-extensions = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, -] -zipp = [ - {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, - {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, -] diff --git a/pyproject.toml b/pyproject.toml index 4c67cf5d3..05232dfc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,8 @@ module = "bench.*" ignore_errors = true [build-system] -requires = ['setuptools>=65.4.1', 'wheel', 'Cython', "poetry-core>=1.0.0"] +# 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505 +requires = ['setuptools>=65.4.1', 'wheel', 'Cython', "poetry-core>=1.5.2"] build-backend = "poetry.core.masonry.api" [tool.codespell] From d45c2f9e78001fe65975ea86e9d78ca6aa608f78 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 20 Mar 2023 03:55:41 +0000 Subject: [PATCH 0778/1433] 0.47.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba11d6565..98f4bf659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.47.4 (2023-03-20) +### Fix +* Correct duplicate record entries in windows wheels by updating poetry-core ([#1134](https://github.com/python-zeroconf/python-zeroconf/issues/1134)) ([`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) + ## v0.47.3 (2023-02-14) ### Fix * Hold a strong reference to the query sender start task ([#1128](https://github.com/python-zeroconf/python-zeroconf/issues/1128)) ([`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) diff --git a/pyproject.toml b/pyproject.toml index 05232dfc4..a4ce7cf41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.47.3" +version = "0.47.4" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 07f22e8c2..76a44fff1 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.47.3' +__version__ = '0.47.4' __license__ = 'LGPL' From c4077dde6dfde9e2598eb63daa03c36063a3e7b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:47:31 -1000 Subject: [PATCH 0779/1433] feat: reduce overhead to send responses (#1135) --- src/zeroconf/_core.py | 88 +++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 958b34688..aa50ddae1 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -28,7 +28,7 @@ import sys import threading from types import TracebackType # noqa # used in type hints -from typing import Awaitable, Dict, List, Optional, Tuple, Type, Union, cast +from typing import Any, Awaitable, Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType @@ -105,6 +105,48 @@ _REGISTER_BROADCASTS = 3 +class _WrappedTransport: + """A wrapper for transports.""" + + __slots__ = ( + 'transport', + 'is_ipv6', + 'sock', + 'fileno', + 'sock_name', + ) + + def __init__( + self, + transport: asyncio.DatagramTransport, + is_ipv6: bool, + sock: socket.socket, + fileno: int, + sock_name: Any, + ) -> None: + """Initialize the wrapped transport. + + These attributes are used when sending packets. + """ + self.transport = transport + self.is_ipv6 = is_ipv6 + self.sock = sock + self.fileno = fileno + self.sock_name = sock_name + + +def _make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: + """Make a wrapped transport.""" + sock: socket.socket = transport.get_extra_info('socket') + return _WrappedTransport( + transport=transport, + is_ipv6=sock.family == socket.AF_INET6, + sock=sock, + fileno=sock.fileno(), + sock_name=sock.getsockname(), + ) + + class AsyncEngine: """An engine wraps sockets in the event loop.""" @@ -117,8 +159,8 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self.zc = zeroconf self.protocols: List[AsyncListener] = [] - self.readers: List[asyncio.DatagramTransport] = [] - self.senders: List[asyncio.DatagramTransport] = [] + self.readers: List[_WrappedTransport] = [] + self.senders: List[_WrappedTransport] = [] self.running_event: Optional[asyncio.Event] = None self._listen_socket = listen_socket self._respond_sockets = respond_sockets @@ -158,9 +200,9 @@ async def _async_create_endpoints(self) -> None: for s in reader_sockets: transport, protocol = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append(cast(asyncio.DatagramTransport, transport)) + self.readers.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) if s in sender_sockets: - self.senders.append(cast(asyncio.DatagramTransport, transport)) + self.senders.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" @@ -186,8 +228,8 @@ def _async_shutdown(self) -> None: """Shutdown transports and sockets.""" assert self.running_event is not None self.running_event.clear() - for transport in itertools.chain(self.senders, self.readers): - transport.close() + for wrapped_transport in itertools.chain(self.senders, self.readers): + wrapped_transport.transport.close() def close(self) -> None: """Close from sync context. @@ -221,7 +263,7 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data: Optional[bytes] = None self.last_time: float = 0 - self.transport: Optional[asyncio.DatagramTransport] = None + self.transport: Optional[_WrappedTransport] = None self.sock_description: Optional[str] = None self._deferred: Dict[str, List[DNSIncoming]] = {} self._timers: Dict[str, asyncio.TimerHandle] = {} @@ -309,7 +351,7 @@ def handle_query_or_defer( msg: DNSIncoming, addr: str, port: int, - transport: asyncio.DatagramTransport, + transport: _WrappedTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Deal with incoming query packets. Provides a response if @@ -341,7 +383,7 @@ def _respond_query( msg: Optional[DNSIncoming], addr: str, port: int, - transport: asyncio.DatagramTransport, + transport: _WrappedTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" @@ -362,10 +404,9 @@ def error_received(self, exc: Exception) -> None: self.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: - self.transport = cast(asyncio.DatagramTransport, transport) - sock_name = self.transport.get_extra_info('sockname') - sock_fileno = self.transport.get_extra_info('socket').fileno() - self.sock_description = f"{sock_fileno} ({sock_name})" + wrapped_transport = _make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + self.transport = wrapped_transport + self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" @@ -373,7 +414,7 @@ def connection_lost(self, exc: Optional[Exception]) -> None: def async_send_with_transport( log_debug: bool, - transport: asyncio.DatagramTransport, + transport: _WrappedTransport, packet: bytes, packet_num: int, out: DNSOutgoing, @@ -381,8 +422,7 @@ def async_send_with_transport( port: int, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: - s = transport.get_extra_info('socket') - ipv6_socket = s.family == socket.AF_INET6 + ipv6_socket = transport.is_ipv6 if addr is None: real_addr = _MDNS_ADDR6 if ipv6_socket else _MDNS_ADDR else: @@ -394,8 +434,8 @@ def async_send_with_transport( 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', real_addr, port or _MDNS_PORT, - s.fileno(), - transport.get_extra_info('sockname'), + transport.fileno, + transport.sock_name, len(packet), packet_num + 1, out, @@ -404,9 +444,9 @@ def async_send_with_transport( # Get flowinfo and scopeid for the IPV6 socket to create a complete IPv6 # address tuple: https://docs.python.org/3.6/library/socket.html#socket-families if ipv6_socket and not v6_flow_scope: - _, _, sock_flowinfo, sock_scopeid = s.getsockname() + _, _, sock_flowinfo, sock_scopeid = transport.sock_name v6_flow_scope = (sock_flowinfo, sock_scopeid) - transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) + transport.transport.sendto(packet, (real_addr, port or _MDNS_PORT, *v6_flow_scope)) class Zeroconf(QuietLogger): @@ -832,7 +872,7 @@ def handle_assembled_query( packets: List[DNSIncoming], addr: str, port: int, - transport: asyncio.DatagramTransport, + transport: _WrappedTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: """Respond to a (re)assembled query. @@ -870,7 +910,7 @@ def send( addr: Optional[str] = None, port: int = _MDNS_PORT, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - transport: Optional[asyncio.DatagramTransport] = None, + transport: Optional[_WrappedTransport] = None, ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None @@ -882,7 +922,7 @@ def async_send( addr: Optional[str] = None, port: int = _MDNS_PORT, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - transport: Optional[asyncio.DatagramTransport] = None, + transport: Optional[_WrappedTransport] = None, ) -> None: """Sends an outgoing packet.""" if self.done: From 489069a51d42c1d21ee5e88bb955d02ea2951d17 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 1 Apr 2023 01:59:39 +0000 Subject: [PATCH 0780/1433] 0.48.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f4bf659..c2f8e1e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.48.0 (2023-04-01) +### Feature +* Reduce overhead to send responses ([#1135](https://github.com/python-zeroconf/python-zeroconf/issues/1135)) ([`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) + ## v0.47.4 (2023-03-20) ### Fix * Correct duplicate record entries in windows wheels by updating poetry-core ([#1134](https://github.com/python-zeroconf/python-zeroconf/issues/1134)) ([`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) diff --git a/pyproject.toml b/pyproject.toml index a4ce7cf41..414a137c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.47.4" +version = "0.48.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 76a44fff1..37e75eb56 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.47.4' +__version__ = '0.48.0' __license__ = 'LGPL' From 7246a344b6c0543871b40715c95c9435db4c7f81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 16:04:43 -1000 Subject: [PATCH 0781/1433] feat: speed up processing incoming records (#1139) --- src/zeroconf/_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 767cd65f9..f2e9a606c 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -489,7 +489,7 @@ def _async_mark_unique_cached_records_older_than_1s_to_expire( # Since unique is set, all old records with that name, rrtype, # and rrclass that were received more than one second ago are declared # invalid, and marked to expire from the cache in one second. - answers_rrset = DNSRRSet(answers) + answers_rrset = set(answers) for name, type_, class_ in unique_types: for entry in self.cache.async_all_by_details(name, type_, class_): if (now - entry.created > _ONE_SECOND) and entry not in answers_rrset: From 874ce6c00b84d173fb5f75d2a9c2cf1ddf400c14 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 1 Apr 2023 02:13:09 +0000 Subject: [PATCH 0782/1433] 0.49.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f8e1e20..11ea31027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.49.0 (2023-04-01) +### Feature +* Speed up processing incoming records ([#1139](https://github.com/python-zeroconf/python-zeroconf/issues/1139)) ([`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) + ## v0.48.0 (2023-04-01) ### Feature * Reduce overhead to send responses ([#1135](https://github.com/python-zeroconf/python-zeroconf/issues/1135)) ([`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) diff --git a/pyproject.toml b/pyproject.toml index 414a137c6..c35f3b8cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.48.0" +version = "0.49.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 37e75eb56..ec41de1f8 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.48.0' +__version__ = '0.49.0' __license__ = 'LGPL' From 5bd1b6e7b4dd796069461c737ded956305096307 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 16:14:22 -1000 Subject: [PATCH 0783/1433] feat: small speed up to handler dispatch (#1140) --- src/zeroconf/_services/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 18b2cf30a..2882fce6a 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -50,7 +50,7 @@ def __init__(self) -> None: self._handlers: List[Callable[..., None]] = [] def fire(self, **kwargs: Any) -> None: - for h in list(self._handlers): + for h in self._handlers[:]: h(**kwargs) @property From 0260a42f3215a37e1168174a55cf3ef8eafa9514 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 1 Apr 2023 02:22:08 +0000 Subject: [PATCH 0784/1433] 0.50.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ea31027..87de86009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.50.0 (2023-04-01) +### Feature +* Small speed up to handler dispatch ([#1140](https://github.com/python-zeroconf/python-zeroconf/issues/1140)) ([`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) + ## v0.49.0 (2023-04-01) ### Feature * Speed up processing incoming records ([#1139](https://github.com/python-zeroconf/python-zeroconf/issues/1139)) ([`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) diff --git a/pyproject.toml b/pyproject.toml index c35f3b8cb..ed693a70c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.49.0" +version = "0.50.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ec41de1f8..b48ff7bf3 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.49.0' +__version__ = '0.50.0' __license__ = 'LGPL' From 36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 17:08:41 -1000 Subject: [PATCH 0785/1433] feat: improve performance of constructing ServiceInfo (#1141) --- src/zeroconf/_services/info.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index eca5320bb..69c2bc9c9 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -23,6 +23,7 @@ import ipaddress import random import socket +from functools import lru_cache from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast from .._dns import ( @@ -79,6 +80,9 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: return info.name[: -len(service_name) - 1] +_cached_ip_addresses = lru_cache(maxsize=256)(ipaddress.ip_address) + + class ServiceInfo(RecordUpdateListener): """Service information. @@ -196,7 +200,7 @@ def addresses(self, value: List[bytes]) -> None: for address in value: try: - addr = ipaddress.ip_address(address) + addr = _cached_ip_addresses(address) except ValueError: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" @@ -245,7 +249,7 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st return self.parsed_addresses(version) def is_link_local(addr_str: str) -> Any: - addr = ipaddress.ip_address(addr_str) + addr = _cached_ip_addresses(addr_str) return addr.version == 6 and addr.is_link_local ll_addrs = list(filter(is_link_local, self.parsed_addresses(version))) @@ -346,7 +350,7 @@ def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: if record.key != self.server_key: return try: - ip_addr = ipaddress.ip_address(record.address) + ip_addr = _cached_ip_addresses(record.address) except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) return From 199a4de38851beb130ae389ee6582c375bb34db3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 1 Apr 2023 03:16:53 +0000 Subject: [PATCH 0786/1433] 0.51.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87de86009..c6dbb994f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.51.0 (2023-04-01) +### Feature +* Improve performance of constructing ServiceInfo ([#1141](https://github.com/python-zeroconf/python-zeroconf/issues/1141)) ([`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) + ## v0.50.0 (2023-04-01) ### Feature * Small speed up to handler dispatch ([#1140](https://github.com/python-zeroconf/python-zeroconf/issues/1140)) ([`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) diff --git a/pyproject.toml b/pyproject.toml index ed693a70c..d107229d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.50.0" +version = "0.51.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b48ff7bf3..539d4ef87 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.50.0' +__version__ = '0.51.0' __license__ = 'LGPL' From da10a3b2827cee0719d3bb9152ae897f061c6e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Sun, 2 Apr 2023 08:05:13 +0200 Subject: [PATCH 0787/1433] feat: include tests and docs in sdist archives (#1142) feat: Include tests and docs in sdist archives Include documentation and test files in source distributions, in order to make them more useful for packagers (Linux distributions, Conda). Testing is an important part of packaging process, and at least Gentoo users have requested offline documentation for Python packages. Furthermore, the COPYING file was missing from sdist, even though it was referenced in README. --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d107229d8..fef507abc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,12 @@ classifiers=[ packages = [ { include = "zeroconf", from = "src" }, ] +include = [ + { path = "CHANGELOG.md", format = "sdist" }, + { path = "COPYING", format = "sdist" }, + { path = "docs", format = "sdist" }, + { path = "tests", format = "sdist" }, +] [tool.poetry.urls] "Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" From 68871c3b5569e41740a66b7d3d7fa5cc41514ea5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 20:05:21 -1000 Subject: [PATCH 0788/1433] feat: speed up matching types in the ServiceBrowser (#1144) --- src/zeroconf/_services/browser.py | 20 ++++++++++++++------ src/zeroconf/_utils/name.py | 4 ++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 3766d0d2c..1663c1b75 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -39,7 +39,7 @@ cast, ) -from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord +from .._dns import DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._services import ( @@ -49,7 +49,7 @@ SignalRegistrationInterface, ) from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.name import possible_types, service_type_name +from .._utils.name import cached_possible_types, service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, @@ -62,6 +62,8 @@ _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, + _TYPE_A, + _TYPE_AAAA, _TYPE_PTR, ) @@ -338,7 +340,9 @@ def service_state_changed(self) -> SignalRegistrationInterface: def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: """Return the type and name for records matching the types we are browsing.""" - return [(type_, name) for name in names for type_ in self.types.intersection(possible_types(name))] + return [ + (type_, name) for name in names for type_ in self.types.intersection(cached_possible_types(name)) + ] def _enqueue_callback( self, @@ -363,8 +367,12 @@ def _async_process_record_update( self, now: float, record: DNSRecord, old_record: Optional[DNSRecord] ) -> None: """Process a single record update from a batch of updates.""" - if isinstance(record, DNSPointer): - for type_ in self.types.intersection(possible_types(record.name)): + record_type = record.type + + if record_type is _TYPE_PTR: + if TYPE_CHECKING: + record = cast(DNSPointer, record) + for type_ in self.types.intersection(cached_possible_types(record.name)): if old_record is None: self._enqueue_callback(ServiceStateChange.Added, type_, record.alias) elif record.is_expired(now): @@ -377,7 +385,7 @@ def _async_process_record_update( if old_record or record.is_expired(now): return - if isinstance(record, DNSAddress): + if record_type in (_TYPE_A, _TYPE_AAAA): # Iterate through the DNSCache and callback any services that use this address for type_, name in self._names_matching_types( {service.name for service in self.zc.cache.async_entries_with_server(record.name)} diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index 5eb58957b..7fa667a10 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -20,6 +20,7 @@ USA """ +from functools import lru_cache from typing import Set from .._exceptions import BadTypeInNameException @@ -170,3 +171,6 @@ def possible_types(name: str) -> Set[str]: break types.add('.'.join(parts)) return types + + +cached_possible_types = lru_cache(maxsize=256)(possible_types) From 6a327d00ffb81de55b7c5b599893c789996680c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 20:05:29 -1000 Subject: [PATCH 0789/1433] feat: speed up processing records in the ServiceBrowser (#1143) --- src/zeroconf/_services/browser.py | 21 +++++++++++++-------- src/zeroconf/asyncio.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 1663c1b75..e239bc42e 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -25,6 +25,7 @@ import random import threading import warnings +from abc import abstractmethod from collections import OrderedDict from typing import ( TYPE_CHECKING, @@ -408,6 +409,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU for record in records: self._async_process_record_update(now, record[0], record[1]) + @abstractmethod def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. @@ -415,14 +417,6 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - while self._pending_handlers: - event = self._pending_handlers.popitem(False) - # If there is a queue running (ServiceBrowser) - # get fired in dedicated thread - if self.queue: - self.queue.put(event) - else: - self._fire_service_state_changed_event(event) def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: """Fire a service state changed event. @@ -553,3 +547,14 @@ def run(self) -> None: if event is None: return self._fire_service_state_changed_event(event) + + def async_update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + + This method will be run in the event loop. + """ + assert self.queue is not None + while self._pending_handlers: + self.queue.put(self._pending_handlers.popitem(False)) diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 97fdac43c..93c638ecb 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -82,6 +82,16 @@ async def async_cancel(self) -> None: """Cancel the browser.""" self._async_cancel() + def async_update_records_complete(self) -> None: + """Called when a record update has completed for all handlers. + + At this point the cache will have the new records. + + This method will be run in the event loop. + """ + while self._pending_handlers: + self._fire_service_state_changed_event(self._pending_handlers.popitem(False)) + class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): """An async version of ZeroconfServiceTypes.""" From 524494edd49bd049726b19ae8ac8f6eea69a3943 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 20:05:37 -1000 Subject: [PATCH 0790/1433] feat: add ip_addresses_by_version to ServiceInfo (#1145) --- src/zeroconf/_services/info.py | 10 ++++++++++ tests/services/test_info.py | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 69c2bc9c9..0ba83f2d7 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -233,6 +233,16 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: *(addr.packed for addr in self._ipv6_addresses), ] + def ip_addresses_by_version( + self, version: IPVersion + ) -> Union[List[ipaddress.IPv4Address], List[ipaddress.IPv6Address], List[ipaddress._BaseAddress]]: + """List ip_address objects matching IP version.""" + if version == IPVersion.V4Only: + return self._ipv4_addresses + if version == IPVersion.V6Only: + return self._ipv6_addresses + return [*self._ipv4_addresses, *self._ipv6_addresses] + def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """List addresses in their parsed string form.""" result = self.addresses_by_version(version) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 2dec272a9..e02d6d54e 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -9,6 +9,7 @@ import socket import threading import unittest +from ipaddress import ip_address from threading import Event from typing import Iterable, List, Optional from unittest.mock import patch @@ -540,8 +541,18 @@ def test_multiple_addresses(): for info in infos: assert info.addresses == [address] assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6, address_v6_ll] + assert info.ip_addresses_by_version(r.IPVersion.All) == [ + ip_address(address), + ip_address(address_v6), + ip_address(address_v6_ll), + ] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] + assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ip_address(address)] assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6, address_v6_ll] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ + ip_address(address_v6), + ip_address(address_v6_ll), + ] assert info.parsed_addresses() == [address_parsed, address_v6_parsed, address_v6_ll_parsed] assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed, address_v6_ll_parsed] From b434b60f14ebe8f114b7b19bb4f54081c8ae0173 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 20:05:48 -1000 Subject: [PATCH 0791/1433] feat: small cleanups to cache cleanup interval (#1146) --- src/zeroconf/_core.py | 8 ++------ src/zeroconf/const.py | 2 +- tests/services/test_browser.py | 2 +- tests/test_core.py | 6 ++---- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index aa50ddae1..eaba387eb 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -175,9 +175,7 @@ def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[thr async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: """Set up the instance.""" assert self.loop is not None - self._cleanup_timer = self.loop.call_later( - millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup - ) + self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) await self._async_create_endpoints() assert self.running_event is not None self.running_event.set() @@ -213,9 +211,7 @@ def _async_cache_cleanup(self) -> None: ) self.zc.record_manager.async_updates_complete(False) assert self.loop is not None - self._cleanup_timer = self.loop.call_later( - millis_to_seconds(_CACHE_CLEANUP_INTERVAL), self._async_cache_cleanup - ) + self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 690d26710..d223401e5 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -32,7 +32,7 @@ _BROWSER_TIME = 1000 # ms _DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms _BROWSER_BACKOFF_LIMIT = 3600 # s -_CACHE_CLEANUP_INTERVAL = 10000 # ms +_CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s _STARTUP_TIMEOUT = 9 # s must be lower than _LOADED_SYSTEM_TIMEOUT _ONE_SECOND = 1000 # ms diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index a3121e6d7..33cc7edeb 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1120,7 +1120,7 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: @patch.object(_handlers, '_DNS_PTR_MIN_TTL', 1) -@patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10) +@patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01) def test_service_browser_expire_callbacks(): """Test that the ServiceBrowser matching does not match partial names.""" # instantiate a zeroconf instance diff --git a/tests/test_core.py b/tests/test_core.py index 299690d90..9e87dba07 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -51,8 +51,7 @@ async def make_query(): # which is not threadsafe @pytest.mark.asyncio async def test_reaper(): - with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): - assert _core._CACHE_CLEANUP_INTERVAL == 10 + with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01): aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zeroconf = aiozc.zeroconf cache = zeroconf.cache @@ -88,8 +87,7 @@ async def test_reaper(): @pytest.mark.asyncio async def test_reaper_aborts_when_done(): """Ensure cache cleanup stops when zeroconf is done.""" - with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 10): - assert _core._CACHE_CLEANUP_INTERVAL == 10 + with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01): aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zeroconf = aiozc.zeroconf record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') From cc5dc7498a6415d4561385aac2fd6119383d6e37 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 2 Apr 2023 06:16:32 +0000 Subject: [PATCH 0792/1433] 0.52.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6dbb994f..569c052ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v0.52.0 (2023-04-02) +### Feature +* Small cleanups to cache cleanup interval ([#1146](https://github.com/python-zeroconf/python-zeroconf/issues/1146)) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) +* Add ip_addresses_by_version to ServiceInfo ([#1145](https://github.com/python-zeroconf/python-zeroconf/issues/1145)) ([`524494e`](https://github.com/python-zeroconf/python-zeroconf/commit/524494edd49bd049726b19ae8ac8f6eea69a3943)) +* Speed up processing records in the ServiceBrowser ([#1143](https://github.com/python-zeroconf/python-zeroconf/issues/1143)) ([`6a327d0`](https://github.com/python-zeroconf/python-zeroconf/commit/6a327d00ffb81de55b7c5b599893c789996680c1)) +* Speed up matching types in the ServiceBrowser ([#1144](https://github.com/python-zeroconf/python-zeroconf/issues/1144)) ([`68871c3`](https://github.com/python-zeroconf/python-zeroconf/commit/68871c3b5569e41740a66b7d3d7fa5cc41514ea5)) +* Include tests and docs in sdist archives ([#1142](https://github.com/python-zeroconf/python-zeroconf/issues/1142)) ([`da10a3b`](https://github.com/python-zeroconf/python-zeroconf/commit/da10a3b2827cee0719d3bb9152ae897f061c6e2e)) + ## v0.51.0 (2023-04-01) ### Feature * Improve performance of constructing ServiceInfo ([#1141](https://github.com/python-zeroconf/python-zeroconf/issues/1141)) ([`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) diff --git a/pyproject.toml b/pyproject.toml index fef507abc..6461a6b25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.51.0" +version = "0.52.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 539d4ef87..03f49bf94 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.51.0' +__version__ = '0.52.0' __license__ = 'LGPL' From 344fdd17d3f0328977f452f6935eb10c617f18be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 10:00:51 -1000 Subject: [PATCH 0793/1433] chore: cleanup _ServiceBrowserBase class to remove queue only used in sync version (#1147) --- src/zeroconf/_services/browser.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index e239bc42e..257f09f85 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -306,7 +306,6 @@ def __init__( self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) - self.queue: Optional[queue.SimpleQueue] = None self.done = False self._first_request: bool = True self._next_send_timer: Optional[asyncio.TimerHandle] = None @@ -522,7 +521,7 @@ def __init__( # Add the queue before the listener is installed in _setup # to ensure that events run in the dedicated thread and do # not block the event loop - self.queue = queue.SimpleQueue() + self.queue: queue.SimpleQueue = queue.SimpleQueue() self.daemon = True self.start() zc.loop.call_soon_threadsafe(self._async_start) @@ -534,14 +533,12 @@ def __init__( def cancel(self) -> None: """Cancel the browser.""" assert self.zc.loop is not None - assert self.queue is not None self.queue.put(None) self.zc.loop.call_soon_threadsafe(self._async_cancel) self.join() def run(self) -> None: """Run the browser thread.""" - assert self.queue is not None while True: event = self.queue.get() if event is None: @@ -555,6 +552,5 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - assert self.queue is not None while self._pending_handlers: self.queue.put(self._pending_handlers.popitem(False)) From d3213d746a7d042b9ce878781eaf387d59deaa1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 10:01:02 -1000 Subject: [PATCH 0794/1433] chore: use a constant for address record types (#1149) --- src/zeroconf/_handlers.py | 2 +- src/zeroconf/_services/browser.py | 5 ++--- src/zeroconf/const.py | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index f2e9a606c..2579deb20 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -47,6 +47,7 @@ from ._updates import RecordUpdate, RecordUpdateListener from ._utils.time import current_time_millis, millis_to_seconds from .const import ( + _ADDRESS_RECORD_TYPES, _CLASS_IN, _CLASS_UNIQUE, _DNS_OTHER_TTL, @@ -71,7 +72,6 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] _MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) -_ADDRESS_RECORD_TYPES = {_TYPE_A, _TYPE_AAAA} _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 257f09f85..d64e3601b 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -53,6 +53,7 @@ from .._utils.name import cached_possible_types, service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( + _ADDRESS_RECORD_TYPES, _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, _CLASS_IN, @@ -63,8 +64,6 @@ _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, - _TYPE_A, - _TYPE_AAAA, _TYPE_PTR, ) @@ -385,7 +384,7 @@ def _async_process_record_update( if old_record or record.is_expired(now): return - if record_type in (_TYPE_A, _TYPE_AAAA): + if record_type in _ADDRESS_RECORD_TYPES: # Iterate through the DNSCache and callback any services that use this address for type_, name in self._names_matching_types( {service.name for service in self.zc.cache.async_entries_with_server(record.name)} diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index d223401e5..3b2012152 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -139,6 +139,8 @@ _TYPE_NSEC: "nsec", } +_ADDRESS_RECORD_TYPES = {_TYPE_A, _TYPE_AAAA} + _HAS_A_TO_Z = re.compile(r'[A-Za-z]') _HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') From 9a16be56a9f69a5d0f7cde13dc1337b6d93c1433 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 10:15:42 -1000 Subject: [PATCH 0795/1433] feat: improve ServiceBrowser performance by removing OrderedDict (#1148) --- src/zeroconf/_services/browser.py | 8 ++++---- src/zeroconf/asyncio.py | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index d64e3601b..751bb7357 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -26,7 +26,6 @@ import threading import warnings from abc import abstractmethod -from collections import OrderedDict from typing import ( TYPE_CHECKING, Callable, @@ -302,7 +301,7 @@ def __init__( self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) self.question_type = question_type - self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() + self._pending_handlers: Dict[Tuple[str, str], ServiceStateChange] = {} self._service_state_changed = Signal() self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) self.done = False @@ -551,5 +550,6 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - while self._pending_handlers: - self.queue.put(self._pending_handlers.popitem(False)) + for pending in self._pending_handlers.items(): + self.queue.put(pending) + self._pending_handlers.clear() diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 93c638ecb..7ded0ecb2 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -89,8 +89,9 @@ def async_update_records_complete(self) -> None: This method will be run in the event loop. """ - while self._pending_handlers: - self._fire_service_state_changed_event(self._pending_handlers.popitem(False)) + for pending in self._pending_handlers.items(): + self._fire_service_state_changed_event(pending) + self._pending_handlers.clear() class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): From 9b6adcf5c04a469632ee866c32f5898c5cbf810a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 10:15:51 -1000 Subject: [PATCH 0796/1433] fix: make parsed_scoped_addresses return addresses in the same order as all other methods (#1150) --- src/zeroconf/_services/info.py | 62 ++++++++++++++++++++++------------ tests/services/test_info.py | 4 +-- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 0ba83f2d7..8080eb0e8 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -22,9 +22,8 @@ import ipaddress import random -import socket from functools import lru_cache -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast from .._dns import ( DNSAddress, @@ -40,7 +39,7 @@ from .._updates import RecordUpdate, RecordUpdateListener from .._utils.asyncio import get_running_loop, run_coro_with_timeout from .._utils.name import service_type_name -from .._utils.net import IPVersion, _encode_address, _is_v6_address +from .._utils.net import IPVersion, _encode_address from .._utils.time import current_time_millis from ..const import ( _CLASS_IN, @@ -223,7 +222,14 @@ def properties(self) -> Dict: return self._properties def addresses_by_version(self, version: IPVersion) -> List[bytes]: - """List addresses matching IP version.""" + """List addresses matching IP version. + + Addresses are guaranteed to be returned in LIFO (last in, first out) + order with IPv4 addresses first and IPv6 addresses second. + + This means the first address will always be the most recently added + address of the given IP version. + """ if version == IPVersion.V4Only: return [addr.packed for addr in self._ipv4_addresses] if version == IPVersion.V6Only: @@ -236,7 +242,14 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: def ip_addresses_by_version( self, version: IPVersion ) -> Union[List[ipaddress.IPv4Address], List[ipaddress.IPv6Address], List[ipaddress._BaseAddress]]: - """List ip_address objects matching IP version.""" + """List ip_address objects matching IP version. + + Addresses are guaranteed to be returned in LIFO (last in, first out) + order with IPv4 addresses first and IPv6 addresses second. + + This means the first address will always be the most recently added + address of the given IP version. + """ if version == IPVersion.V4Only: return self._ipv4_addresses if version == IPVersion.V6Only: @@ -244,27 +257,32 @@ def ip_addresses_by_version( return [*self._ipv4_addresses, *self._ipv6_addresses] def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: - """List addresses in their parsed string form.""" - result = self.addresses_by_version(version) - return [ - socket.inet_ntop(socket.AF_INET6 if _is_v6_address(addr) else socket.AF_INET, addr) - for addr in result - ] + """List addresses in their parsed string form. + + Addresses are guaranteed to be returned in LIFO (last in, first out) + order with IPv4 addresses first and IPv6 addresses second. + + This means the first address will always be the most recently added + address of the given IP version. + """ + return [str(addr) for addr in self.ip_addresses_by_version(version)] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local addresses are qualified with % when available + + Addresses are guaranteed to be returned in LIFO (last in, first out) + order with IPv4 addresses first and IPv6 addresses second. + + This means the first address will always be the most recently added + address of the given IP version. """ if self.interface_index is None: return self.parsed_addresses(version) - - def is_link_local(addr_str: str) -> Any: - addr = _cached_ip_addresses(addr_str) - return addr.version == 6 and addr.is_link_local - - ll_addrs = list(filter(is_link_local, self.parsed_addresses(version))) - other_addrs = list(filter(lambda addr: not is_link_local(addr), self.parsed_addresses(version))) - return [f"{addr}%{self.interface_index}" for addr in ll_addrs] + other_addrs + return [ + f"{addr}%{self.interface_index}" if addr.version == 6 and addr.is_link_local else str(addr) + for addr in self.ip_addresses_by_version(version) + ] def _set_properties(self, properties: Dict) -> None: """Sets properties and text of this info from a dictionary""" @@ -399,13 +417,13 @@ def dns_addresses( return [ DNSAddress( self.server, - _TYPE_AAAA if _is_v6_address(address) else _TYPE_A, + _TYPE_AAAA if address.version == 6 else _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, - address, + address.packed, created=created, ) - for address in self.addresses_by_version(version) + for address in self.ip_addresses_by_version(version) ] def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: diff --git a/tests/services/test_info.py b/tests/services/test_info.py index e02d6d54e..432151da7 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -557,14 +557,14 @@ def test_multiple_addresses(): assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed, address_v6_ll_parsed] assert info.parsed_scoped_addresses() == [ - address_v6_ll_scoped_parsed, address_parsed, address_v6_parsed, + address_v6_ll_scoped_parsed, ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ - address_v6_ll_scoped_parsed, address_v6_parsed, + address_v6_ll_scoped_parsed, ] From a3b4ef5118d8083a98621942d430772f329a2ffe Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 2 Apr 2023 20:31:29 +0000 Subject: [PATCH 0797/1433] 0.53.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569c052ef..c1c6cf90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.53.0 (2023-04-02) +### Feature +* Improve ServiceBrowser performance by removing OrderedDict ([#1148](https://github.com/python-zeroconf/python-zeroconf/issues/1148)) ([`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) + +### Fix +* Make parsed_scoped_addresses return addresses in the same order as all other methods ([#1150](https://github.com/python-zeroconf/python-zeroconf/issues/1150)) ([`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) + ## v0.52.0 (2023-04-02) ### Feature * Small cleanups to cache cleanup interval ([#1146](https://github.com/python-zeroconf/python-zeroconf/issues/1146)) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) diff --git a/pyproject.toml b/pyproject.toml index 6461a6b25..549d80c9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.52.0" +version = "0.53.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 03f49bf94..21b84e435 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.52.0' +__version__ = '0.53.0' __license__ = 'LGPL' From 41ea06a0192c0d186e678009285759eb37d880d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Apr 2023 10:53:24 -1000 Subject: [PATCH 0798/1433] fix: addresses incorrect after server name change (#1154) --- src/zeroconf/_core.py | 3 + src/zeroconf/_handlers.py | 3 + src/zeroconf/_services/info.py | 173 ++++++++++------ src/zeroconf/_services/registry.py | 2 + tests/services/test_info.py | 314 ++++++++++++++++++++++++++++- tests/test_asyncio.py | 66 ++++++ tests/test_handlers.py | 4 +- 7 files changed, 503 insertions(+), 62 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index eaba387eb..18823ef2d 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -641,6 +641,7 @@ async def async_register_service( info.host_ttl = ttl info.other_ttl = ttl + info.set_server_if_missing() await self.async_wait_for_start() await self.async_check_service(info, allow_name_change, cooperating_responders) self.registry.async_add(info) @@ -738,10 +739,12 @@ def unregister_service(self, info: ServiceInfo) -> None: async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: """Unregister a service.""" + info.set_server_if_missing() self.registry.async_remove(info) # If another server uses the same addresses, we do not want to send # goodbye packets for the address records + assert info.server is not None entries = self.registry.async_get_infos_server(info.server) broadcast_addresses = not bool(entries) return asyncio.ensure_future( diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 2579deb20..01e9e9514 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -247,6 +247,7 @@ def _get_address_and_nsec_records(service: ServiceInfo, now: float) -> Set[DNSRe records.add(dns_address) missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if missing_types: + assert service.server is not None, "Service server must be set for NSEC record." records.add(construct_nsec_record(service.server, list(missing_types), now)) return records @@ -310,10 +311,12 @@ def _add_address_answers( missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: if missing_types: + assert service.server is not None, "Service server must be set for NSEC record." additionals.add(construct_nsec_record(service.server, list(missing_types), now)) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: + assert service.server is not None, "Service server must be set for NSEC record." answer_set[construct_nsec_record(service.server, list(missing_types), now)] = set() def _answer_question( diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 8080eb0e8..e4fe5cddf 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -23,7 +23,7 @@ import ipaddress import random from functools import lru_cache -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Union, cast from .._dns import ( DNSAddress, @@ -156,8 +156,8 @@ def __init__( self.port = port self.weight = weight self.priority = priority - self.server = server if server else name - self.server_key = self.server.lower() + self.server = server if server else None + self.server_key = server.lower() if server else None self._properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} if isinstance(properties, bytes): self._set_text(properties) @@ -205,7 +205,7 @@ def addresses(self, value: List[bytes]) -> None: "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" f" got {address!r}. Hint: convert string addresses with socket.inet_pton" ) - if isinstance(addr, ipaddress.IPv4Address): + if addr.version == 4: self._ipv4_addresses.append(addr) else: self._ipv6_addresses.append(addr) @@ -339,6 +339,35 @@ def get_name(self) -> str: """Name accessor""" return self.name[: len(self.name) - len(self.type) - 1] + def _get_ip_addresses_from_cache_lifo( + self, zc: 'Zeroconf', now: float, type: int + ) -> List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: + """Set IPv6 addresses from the cache.""" + address_list: List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]] = [] + for record in self._get_address_records_from_cache_by_type(zc, type): + if record.is_expired(now): + continue + try: + ip_address = _cached_ip_addresses(record.address) + except ValueError: + continue + else: + address_list.append(ip_address) + address_list.reverse() # Reverse to get LIFO order + return address_list + + def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: + """Set IPv6 addresses from the cache.""" + self._ipv6_addresses = cast( + "List[ipaddress.IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) + ) + + def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: + """Set IPv4 addresses from the cache.""" + self._ipv4_addresses = cast( + "List[ipaddress.IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) + ) + def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: """Updates service information from a DNS record. @@ -348,7 +377,7 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) This method will be run in the event loop. """ if record is not None: - self._process_records_threadsafe(zc, now, [RecordUpdate(record, None)]) + self._process_record_threadsafe(zc, record, now) def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Updates service information from a DNS record. @@ -357,55 +386,77 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU """ self._process_records_threadsafe(zc, now, records) - def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: - """Thread safe record updating.""" - seen_addresses: Set[bytes] = set() + def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> bool: + """Thread safe record updating. + + Returns True if new records were added. + """ + updated: bool = False for record_update in records: - record = record_update.new - if isinstance(record, DNSAddress): - seen_addresses.add(record.address) - self._process_record_threadsafe(record, now) - for record in self._get_address_records_from_cache(zc): - if record.address not in seen_addresses: - self._process_record_threadsafe(record, now) - - def _process_record_threadsafe(self, record: DNSRecord, now: float) -> None: - """Thread safe record updating.""" + updated |= self._process_record_threadsafe(zc, record_update.new, now) + return updated + + def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: float) -> bool: + """Thread safe record updating. + + Returns True if a new record was added. + """ if record.is_expired(now): - return + return False - if isinstance(record, DNSAddress): - if record.key != self.server_key: - return + if record.key == self.server_key and isinstance(record, DNSAddress): try: ip_addr = _cached_ip_addresses(record.address) except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) - return - if isinstance(ip_addr, ipaddress.IPv4Address): + return False + + if ip_addr.version == 4: + if not self._ipv4_addresses: + self._set_ipv4_addresses_from_cache(zc, now) + if ip_addr not in self._ipv4_addresses: self._ipv4_addresses.insert(0, ip_addr) - return + return True + elif ip_addr != self._ipv4_addresses[0]: + self._ipv4_addresses.remove(ip_addr) + self._ipv4_addresses.insert(0, ip_addr) + + return False + + if not self._ipv6_addresses: + self._set_ipv6_addresses_from_cache(zc, now) + if ip_addr not in self._ipv6_addresses: self._ipv6_addresses.insert(0, ip_addr) - if ip_addr.is_link_local: - self.interface_index = record.scope_id - return + return True + elif ip_addr != self._ipv6_addresses[0]: + self._ipv6_addresses.remove(ip_addr) + self._ipv6_addresses.insert(0, ip_addr) - if isinstance(record, DNSText): - if record.key == self.key: - self._set_text(record.text) - return + return False + + if record.key != self.key: + return False + + if record.type == _TYPE_TXT and isinstance(record, DNSText): + self._set_text(record.text) + return True - if isinstance(record, DNSService): - if record.key != self.key: - return + if record.type == _TYPE_SRV and isinstance(record, DNSService): + old_server_key = self.server_key self.name = record.name self.server = record.server self.server_key = record.server.lower() self.port = record.port self.weight = record.weight self.priority = record.priority + if old_server_key != self.server_key: + self._set_ipv4_addresses_from_cache(zc, now) + self._set_ipv6_addresses_from_cache(zc, now) + return True + + return False def dns_addresses( self, @@ -416,7 +467,7 @@ def dns_addresses( """Return matching DNSAddress from ServiceInfo.""" return [ DNSAddress( - self.server, + self.server or self.name, _TYPE_AAAA if address.version == 6 else _TYPE_A, _CLASS_IN | _CLASS_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, @@ -447,7 +498,7 @@ def dns_service(self, override_ttl: Optional[int] = None, created: Optional[floa self.priority, self.weight, cast(int, self.port), - self.server, + self.server or self.name, created, ) @@ -462,15 +513,20 @@ def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] created, ) - def _get_address_records_from_cache(self, zc: 'Zeroconf') -> List[DNSAddress]: - """Get the address records from the cache.""" - return cast( - "List[DNSAddress]", - [ - *zc.cache.get_all_by_details(self.server, _TYPE_A, _CLASS_IN), - *zc.cache.get_all_by_details(self.server, _TYPE_AAAA, _CLASS_IN), - ], - ) + def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int) -> List[DNSAddress]: + """Get the addresses from the cache.""" + if self.server_key is None: + return [] + return cast("List[DNSAddress]", zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) + + def set_server_if_missing(self) -> None: + """Set the server if it is missing. + + This function is for backwards compatibility. + """ + if self.server is None: + self.server = self.name + self.server_key = self.server.lower() def load_from_cache(self, zc: 'Zeroconf') -> bool: """Populate the service info from the cache. @@ -478,19 +534,22 @@ def load_from_cache(self, zc: 'Zeroconf') -> bool: This method is designed to be threadsafe. """ now = current_time_millis() - record_updates: List[RecordUpdate] = [] + original_server_key = self.server_key cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: - # If there is a srv record, A and AAAA will already - # be called and we do not want to do it twice - record_updates.append(RecordUpdate(cached_srv_record, None)) - else: - for record in self._get_address_records_from_cache(zc): - record_updates.append(RecordUpdate(record, None)) + self._process_record_threadsafe(zc, cached_srv_record, now) cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: - record_updates.append(RecordUpdate(cached_txt_record, None)) - self._process_records_threadsafe(zc, now, record_updates) + self._process_record_threadsafe(zc, cached_txt_record, now) + if original_server_key == self.server_key: + # If there is a srv which changes the server_key, + # A and AAAA will already be loaded from the cache + # and we do not want to do it twice + for record in [ + *self._get_address_records_from_cache_by_type(zc, _TYPE_A), + *self._get_address_records_from_cache_by_type(zc, _TYPE_AAAA), + ]: + self._process_record_threadsafe(zc, record, now) return self._is_complete @property @@ -560,8 +619,8 @@ def generate_request_query( out = DNSOutgoing(_FLAGS_QR_QUERY) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server, _TYPE_AAAA, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server or self.name, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(zc.cache, now, self.server or self.name, _TYPE_AAAA, _CLASS_IN) if question_type == DNSQuestionType.QU: for question in out.questions: question.unicast = True diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index d4f7d51a9..b3dba6742 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -80,6 +80,7 @@ def _async_get_by_index(self, records: Dict[str, List], key: str) -> List[Servic def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" + assert info.server_key is not None, "ServiceInfo must have a server" if info.key in self._services: raise ServiceNameAlreadyRegistered @@ -93,6 +94,7 @@ def _remove(self, infos: List[ServiceInfo]) -> None: if info.key not in self._services: continue old_service_info = self._services[info.key] + assert old_service_info.server_key is not None self.types[old_service_info.type.lower()].remove(info.key) self.servers[old_service_info.server_key].remove(info.key) del self._services[info.key] diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 432151da7..1f5729877 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -961,10 +961,10 @@ async def test_port_changes_are_seen(): @pytest.mark.asyncio -async def test_ip_changes_are_seen(): - """Test that ip changes are seen by async_request.""" +async def test_ipv4_changes_are_seen(): + """Test that ipv4 changes are seen by async_request.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = "multiaipv4rec.%s" % type_ aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) host = "multahost.local." @@ -1040,3 +1040,311 @@ async def test_ip_changes_are_seen(): await info.async_request(aiozc.zeroconf, timeout=200) assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_ipv6_changes_are_seen(): + """Test that ipv6 changes are seen by async_request.""" + type_ = "_http._tcp.local." + registration_name = "multiaipv6rec.%s" % type_ + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_A], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN, + 10000, + b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V6Only) == [ + b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + ] + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN, + 10000, + b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V6Only) == [ + b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ] + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V6Only) == [ + b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ] + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_bad_ip_addresses_ignored_in_cache(): + """Test that bad ip address in the cache are ignored async_request.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + # Manually add a bad record to the cache + aiozc.zeroconf.cache.async_add_records([DNSAddress(host, const._TYPE_A, const._CLASS_IN, 10000, b'\x00')]) + + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + info = ServiceInfo(type_, registration_name) + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] + + +@pytest.mark.asyncio +async def test_service_name_change_as_seen_has_ip_in_cache(): + """Test that service name changes are seen by async_request when the ip is in the cache.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + registration_name, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x02', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V4Only) == [] + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02'] + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_name_change_as_seen_ip_not_in_cache(): + """Test that service name changes are seen by async_request when the ip is not in the cache.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + registration_name, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V4Only) == [] + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x02', + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name) + await info.async_request(aiozc.zeroconf, timeout=200) + assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02'] + + await aiozc.async_close() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 9a0bc0d6e..807124339 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -181,6 +181,72 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] +@pytest.mark.asyncio +async def test_async_service_registration_with_server_missing() -> None: + """Test registering a service with the server not specified. + + For backwards compatibility, the server should be set to the + name that was passed in. + """ + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_test1-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + + calls = [] + + class MyListener(ServiceListener): + def add_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("add", type, name)) + + def remove_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("remove", type, name)) + + def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: + calls.append(("update", type, name)) + + listener = MyListener() + + aiozc.zeroconf.add_service_listener(type_, listener) + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aiozc.async_register_service(info) + await task + + assert info.server == registration_name + assert info.server_key == registration_name + new_info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], + ) + task = await aiozc.async_update_service(new_info) + await task + task = await aiozc.async_unregister_service(new_info) + await task + await aiozc.async_close() + + assert calls == [ + ('add', type_, registration_name), + ('update', type_, registration_name), + ('remove', type_, registration_name), + ] + + @pytest.mark.asyncio async def test_async_service_registration_same_server_different_ports() -> None: """Test registering services with the same server with different srv records.""" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index cadbbaa35..0a976d3df 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -103,7 +103,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) @@ -141,7 +141,7 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server, const._TYPE_A, const._CLASS_IN)) + query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) From 68abfeadfb6a55220e6d79a23b219187e0caa09e Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 3 Apr 2023 21:01:15 +0000 Subject: [PATCH 0799/1433] 0.53.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c6cf90b..40de56797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.53.1 (2023-04-03) +### Fix +* Addresses incorrect after server name change ([#1154](https://github.com/python-zeroconf/python-zeroconf/issues/1154)) ([`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) + ## v0.53.0 (2023-04-02) ### Feature * Improve ServiceBrowser performance by removing OrderedDict ([#1148](https://github.com/python-zeroconf/python-zeroconf/issues/1148)) ([`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) diff --git a/pyproject.toml b/pyproject.toml index 549d80c9f..9985b26d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.53.0" +version = "0.53.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 21b84e435..3673a700f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.53.0' +__version__ = '0.53.1' __license__ = 'LGPL' From a3f970c7f66067cf2c302c49ed6ad8286f19b679 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Apr 2023 11:09:59 -1000 Subject: [PATCH 0800/1433] feat: avoid waking async_request when record updates are not relevant (#1153) --- src/zeroconf/_services/info.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index e4fe5cddf..fd7a9619a 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -20,6 +20,7 @@ USA """ +import asyncio import ipaddress import random from functools import lru_cache @@ -37,10 +38,14 @@ from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.asyncio import get_running_loop, run_coro_with_timeout +from .._utils.asyncio import ( + get_running_loop, + run_coro_with_timeout, + wait_event_or_timeout, +) from .._utils.name import service_type_name from .._utils.net import IPVersion, _encode_address -from .._utils.time import current_time_millis +from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _CLASS_IN, _CLASS_UNIQUE, @@ -166,6 +171,7 @@ def __init__( self.host_ttl = host_ttl self.other_ttl = other_ttl self.interface_index = interface_index + self._notify_event: Optional[asyncio.Event] = None @property def name(self) -> str: @@ -221,6 +227,12 @@ def properties(self) -> Dict: """ return self._properties + async def async_wait(self, timeout: float) -> None: + """Calling task waits for a given number of milliseconds or until notified.""" + if self._notify_event is None: + self._notify_event = asyncio.Event() + await wait_event_or_timeout(self._notify_event, timeout=millis_to_seconds(timeout)) + def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version. @@ -384,7 +396,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU This method will be run in the event loop. """ - self._process_records_threadsafe(zc, now, records) + if self._process_records_threadsafe(zc, now, records) and self._notify_event: + self._notify_event.set() + self._notify_event.clear() def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> bool: """Thread safe record updating. @@ -605,7 +619,7 @@ async def async_request( delay *= 2 next_ += random.randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) - await zc.async_wait(min(next_, last) - now) + await self.async_wait(min(next_, last) - now) now = current_time_millis() finally: zc.async_remove_listener(self) From 6abeb78ea47e74c85244c24a2bb4e3e0df9f4da9 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 3 Apr 2023 21:17:40 +0000 Subject: [PATCH 0801/1433] 0.54.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40de56797..27313576d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.54.0 (2023-04-03) +### Feature +* Avoid waking async_request when record updates are not relevant ([#1153](https://github.com/python-zeroconf/python-zeroconf/issues/1153)) ([`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) + ## v0.53.1 (2023-04-03) ### Fix * Addresses incorrect after server name change ([#1154](https://github.com/python-zeroconf/python-zeroconf/issues/1154)) ([`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) diff --git a/pyproject.toml b/pyproject.toml index 9985b26d8..3652f6da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.53.1" +version = "0.54.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3673a700f..776c4dae0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.53.1' +__version__ = '0.54.0' __license__ = 'LGPL' From b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Apr 2023 14:45:23 -1000 Subject: [PATCH 0802/1433] feat: improve performance of processing incoming records (#1155) --- src/zeroconf/_dns.pxd | 7 +++++- src/zeroconf/_dns.py | 35 +++++++++++++++--------------- src/zeroconf/_handlers.py | 10 ++------- src/zeroconf/_protocol/incoming.py | 5 +++++ tests/test_dns.py | 4 ++-- 5 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 14c7fb703..2d50c07a0 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -16,6 +16,8 @@ cdef object _RECENT_TIME_MS cdef object _CLASS_UNIQUE cdef object _CLASS_MASK +cdef object current_time_millis + cdef class DNSEntry: cdef public object key @@ -96,8 +98,11 @@ cdef class DNSNsec(DNSRecord): cdef class DNSRRSet: - cdef _records + cdef _record_sets cdef cython.dict _lookup @cython.locals(other=DNSRecord) cpdef suppresses(self, DNSRecord record) + + @cython.locals(lookup=cython.dict) + cdef _get_lookup(self) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 5727d83a8..3764edf72 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -512,31 +512,30 @@ def __repr__(self) -> str: class DNSRRSet: - """A set of dns records independent of the ttl.""" + """A set of dns records with a lookup to get the ttl.""" - __slots__ = ('_records', '_lookup') + __slots__ = ('_record_sets', '_lookup') - def __init__(self, records: Iterable[DNSRecord]) -> None: - """Create an RRset from records.""" - self._records = records - self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None + def __init__(self, record_sets: Iterable[List[DNSRecord]]) -> None: + """Create an RRset from records sets.""" + self._record_sets = record_sets + self._lookup: Optional[Dict[DNSRecord, float]] = None @property - def lookup(self) -> Dict[DNSRecord, DNSRecord]: + def lookup(self) -> Dict[DNSRecord, float]: + """Return the lookup table.""" + return self._get_lookup() + + def _get_lookup(self) -> Dict[DNSRecord, float]: + """Return the lookup table, building it if needed.""" if self._lookup is None: - # Build the hash table so we can lookup the record independent of the ttl - self._lookup = {record: record for record in self._records} + # Build the hash table so we can lookup the record ttl + self._lookup = {record: record.ttl for record_sets in self._record_sets for record in record_sets} return self._lookup def suppresses(self, record: _DNSRecord) -> bool: """Returns true if any answer in the rrset can suffice for the information held in this record.""" - if self._lookup is None: - other = self.lookup.get(record) - else: - other = self._lookup.get(record) - return bool(other and other.ttl > (record.ttl / 2)) - - def __contains__(self, record: DNSRecord) -> bool: - """Returns true if the rrset contains the record.""" - return record in self.lookup + lookup = self._get_lookup() + other_ttl = lookup.get(record) + return bool(other_ttl and other_ttl > (record.ttl / 2)) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 01e9e9514..1736106c6 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -90,10 +90,6 @@ class AnswerGroup(NamedTuple): answers: _AnswerWithAdditionalsType -def _message_is_probe(msg: DNSIncoming) -> bool: - return msg.num_authorities > 0 - - def construct_nsec_record(name: str, types: List[int], now: float) -> DNSNsec: """Construct an NSEC record for name and a list of dns types. @@ -159,7 +155,7 @@ class _QueryResponse: def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: """Build a query response.""" - self._is_probe = any(_message_is_probe(msg) for msg in msgs) + self._is_probe = any(msg.is_probe for msg in msgs) self._msg = msgs[0] self._now = self._msg.now self._cache = cache @@ -363,9 +359,7 @@ def async_response( # pylint: disable=unused-argument This function must be run in the event loop as it is not threadsafe. """ - known_answers = DNSRRSet( - itertools.chain.from_iterable(msg.answers for msg in msgs if not _message_is_probe(msg)) - ) + known_answers = DNSRRSet(msg.answers for msg in msgs if not msg.is_probe) query_res = _QueryResponse(self.cache, msgs) for msg in msgs: diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index aaf0340bc..9996b37c9 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -174,6 +174,11 @@ def answers(self) -> List[DNSRecord]: ) return self._answers + @property + def is_probe(self) -> bool: + """Returns true if this is a probe.""" + return self.num_authorities > 0 + def __repr__(self) -> str: return '' % ', '.join( [ diff --git a/tests/test_dns.py b/tests/test_dns.py index 08f805f03..b82f5d812 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -392,7 +392,7 @@ def test_rrset_does_not_consider_ttl(): longaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 100, b'same') shortaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 10, b'same') - rrset = DNSRRSet([longarec, shortaaaarec]) + rrset = DNSRRSet([[longarec, shortaaaarec]]) assert rrset.suppresses(longarec) assert rrset.suppresses(shortarec) @@ -404,7 +404,7 @@ def test_rrset_does_not_consider_ttl(): mediumarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 60, b'same') shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') - rrset2 = DNSRRSet([mediumarec]) + rrset2 = DNSRRSet([[mediumarec]]) assert not rrset2.suppresses(verylongarec) assert rrset2.suppresses(longarec) assert rrset2.suppresses(mediumarec) From 9a5dcdbca103258dddc451097e9b178e79c49671 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 7 Apr 2023 00:52:59 +0000 Subject: [PATCH 0803/1433] 0.55.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27313576d..65ecfe92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.55.0 (2023-04-07) +### Feature +* Improve performance of processing incoming records ([#1155](https://github.com/python-zeroconf/python-zeroconf/issues/1155)) ([`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) + ## v0.54.0 (2023-04-03) ### Feature * Avoid waking async_request when record updates are not relevant ([#1153](https://github.com/python-zeroconf/python-zeroconf/issues/1153)) ([`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) diff --git a/pyproject.toml b/pyproject.toml index 3652f6da8..4a4dfad04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.54.0" +version = "0.55.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 776c4dae0..fe29823ad 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.54.0' +__version__ = '0.55.0' __license__ = 'LGPL' From 2c2f26a87d0aac81a77205b06bc9ba499caa2321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Apr 2023 13:10:20 -1000 Subject: [PATCH 0804/1433] feat: reduce denial of service protection overhead (#1157) --- src/zeroconf/_handlers.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 1736106c6..240deb47c 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -133,23 +133,6 @@ def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsTy sending.add(additional) -def sanitize_incoming_record(record: DNSRecord) -> None: - """Protect zeroconf from records that can cause denial of service. - - We enforce a minimum TTL for PTR records to avoid - ServiceBrowsers generating excessive queries refresh queries. - Apple uses a 15s minimum TTL, however we do not have the same - level of rate limit and safe guards so we use 1/4 of the recommended value. - """ - if record.ttl and record.ttl < _DNS_PTR_MIN_TTL and isinstance(record, DNSPointer): - log.debug( - "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", - record, - _DNS_PTR_MIN_TTL, - ) - record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) - - class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" @@ -420,14 +403,26 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: threadsafe. """ updates: List[RecordUpdate] = [] - address_adds: List[DNSAddress] = [] + address_adds: List[DNSRecord] = [] other_adds: List[DNSRecord] = [] removes: Set[DNSRecord] = set() now = msg.now unique_types: Set[Tuple[str, int, int]] = set() for record in msg.answers: - sanitize_incoming_record(record) + # Protect zeroconf from records that can cause denial of service. + # + # We enforce a minimum TTL for PTR records to avoid + # ServiceBrowsers generating excessive queries refresh queries. + # Apple uses a 15s minimum TTL, however we do not have the same + # level of rate limit and safe guards so we use 1/4 of the recommended value. + if record.ttl and record.type == _TYPE_PTR and record.ttl < _DNS_PTR_MIN_TTL: + log.debug( + "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", + record, + _DNS_PTR_MIN_TTL, + ) + record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record.type, record.class_)) @@ -437,7 +432,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: if maybe_entry is not None: maybe_entry.reset_ttl(record) else: - if isinstance(record, DNSAddress): + if record.type in _ADDRESS_RECORD_TYPES: address_adds.append(record) else: other_adds.append(record) From f06de0a2f7db28c58c0efdbc9c9355952972e206 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 7 Apr 2023 23:21:21 +0000 Subject: [PATCH 0805/1433] 0.56.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ecfe92b..a3d9755e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.56.0 (2023-04-07) +### Feature +* Reduce denial of service protection overhead ([#1157](https://github.com/python-zeroconf/python-zeroconf/issues/1157)) ([`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) + ## v0.55.0 (2023-04-07) ### Feature * Improve performance of processing incoming records ([#1155](https://github.com/python-zeroconf/python-zeroconf/issues/1155)) ([`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) diff --git a/pyproject.toml b/pyproject.toml index 4a4dfad04..55df94773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.55.0" +version = "0.56.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index fe29823ad..e985dbf9b 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.55.0' +__version__ = '0.56.0' __license__ = 'LGPL' From 70719dd70f158295e329fd9588167cd5f4b85678 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Apr 2023 12:07:40 -1000 Subject: [PATCH 0806/1433] chore: document unexpected technically breaking change in 0.53.0 (#1160) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d9755e7..690c4cf08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ ### Fix * Make parsed_scoped_addresses return addresses in the same order as all other methods ([#1150](https://github.com/python-zeroconf/python-zeroconf/issues/1150)) ([`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) +### Technically breaking change +* IP Addresses returned from `ServiceInfo.parsed_addresses` are now stringified using the python `ipaddress` library which may format them differently than `socket.inet_ntop` depending on the operating system. It is recommended to use `ServiceInfo.ip_addresses_by_version` instead going forward as it offers a stronger guarantee since it returns `ipaddress` objects. + ## v0.52.0 (2023-04-02) ### Feature * Small cleanups to cache cleanup interval ([#1146](https://github.com/python-zeroconf/python-zeroconf/issues/1146)) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) From 02e7432b1eb001914f00eede734a19a074b63baf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Apr 2023 13:38:09 -0500 Subject: [PATCH 0807/1433] chore: update cython version and deps (#1162) --- poetry.lock | 226 ++++++++++++++++++++++++---------------------------- 1 file changed, 103 insertions(+), 123 deletions(-) diff --git a/poetry.lock b/poetry.lock index 19ba55bd3..ebcbf373b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -15,25 +15,6 @@ files = [ [package.dependencies] typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} -[[package]] -name = "attrs" -version = "22.2.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, - {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["attrs[docs,tests]"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] -tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] - [[package]] name = "colorama" version = "0.4.6" @@ -48,63 +29,63 @@ files = [ [[package]] name = "coverage" -version = "7.2.2" +version = "7.2.3" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7"}, - {file = "coverage-7.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d"}, - {file = "coverage-7.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5"}, - {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169"}, - {file = "coverage-7.2.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6"}, - {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137"}, - {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90"}, - {file = "coverage-7.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2"}, - {file = "coverage-7.2.2-cp310-cp310-win32.whl", hash = "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292"}, - {file = "coverage-7.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab"}, - {file = "coverage-7.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b"}, - {file = "coverage-7.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5"}, - {file = "coverage-7.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731"}, - {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd"}, - {file = "coverage-7.2.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d"}, - {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212"}, - {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54"}, - {file = "coverage-7.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57"}, - {file = "coverage-7.2.2-cp311-cp311-win32.whl", hash = "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d"}, - {file = "coverage-7.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512"}, - {file = "coverage-7.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9"}, - {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e"}, - {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69"}, - {file = "coverage-7.2.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0"}, - {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f"}, - {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67"}, - {file = "coverage-7.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9"}, - {file = "coverage-7.2.2-cp37-cp37m-win32.whl", hash = "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8"}, - {file = "coverage-7.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25"}, - {file = "coverage-7.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6"}, - {file = "coverage-7.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5"}, - {file = "coverage-7.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4"}, - {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd"}, - {file = "coverage-7.2.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84"}, - {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540"}, - {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88"}, - {file = "coverage-7.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2"}, - {file = "coverage-7.2.2-cp38-cp38-win32.whl", hash = "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3"}, - {file = "coverage-7.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8"}, - {file = "coverage-7.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d"}, - {file = "coverage-7.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005"}, - {file = "coverage-7.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988"}, - {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149"}, - {file = "coverage-7.2.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8"}, - {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140"}, - {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016"}, - {file = "coverage-7.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be"}, - {file = "coverage-7.2.2-cp39-cp39-win32.whl", hash = "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc"}, - {file = "coverage-7.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef"}, - {file = "coverage-7.2.2-pp37.pp38.pp39-none-any.whl", hash = "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968"}, - {file = "coverage-7.2.2.tar.gz", hash = "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535"}, + {file = "coverage-7.2.3-cp310-cp310-win32.whl", hash = "sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91"}, + {file = "coverage-7.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4"}, + {file = "coverage-7.2.3-cp311-cp311-win32.whl", hash = "sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925"}, + {file = "coverage-7.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910"}, + {file = "coverage-7.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48"}, + {file = "coverage-7.2.3-cp37-cp37m-win32.whl", hash = "sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1"}, + {file = "coverage-7.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152"}, + {file = "coverage-7.2.3-cp38-cp38-win32.whl", hash = "sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22"}, + {file = "coverage-7.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4"}, + {file = "coverage-7.2.3-cp39-cp39-win32.whl", hash = "sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30"}, + {file = "coverage-7.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a"}, + {file = "coverage-7.2.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0"}, + {file = "coverage-7.2.3.tar.gz", hash = "sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259"}, ] [package.dependencies] @@ -115,52 +96,52 @@ toml = ["tomli"] [[package]] name = "cython" -version = "0.29.33" +version = "0.29.34" description = "The Cython compiler for writing C extensions for the Python language." category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ - {file = "Cython-0.29.33-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:286cdfb193e23799e113b7bd5ac74f58da5e9a77c70e3b645b078836b896b165"}, - {file = "Cython-0.29.33-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8507279a4f86ed8365b96603d5ad155888d4d01b72a9bbf0615880feda5a11d4"}, - {file = "Cython-0.29.33-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf5ffd96957a595441cca2fc78470d93fdc40dfe5449881b812ea6045d7e9be"}, - {file = "Cython-0.29.33-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d2019a7e54ba8b253f44411863b8f8c0b6cd623f7a92dc0ccb83892358c4283a"}, - {file = "Cython-0.29.33-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:190e60b7505d3b9b60130bcc2251c01b9ef52603420829c19d3c3ede4ac2763a"}, - {file = "Cython-0.29.33-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0168482495b75fea1c97a9641a95bac991f313e85f378003f9a4909fdeb3d454"}, - {file = "Cython-0.29.33-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:090556e41f2b30427dd3a1628d3613177083f47567a30148b6b7b8c7a5862187"}, - {file = "Cython-0.29.33-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:19c9913e9304bf97f1d2c357438895466f99aa2707d3c7a5e9de60c259e1ca1d"}, - {file = "Cython-0.29.33-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:afc9b6ab20889676c76e700ae6967aa6886a7efe5b05ef6d5b744a6ca793cc43"}, - {file = "Cython-0.29.33-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:49fb45b2bf12d6e2060bbd64506c06ac90e254f3a4bceb32c717f4964a1ae812"}, - {file = "Cython-0.29.33-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:5430f38d3d01c4715ec2aef5c41e02a2441c1c3a0149359c7a498e4c605b8e6c"}, - {file = "Cython-0.29.33-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4d315443c7f4c61180b6c3ea9a9717ee7c901cc9db8d1d46fdf6556613840ed"}, - {file = "Cython-0.29.33-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b4e6481e3e7e4d345640fe2fdc6dc57c94369b467f3dc280949daa8e9fd13b9"}, - {file = "Cython-0.29.33-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:060a2568ef80116a0a9dcaf3218a61c6007be0e0b77c5752c094ce5187a4d63c"}, - {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b67ddd32eaa2932a66bf8121accc36a7b3078593805519b0f00040f2b10a6a52"}, - {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1b507236ba3ca94170ce0a504dd03acf77307d4bfbc5a010a8031673f6b213a9"}, - {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:581efc0622a9be05714222f2b4ac96a5419de58d5949517282d8df38155c8b9d"}, - {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b8bcbf8f1c3c46d6184be1e559e3a3fb8cdf27c6d507d8bc8ae04cfcbfd75f5"}, - {file = "Cython-0.29.33-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1ca93bbe584aee92094fd4fb6acc5cb6500acf98d4f57cc59244f0a598b0fcf6"}, - {file = "Cython-0.29.33-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:da490129e1e4ffaf3f88bfb46d338549a2150f60f809a63d385b83e00960d11a"}, - {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4cadf5250eda0c5cdaf4c3a29b52be3e0695f4a2bf1ccd49b638d239752ea513"}, - {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bcb1a84fd2bd7885d572adc180e24fd8a7d4b0c104c144e33ccf84a1ab4eb2b8"}, - {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d78147ad8a3417ae6b371bbc5bfc6512f6ad4ad3fb71f5eef42e136e4ed14970"}, - {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dd96b06b93c0e5fa4fc526c5be37c13a93e2fe7c372b5f358277ebe9e1620957"}, - {file = "Cython-0.29.33-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:959f0092d58e7fa00fd3434f7ff32fb78be7c2fa9f8e0096326343159477fe45"}, - {file = "Cython-0.29.33-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0455d5b92f461218bcf173a149a88b7396c3a109066274ccab5eff58db0eae32"}, - {file = "Cython-0.29.33-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:a9b0b890656e9d18a18e1efe26ea3d2d0f3e525a07a2a853592b0afc56a15c89"}, - {file = "Cython-0.29.33-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b5e8ce3039ff64000d58cd45b3f6f83e13f032dde7f27bb1ab96070d9213550b"}, - {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:e8922fa3d7e76b7186bbd0810e170ca61f83661ab1b29dc75e88ff2327aaf49d"}, - {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f67b7306fd00d55f271009335cecadc506d144205c7891070aad889928d85750"}, - {file = "Cython-0.29.33-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f271f90005064c49b47a93f456dc6cf0a21d21ef835bd33ac1e0db10ad51f84f"}, - {file = "Cython-0.29.33-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4457d417ffbb94abc42adcd63a03b24ff39cf090f3e9eca5e10cfb90766cbe3"}, - {file = "Cython-0.29.33-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0b53e017522feb8dcc2189cf1d2d344bab473c5bba5234390b5666d822992c7c"}, - {file = "Cython-0.29.33-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:4f88c2dc0653eef6468848eb8022faf64115b39734f750a1c01a7ba7eb04d89f"}, - {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:1900d862a4a537d2125706740e9f3b016e80f7bbf7b54db6b3cc3d0bdf0f5c3a"}, - {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37bfca4f9f26361343d8c678f8178321e4ae5b919523eed05d2cd8ddbe6b06ec"}, - {file = "Cython-0.29.33-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a9863f8238642c0b1ef8069d99da5ade03bfe2225a64b00c5ae006d95f142a73"}, - {file = "Cython-0.29.33-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1dd503408924723b0bb10c0013b76e324eeee42db6deced9b02b648f1415d94c"}, - {file = "Cython-0.29.33-py2.py3-none-any.whl", hash = "sha256:8b99252bde8ff51cd06a3fe4aeacd3af9b4ff4a4e6b701ac71bddc54f5da61d6"}, - {file = "Cython-0.29.33.tar.gz", hash = "sha256:5040764c4a4d2ce964a395da24f0d1ae58144995dab92c6b96f44c3f4d72286a"}, + {file = "Cython-0.29.34-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:742544024ddb74314e2d597accdb747ed76bd126e61fcf49940a5b5be0a8f381"}, + {file = "Cython-0.29.34-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:03daae07f8cbf797506446adae512c3dd86e7f27a62a541fa1ee254baf43e32c"}, + {file = "Cython-0.29.34-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5a8de3e793a576e40ca9b4f5518610cd416273c7dc5e254115656b6e4ec70663"}, + {file = "Cython-0.29.34-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:60969d38e6a456a67e7ef8ae20668eff54e32ba439d4068ccf2854a44275a30f"}, + {file = "Cython-0.29.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:21b88200620d80cfe193d199b259cdad2b9af56f916f0f7f474b5a3631ca0caa"}, + {file = "Cython-0.29.34-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:308c8f1e58bf5e6e8a1c4dcf8abbd2d13d0f9b1e582f4d9ae8b89857342d8bb5"}, + {file = "Cython-0.29.34-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d8f822fb6ecd5d88c42136561f82960612421154fc5bf23c57103a367bb91356"}, + {file = "Cython-0.29.34-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56866323f1660cecb4d5ff3a1fba92a56b91b7cfae0a8253777aa4bdb3bdf9a8"}, + {file = "Cython-0.29.34-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e971db8aeb12e7c0697cefafe65eefcc33ff1224ae3d8c7f83346cbc42c6c270"}, + {file = "Cython-0.29.34-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4401270b0dc464c23671e2e9d52a60985f988318febaf51b047190e855bbe7d"}, + {file = "Cython-0.29.34-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:dce0a36d163c05ae8b21200059511217d79b47baf2b7b0f926e8367bd7a3cc24"}, + {file = "Cython-0.29.34-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dbd79221869ee9a6ccc4953b2c8838bb6ae08ab4d50ea4b60d7894f03739417b"}, + {file = "Cython-0.29.34-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0f4229df10bc4545ebbeaaf96ebb706011d8b333e54ed202beb03f2bee0a50e"}, + {file = "Cython-0.29.34-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fd1ea21f1cebf33ae288caa0f3e9b5563a709f4df8925d53bad99be693fc0d9b"}, + {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d7ef5f68f4c5baa93349ea54a352f8716d18bee9a37f3e93eff38a5d4e9b7262"}, + {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:459994d1de0f99bb18fad9f2325f760c4b392b1324aef37bcc1cd94922dfce41"}, + {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:1d6c809e2f9ce5950bbc52a1d2352ef3d4fc56186b64cb0d50c8c5a3c1d17661"}, + {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f674ceb5f722d364395f180fbac273072fc1a266aab924acc9cfd5afc645aae1"}, + {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9489de5b2044dcdfd9d6ca8242a02d560137b3c41b1f5ae1c4f6707d66d6e44d"}, + {file = "Cython-0.29.34-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5c121dc185040f4333bfded68963b4529698e1b6d994da56be32c97a90c896b6"}, + {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b6149f7cc5b31bccb158c5b968e5a8d374fdc629792e7b928a9b66e08b03fca5"}, + {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0ab3cbf3d62b0354631a45dc93cfcdf79098663b1c65a6033af4a452b52217a7"}, + {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:4a2723447d1334484681d5aede34184f2da66317891f94b80e693a2f96a8f1a7"}, + {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e40cf86aadc29ecd1cb6de67b0d9488705865deea4fc185c7ad56d7a6fc78703"}, + {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8c3cd8bb8e880a3346f5685601004d96e0a2221e73edcaeea57ea848618b4ac6"}, + {file = "Cython-0.29.34-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0e9032cd650b0cb1d2c2ef2623f5714c14d14c28d7647d589c3eeed0baf7428e"}, + {file = "Cython-0.29.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bdb3285660e3068438791ace7dd7b1efd6b442a10b5c8d7a4f0c9d184d08c8ed"}, + {file = "Cython-0.29.34-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a8ad755f9364e720f10a36734a1c7a5ced5c679446718b589259261438a517c9"}, + {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7595d29eaee95633dd8060f50f0e54b27472d01587659557ebcfe39da3ea946b"}, + {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ef7879668214d80ea3914c17e7d4e1ebf4242e0dd4dabe95ca5ccbe75589a5"}, + {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ccb223b5f0fd95d8d27561efc0c14502c0945f1a32274835831efa5d5baddfc1"}, + {file = "Cython-0.29.34-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:11b1b278b8edef215caaa5250ad65a10023bfa0b5a93c776552248fc6f60098d"}, + {file = "Cython-0.29.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5718319a01489688fdd22ddebb8e2fcbbd60be5f30de4336ea7063c3ae29fbe5"}, + {file = "Cython-0.29.34-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:cfb2302ef617d647ee590a4c0a00ba3c2da05f301dcefe7721125565d2e51351"}, + {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:67b850cf46b861bc27226d31e1d87c0e69869a02f8d3cc5d5bef549764029879"}, + {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0963266dad685812c1dbb758fcd4de78290e3adc7db271c8664dcde27380b13e"}, + {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7879992487d9060a61393eeefe00d299210256928dce44d887b6be313d342bac"}, + {file = "Cython-0.29.34-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:44733366f1604b0c327613b6918469284878d2f5084297d10d26072fc6948d51"}, + {file = "Cython-0.29.34-py2.py3-none-any.whl", hash = "sha256:be4f6b7be75a201c290c8611c0978549c60353890204573078e865423dbe3c83"}, + {file = "Cython-0.29.34.tar.gz", hash = "sha256:1909688f5d7b521a60c396d20bba9e47a1b2d2784bfb085401e1e1e7d29a29a8"}, ] [[package]] @@ -192,14 +173,14 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.1.0" +version = "6.6.0" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.1.0-py3-none-any.whl", hash = "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"}, - {file = "importlib_metadata-6.1.0.tar.gz", hash = "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20"}, + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, ] [package.dependencies] @@ -225,14 +206,14 @@ files = [ [[package]] name = "packaging" -version = "23.0" +version = "23.1" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, - {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] [[package]] @@ -256,18 +237,17 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.2.2" +version = "7.3.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e"}, - {file = "pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4"}, + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -277,7 +257,7 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] [[package]] name = "pytest-asyncio" From cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Apr 2023 13:38:27 -0500 Subject: [PATCH 0808/1433] feat: speed up incoming data parser (#1161) --- src/zeroconf/_dns.pxd | 2 +- src/zeroconf/_dns.py | 4 +++- src/zeroconf/_protocol/incoming.pxd | 9 +++++---- src/zeroconf/_protocol/incoming.py | 13 ++++++++----- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 2d50c07a0..b28f73237 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -105,4 +105,4 @@ cdef class DNSRRSet: cpdef suppresses(self, DNSRecord record) @cython.locals(lookup=cython.dict) - cdef _get_lookup(self) + cdef cython.dict _get_lookup(self) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 3764edf72..ada6e9df4 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -538,4 +538,6 @@ def suppresses(self, record: _DNSRecord) -> bool: information held in this record.""" lookup = self._get_lookup() other_ttl = lookup.get(record) - return bool(other_ttl and other_ttl > (record.ttl / 2)) + if other_ttl is None: + return False + return other_ttl > (record.ttl / 2) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 79130d8a2..4233c810f 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -35,10 +35,10 @@ cdef class DNSIncoming: cdef bint _did_read_others cdef public unsigned int flags - cdef unsigned int offset + cdef object offset cdef public bytes data cdef unsigned int _data_len - cdef public object name_cache + cdef public cython.dict name_cache cdef public object questions cdef object _answers cdef public object id @@ -55,9 +55,10 @@ cdef class DNSIncoming: off=cython.uint, label_idx=cython.uint, length=cython.uint, - link=cython.uint + link=cython.uint, + link_data=cython.uint ) - cdef _decode_labels_at_offset(self, unsigned int off, cython.list labels, object seen_pointers) + cdef _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) cdef _read_header(self) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 9996b37c9..9c0c39a2d 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -353,7 +353,9 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S ) # We have a DNS compression pointer - link = (length & 0x3F) * 256 + self.data[off + 1] + link_data = self.data[off + 1] + link = (length & 0x3F) * 256 + link_data + lint_int = int(link) if link > self._data_len: raise IncomingDecodeError( f"DNS compression pointer at {off} points to {link} beyond packet from {self.source}" @@ -362,15 +364,16 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S raise IncomingDecodeError( f"DNS compression pointer at {off} points to itself from {self.source}" ) - if link in seen_pointers: + if lint_int in seen_pointers: raise IncomingDecodeError( f"DNS compression pointer at {off} was seen again from {self.source}" ) - linked_labels = self.name_cache.get(link, []) + linked_labels = self.name_cache.get(lint_int) if not linked_labels: - seen_pointers.add(link) + linked_labels = [] + seen_pointers.add(lint_int) self._decode_labels_at_offset(link, linked_labels, seen_pointers) - self.name_cache[link] = linked_labels + self.name_cache[lint_int] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: raise IncomingDecodeError( From 86e5c4fbf0175d105c806fcbc5ac5f4d27247155 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Apr 2023 18:49:10 +0000 Subject: [PATCH 0809/1433] 0.57.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 690c4cf08..c7a097d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.57.0 (2023-04-23) +### Feature +* Speed up incoming data parser ([#1161](https://github.com/python-zeroconf/python-zeroconf/issues/1161)) ([`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) + ## v0.56.0 (2023-04-07) ### Feature * Reduce denial of service protection overhead ([#1157](https://github.com/python-zeroconf/python-zeroconf/issues/1157)) ([`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) diff --git a/pyproject.toml b/pyproject.toml index 55df94773..765207c02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.56.0" +version = "0.57.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index e985dbf9b..bdcbae7c0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.56.0' +__version__ = '0.57.0' __license__ = 'LGPL' From 46263999c0c7ea5176885f1eadd2c8498834b70e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Apr 2023 14:57:05 -0500 Subject: [PATCH 0810/1433] feat: speed up incoming parser (#1163) --- src/zeroconf/_protocol/incoming.pxd | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 4233c810f..71d705369 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -7,6 +7,7 @@ cdef cython.uint MAX_DNS_LABELS cdef cython.uint DNS_COMPRESSION_POINTER_LEN cdef cython.uint MAX_NAME_LENGTH +cdef object current_time_millis cdef cython.uint _TYPE_A cdef cython.uint _TYPE_CNAME @@ -31,6 +32,18 @@ cdef object DECODE_EXCEPTIONS cdef object IncomingDecodeError +from .._dns cimport ( + DNSAddress, + DNSEntry, + DNSHinfo, + DNSNsec, + DNSPointer, + DNSRecord, + DNSService, + DNSText, +) + + cdef class DNSIncoming: cdef bint _did_read_others @@ -64,6 +77,8 @@ cdef class DNSIncoming: cdef _initial_parse(self) + cdef _unpack(self, object unpacker, object length) + @cython.locals( end=cython.uint, length=cython.uint From 26a4a92b9ddd2166d7a108661bdeb18d2bea54a2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Apr 2023 20:04:25 +0000 Subject: [PATCH 0811/1433] 0.58.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a097d22..eb0b6dde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.58.0 (2023-04-23) +### Feature +* Speed up incoming parser ([#1163](https://github.com/python-zeroconf/python-zeroconf/issues/1163)) ([`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) + ## v0.57.0 (2023-04-23) ### Feature * Speed up incoming data parser ([#1161](https://github.com/python-zeroconf/python-zeroconf/issues/1161)) ([`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) diff --git a/pyproject.toml b/pyproject.toml index 765207c02..cd62f63de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.57.0" +version = "0.58.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index bdcbae7c0..06f66d62e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.57.0' +__version__ = '0.58.0' __license__ = 'LGPL' From c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Apr 2023 23:56:02 -0500 Subject: [PATCH 0812/1433] fix: reduce cast calls in service browser (#1164) --- src/zeroconf/_services/browser.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 751bb7357..d3dfe328c 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -157,18 +157,16 @@ def generate_service_query( question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) question.unicast = qu_question known_answers = { - cast(DNSPointer, record) + record for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) } - if not qu_question and zc.question_history.suppresses( - question, now, cast(Set[DNSRecord], known_answers) - ): + if not qu_question and zc.question_history.suppresses(question, now, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue - questions_with_known_answers[question] = known_answers + questions_with_known_answers[question] = cast(Set[DNSPointer], known_answers) if not qu_question: - zc.question_history.add_question_at_time(question, now, cast(Set[DNSRecord], known_answers)) + zc.question_history.add_question_at_time(question, now, known_answers) return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) From 3bb1fa0e35ff9e7704e6ecd8138703738790a91f Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 26 Apr 2023 05:03:29 +0000 Subject: [PATCH 0813/1433] 0.58.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0b6dde1..81d9b2f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.58.1 (2023-04-26) +### Fix +* Reduce cast calls in service browser ([#1164](https://github.com/python-zeroconf/python-zeroconf/issues/1164)) ([`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) + ## v0.58.0 (2023-04-23) ### Feature * Speed up incoming parser ([#1163](https://github.com/python-zeroconf/python-zeroconf/issues/1163)) ([`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) diff --git a/pyproject.toml b/pyproject.toml index cd62f63de..aca69d67b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.58.0" +version = "0.58.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 06f66d62e..239b1a3cc 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.58.0' +__version__ = '0.58.1' __license__ = 'LGPL' From 498627166a4976f1d9d8cd1f3654b0d50272d266 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Apr 2023 00:47:52 -0500 Subject: [PATCH 0814/1433] fix: re-release to rebuild failed wheels (#1165) From e281d35fc448ac707bd268d1c1a9ba911a34b712 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 26 Apr 2023 05:56:37 +0000 Subject: [PATCH 0815/1433] 0.58.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d9b2f85..fabe132ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.58.2 (2023-04-26) +### Fix +* Re-release to rebuild failed wheels ([#1165](https://github.com/python-zeroconf/python-zeroconf/issues/1165)) ([`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) + ## v0.58.1 (2023-04-26) ### Fix * Reduce cast calls in service browser ([#1164](https://github.com/python-zeroconf/python-zeroconf/issues/1164)) ([`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) diff --git a/pyproject.toml b/pyproject.toml index aca69d67b..4968d9b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.58.1" +version = "0.58.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 239b1a3cc..fe3537e74 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.58.1' +__version__ = '0.58.2' __license__ = 'LGPL' From f927190cb24f70fd7c825c6e12151fcc0daf3973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 May 2023 08:17:21 -0500 Subject: [PATCH 0816/1433] feat: speed up decoding dns questions when processing incoming data (#1168) --- src/zeroconf/_protocol/incoming.pxd | 5 +++-- src/zeroconf/_protocol/incoming.py | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 71d705369..d5620692f 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -38,6 +38,7 @@ from .._dns cimport ( DNSHinfo, DNSNsec, DNSPointer, + DNSQuestion, DNSRecord, DNSService, DNSText, @@ -48,11 +49,11 @@ cdef class DNSIncoming: cdef bint _did_read_others cdef public unsigned int flags - cdef object offset + cdef cython.uint offset cdef public bytes data cdef unsigned int _data_len cdef public cython.dict name_cache - cdef public object questions + cdef public cython.list questions cdef object _answers cdef public object id cdef public cython.uint num_questions diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 9c0c39a2d..9505f4660 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -211,9 +211,12 @@ def _read_header(self) -> None: def _read_questions(self) -> None: """Reads questions section of packet""" - self.questions = [ - DNSQuestion(self._read_name(), *self._unpack(UNPACK_HH, 4)) for _ in range(self.num_questions) - ] + for _ in range(self.num_questions): + name = self._read_name() + type_, class_ = UNPACK_HH(self.data, self.offset) + self.offset += 4 + question = DNSQuestion(name, type_, class_) + self.questions.append(question) def _read_character_string(self) -> bytes: """Reads a character string from the packet""" From 1431517d2123d633d85e7b5bbfb8a58b1e8848f5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 1 May 2023 13:34:16 +0000 Subject: [PATCH 0817/1433] 0.59.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fabe132ab..2adb5ab43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.59.0 (2023-05-01) +### Feature +* Speed up decoding dns questions when processing incoming data ([#1168](https://github.com/python-zeroconf/python-zeroconf/issues/1168)) ([`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) + ## v0.58.2 (2023-04-26) ### Fix * Re-release to rebuild failed wheels ([#1165](https://github.com/python-zeroconf/python-zeroconf/issues/1165)) ([`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) diff --git a/pyproject.toml b/pyproject.toml index 4968d9b2b..34294fd1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.58.2" +version = "0.59.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index fe3537e74..06e4f465a 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.58.2' +__version__ = '0.59.0' __license__ = 'LGPL' From fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 May 2023 10:20:24 -0500 Subject: [PATCH 0818/1433] feat: speed up processing incoming data (#1167) --- src/zeroconf/_protocol/incoming.pxd | 7 +------ src/zeroconf/_protocol/incoming.py | 32 ++++++++++++++++------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index d5620692f..348cc667f 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -54,7 +54,7 @@ cdef class DNSIncoming: cdef unsigned int _data_len cdef public cython.dict name_cache cdef public cython.list questions - cdef object _answers + cdef cython.list _answers cdef public object id cdef public cython.uint num_questions cdef public cython.uint num_answers @@ -78,8 +78,6 @@ cdef class DNSIncoming: cdef _initial_parse(self) - cdef _unpack(self, object unpacker, object length) - @cython.locals( end=cython.uint, length=cython.uint @@ -88,9 +86,6 @@ cdef class DNSIncoming: cdef _read_questions(self) - @cython.locals( - length=cython.uint - ) cdef bytes _read_character_string(self) cdef _read_string(self, unsigned int length) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 9505f4660..32fdc47fa 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -22,7 +22,7 @@ import struct import sys -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast +from typing import Any, Dict, List, Optional, Set, Tuple, Union from .._dns import ( DNSAddress, @@ -194,10 +194,6 @@ def __repr__(self) -> str: ] ) - def _unpack(self, unpacker: Callable[[bytes, int], tuple], length: int) -> tuple: - self.offset += length - return unpacker(self.data, self.offset - length) - def _read_header(self) -> None: """Reads header portion of packet""" ( @@ -207,7 +203,8 @@ def _read_header(self) -> None: self.num_answers, self.num_authorities, self.num_additionals, - ) = self._unpack(UNPACK_6H, 12) + ) = UNPACK_6H(self.data) + self.offset += 12 def _read_questions(self) -> None: """Reads questions section of packet""" @@ -264,18 +261,24 @@ def _read_record( ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - return DNSAddress(domain, type_, class_, ttl, self._read_string(4), created=self.now) + dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(4)) + dns_address.created = self.now + return dns_address if type_ in (_TYPE_CNAME, _TYPE_PTR): return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) if type_ == _TYPE_TXT: return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) if type_ == _TYPE_SRV: + priority, weight, port = UNPACK_3H(self.data, self.offset) + self.offset += 6 return DNSService( domain, type_, class_, ttl, - *cast(Tuple[int, int, int], self._unpack(UNPACK_3H, 6)), + priority, + weight, + port, self._read_name(), self.now, ) @@ -285,14 +288,15 @@ def _read_record( type_, class_, ttl, - self._read_character_string().decode('utf-8'), - self._read_character_string().decode('utf-8'), + self._read_character_string().decode('utf-8', 'replace'), + self._read_character_string().decode('utf-8', 'replace'), self.now, ) if type_ == _TYPE_AAAA: - return DNSAddress( - domain, type_, class_, ttl, self._read_string(16), created=self.now, scope_id=self.scope_id - ) + dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(16)) + dns_address.created = self.now + dns_address.scope_id = self.scope_id + return dns_address if type_ == _TYPE_NSEC: name_start = self.offset return DNSNsec( @@ -384,4 +388,4 @@ def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: S ) return off + DNS_COMPRESSION_POINTER_LEN - raise IncomingDecodeError("Corrupt packet received while decoding name from {self.source}") + raise IncomingDecodeError(f"Corrupt packet received while decoding name from {self.source}") From 7e7690c597711993f17c38d58d903c7c4cd99d95 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 1 May 2023 15:40:46 +0000 Subject: [PATCH 0819/1433] 0.60.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2adb5ab43..700c9fbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.60.0 (2023-05-01) +### Feature +* Speed up processing incoming data ([#1167](https://github.com/python-zeroconf/python-zeroconf/issues/1167)) ([`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) + ## v0.59.0 (2023-05-01) ### Feature * Speed up decoding dns questions when processing incoming data ([#1168](https://github.com/python-zeroconf/python-zeroconf/issues/1168)) ([`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) diff --git a/pyproject.toml b/pyproject.toml index 34294fd1a..936b605e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.59.0" +version = "0.60.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 06e4f465a..41c817c3c 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.59.0' +__version__ = '0.60.0' __license__ = 'LGPL' From 06fa94d87b4f0451cb475a921ce1d8e9562e0f26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 15:05:32 -0500 Subject: [PATCH 0820/1433] feat: speed up parsing NSEC records (#1169) --- bench/incoming.py | 20 +++++++++++++++++++- src/zeroconf/_protocol/incoming.pxd | 10 ++++++++++ src/zeroconf/_protocol/incoming.py | 10 +++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/bench/incoming.py b/bench/incoming.py index 02a1e602e..233f19e94 100644 --- a/bench/incoming.py +++ b/bench/incoming.py @@ -3,7 +3,15 @@ import timeit from typing import List -from zeroconf import DNSAddress, DNSIncoming, DNSOutgoing, DNSService, DNSText, const +from zeroconf import ( + DNSAddress, + DNSIncoming, + DNSNsec, + DNSOutgoing, + DNSService, + DNSText, + const, +) def generate_packets() -> List[bytes]: @@ -150,6 +158,16 @@ def generate_packets() -> List[bytes]: record["address"], # type: ignore ) ) + out.add_additional_answer( + DNSNsec( + record["name"], # type: ignore + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["name"], # type: ignore + [const._TYPE_TXT, const._TYPE_SRV], + ) + ) return out.packets() diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 348cc667f..fda451cd1 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -95,6 +95,16 @@ cdef class DNSIncoming: ) cdef _read_record(self, object domain, unsigned int type_, object class_, object ttl, unsigned int length) + @cython.locals( + offset=cython.uint, + offset_plus_one=cython.uint, + offset_plus_two=cython.uint, + window=cython.uint, + bit=cython.uint, + byte=cython.uint, + i=cython.uint, + bitmap_length=cython.uint, + ) cdef _read_bitmap(self, unsigned int end) cdef _read_name(self) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 32fdc47fa..99a48d9da 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -318,9 +318,13 @@ def _read_bitmap(self, end: int) -> List[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] while self.offset < end: - window = self.data[self.offset] - bitmap_length = self.data[self.offset + 1] - for i, byte in enumerate(self.data[self.offset + 2 : self.offset + 2 + bitmap_length]): + offset = self.offset + offset_plus_one = offset + 1 + offset_plus_two = offset + 2 + window = self.data[offset] + bitmap_length = self.data[offset_plus_one] + bitmap_end = offset_plus_two + bitmap_length + for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]): for bit in range(0, 8): if byte & (0x80 >> bit): rdtypes.append(bit + window * 256 + i * 8) From 90410a2c08efcaac7eb42af1c9f60aa250f596ed Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 3 May 2023 20:19:57 +0000 Subject: [PATCH 0821/1433] 0.61.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 700c9fbb0..c6571bd0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.61.0 (2023-05-03) +### Feature +* Speed up parsing NSEC records ([#1169](https://github.com/python-zeroconf/python-zeroconf/issues/1169)) ([`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) + ## v0.60.0 (2023-05-01) ### Feature * Speed up processing incoming data ([#1167](https://github.com/python-zeroconf/python-zeroconf/issues/1167)) ([`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) diff --git a/pyproject.toml b/pyproject.toml index 936b605e1..c331d7708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.60.0" +version = "0.61.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 41c817c3c..2fcb9eb88 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.60.0' +__version__ = '0.61.0' __license__ = 'LGPL' From 963d022ef82b615540fa7521d164a98a6c6f5209 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 19:40:04 -0500 Subject: [PATCH 0822/1433] feat: improve performance of ServiceBrowser outgoing query scheduler (#1170) --- src/zeroconf/_services/browser.py | 7 +++- tests/test_asyncio.py | 67 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index d3dfe328c..fef49383d 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -460,13 +460,18 @@ def _cancel_send_timer(self) -> None: """Cancel the next send.""" if self._next_send_timer: self._next_send_timer.cancel() + self._next_send_timer = None def reschedule_type(self, type_: str, now: float, next_time: float) -> None: """Reschedule a type to be refreshed in the future.""" if self.query_scheduler.reschedule_type(type_, next_time): + # We need to send the queries before rescheduling the next one + # otherwise we may be scheduling a query to go out in the next + # iteration of the event loop which should be sent now. + if now >= next_time: + self._async_send_ready_queries(now) self._cancel_send_timer() self._async_schedule_next(now) - self._async_send_ready_queries(now) def _async_send_ready_queries(self, now: float) -> None: """Send any ready queries.""" diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 807124339..53d8e749d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -996,6 +996,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # Increase simulated time shift by 1/4 of the TTL in seconds time_offset += expected_ttl / 4 now = _new_current_time_millis() + # Force the next query to be sent since we are testing + # to see if the query contains answers and not the scheduler + browser.query_scheduler._next_time[type_] = now + (1000 * expected_ttl) browser.reschedule_type(type_, now, now) sleep_count += 1 await asyncio.wait_for(got_query.wait(), 1) @@ -1244,3 +1247,67 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de ('add', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), ('update', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), ] + + +@pytest.mark.asyncio +async def test_service_browser_does_not_try_to_send_if_not_ready(): + """Test that the service browser does not try to send if not ready when rescheduling a type.""" + service_added = asyncio.Event() + type_ = "_http._tcp.local." + registration_name = "nosend.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + await zeroconf_browser.async_wait_for_start() + + expected_ttl = const._DNS_HOST_TTL + time_offset = 0.0 + + def _new_current_time_millis(): + """Current system time in milliseconds""" + return (time.monotonic() * 1000) + (time_offset * 1000) + + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + assert len(zeroconf_registrar.engine.protocols) == 2 + with patch("zeroconf._services.browser.current_time_millis", _new_current_time_millis): + service_added = asyncio.Event() + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + + try: + await asyncio.wait_for(service_added.wait(), 1) + time_offset = 1000 * expected_ttl # set the time to the end of the ttl + now = _new_current_time_millis() + browser.query_scheduler._next_time[type_] = now + (1000 * expected_ttl) + # Make sure the query schedule is to a time in the future + # so we will reschedule + with patch.object( + browser, "_async_send_ready_queries" + ) as _async_send_ready_queries, patch.object( + browser, "_async_send_ready_queries_schedule_next" + ) as _async_send_ready_queries_schedule_next: + # Reschedule the type to be sent in 1ms in the future + # to make sure the query is not sent + browser.reschedule_type(type_, now, now + 1) + assert not _async_send_ready_queries.called + await asyncio.sleep(0.01) + # Make sure it does happen after the sleep + assert _async_send_ready_queries_schedule_next.called + finally: + await aio_zeroconf_registrar.async_close() + await browser.async_cancel() + await aiozc.async_close() From 55c879c7d93b8ade1efa5b522f9b29920a62c9e0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 4 May 2023 00:48:01 +0000 Subject: [PATCH 0823/1433] 0.62.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6571bd0d..f5fdd5be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.62.0 (2023-05-04) +### Feature +* Improve performance of ServiceBrowser outgoing query scheduler ([#1170](https://github.com/python-zeroconf/python-zeroconf/issues/1170)) ([`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) + ## v0.61.0 (2023-05-03) ### Feature * Speed up parsing NSEC records ([#1169](https://github.com/python-zeroconf/python-zeroconf/issues/1169)) ([`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) diff --git a/pyproject.toml b/pyproject.toml index c331d7708..3b0a08651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.61.0" +version = "0.62.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2fcb9eb88..12cb17697 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.61.0' +__version__ = '0.62.0' __license__ = 'LGPL' From bb496a1dd5fa3562c0412cb064d14639a542592e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 07:45:03 -0500 Subject: [PATCH 0824/1433] feat: improve dns cache performance (#1172) --- src/zeroconf/_cache.pxd | 2 ++ src/zeroconf/_cache.py | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index acf4029e8..ea436be70 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -1,4 +1,5 @@ import cython + from ._dns cimport ( DNSAddress, DNSEntry, @@ -10,6 +11,7 @@ from ._dns cimport ( ) +cdef object _UNIQUE_RECORD_TYPES cdef object _TYPE_PTR cdef _remove_key(cython.dict cache, object key, DNSRecord record) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index f022c2cbe..49f92f911 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -134,14 +134,17 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details(self, name: str, type_: int, class_: int) -> Iterator[DNSRecord]: + def async_all_by_details(self, name: _str, type_: int, class_: int) -> Iterator[DNSRecord]: """Gets all matching entries by details. This function is not threadsafe and must be called from the event loop. """ key = name.lower() - for entry in self.cache.get(key, []): + records = self.cache.get(key) + if records is None: + return + for entry in records: if _dns_record_matches(entry, key, type_, class_): yield entry @@ -151,7 +154,7 @@ def async_entries_with_name(self, name: str) -> Dict[DNSRecord, DNSRecord]: This function is not threadsafe and must be called from the event loop. """ - return self.cache.get(name.lower(), {}) + return self.cache.get(name.lower()) or {} def async_entries_with_server(self, name: str) -> Dict[DNSRecord, DNSRecord]: """Returns a dict of entries whose key matches the server. @@ -159,7 +162,7 @@ def async_entries_with_server(self, name: str) -> Dict[DNSRecord, DNSRecord]: This function is not threadsafe and must be called from the event loop. """ - return self.service_cache.get(name.lower(), {}) + return self.service_cache.get(name.lower()) or {} # The below functions are threadsafe and do not need to be run in the # event loop, however they all make copies so they significantly From 360ceb2548c4c4974ff798aac43a6fff9803ea0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 07:45:13 -0500 Subject: [PATCH 0825/1433] feat: speed up the service registry (#1174) --- src/zeroconf/_core.py | 2 +- src/zeroconf/_handlers.py | 17 ++--- src/zeroconf/_services/registry.py | 4 +- tests/services/test_registry.py | 19 ----- tests/test_handlers.py | 107 +++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 18823ef2d..a55f55e89 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -745,7 +745,7 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: # goodbye packets for the address records assert info.server is not None - entries = self.registry.async_get_infos_server(info.server) + entries = self.registry.async_get_infos_server(info.server.lower()) broadcast_addresses = not bool(entries) return asyncio.ensure_future( self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 240deb47c..159fd0d5d 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -255,10 +255,10 @@ def _add_service_type_enumeration_query_answers( answer_set[dns_pointer] = set() def _add_pointer_answers( - self, name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float + self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float ) -> None: """Answer PTR/ANY question.""" - for service in self.registry.async_get_infos_type(name): + for service in self.registry.async_get_infos_type(lower_name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service.dns_pointer(created=now) @@ -270,14 +270,14 @@ def _add_pointer_answers( def _add_address_answers( self, - name: str, + lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float, type_: int, ) -> None: """Answer A/AAAA/ANY question.""" - for service in self.registry.async_get_infos_server(name): + for service in self.registry.async_get_infos_server(lower_name): answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() seen_types: Set[int] = set() @@ -305,21 +305,22 @@ def _answer_question( now: float, ) -> _AnswerWithAdditionalsType: answer_set: _AnswerWithAdditionalsType = {} + question_lower_name = question.name.lower() - if question.type == _TYPE_PTR and question.name.lower() == _SERVICE_TYPE_ENUMERATION_NAME: + if question.type == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) return answer_set type_ = question.type if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question.name, answer_set, known_answers, now) + self._add_pointer_answers(question_lower_name, answer_set, known_answers, now) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question.name, answer_set, known_answers, now, type_) + self._add_address_answers(question_lower_name, answer_set, known_answers, now, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): - service = self.registry.async_get_info_name(question.name) + service = self.registry.async_get_info_name(question_lower_name) if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index b3dba6742..1c4ad0859 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -60,7 +60,7 @@ def async_get_service_infos(self) -> List[ServiceInfo]: def async_get_info_name(self, name: str) -> Optional[ServiceInfo]: """Return all ServiceInfo for the name.""" - return self._services.get(name.lower()) + return self._services.get(name) def async_get_types(self) -> List[str]: """Return all types.""" @@ -76,7 +76,7 @@ def async_get_infos_server(self, server: str) -> List[ServiceInfo]: def _async_get_by_index(self, records: Dict[str, List], key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" - return [self._services[name] for name in records.get(key.lower(), [])] + return [self._services[name] for name in records.get(key, [])] def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 3207b14e0..f8656e2fa 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -110,22 +110,3 @@ def test_lookups_upper_case_by_lower_case(self): assert registry.async_get_infos_type(type_.lower()) == [info] assert registry.async_get_infos_server("ash-2.local.") == [info] assert registry.async_get_types() == [type_.lower()] - - def test_lookups_lower_case_by_upper_case(self): - type_ = "_test-srvc-type._tcp.local." - name = "xxxyyy" - registration_name = f"{name}.{type_}" - - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - - registry = r.ServiceRegistry() - registry.async_add(info) - - assert registry.async_get_service_infos() == [info] - assert registry.async_get_info_name(registration_name.upper()) == info - assert registry.async_get_infos_type(type_.upper()) == [info] - assert registry.async_get_infos_server("ASH-2.local.") == [info] - assert registry.async_get_types() == [type_] diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 0a976d3df..c1c0a9a78 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -340,6 +340,32 @@ def test_aaaa_query(): zc.close() +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +def test_aaaa_query_upper_case(): + """Test that queries for AAAA records work and should respond right away with an upper case name.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_knownaaaservice._tcp.local." + name = "knownname" + registration_name = f"{name}.{type_}" + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + zc.registry.async_add(info) + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(server_name.upper(), const._TYPE_AAAA, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + mcast_answers = list(question_answers.mcast_now) + assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] + # unregister + zc.registry.async_remove(info) + zc.close() + + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_a_and_aaaa_record_fate_sharing(): @@ -481,6 +507,48 @@ async def test_probe_answered_immediately(): zc.close() +@pytest.mark.asyncio +async def test_probe_answered_immediately_with_uppercase_name(): + """Verify probes are responded to immediately with an uppercase name.""" + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + + # service definition + type_ = "_test-srvc-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.async_add(info) + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type.upper(), const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + query.add_authorative_answer(info.dns_pointer()) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False + ) + assert not question_answers.ucast + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + assert question_answers.mcast_now + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) + question.unicast = True + query.add_question(question) + query.add_authorative_answer(info.dns_pointer()) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in query.packets()], False + ) + assert question_answers.ucast + assert question_answers.mcast_now + assert not question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + zc.close() + + def test_qu_response(): """Handle multicast incoming with the QU bit set.""" # instantiate a zeroconf instance @@ -842,6 +910,45 @@ def test_known_answer_supression_service_type_enumeration_query(): zc.close() +def test_upper_case_enumeration_query(): + zc = Zeroconf(interfaces=['127.0.0.1']) + type_ = "_otherknown._tcp.local." + name = "knownname" + registration_name = f"{name}.{type_}" + desc = {'path': '/~paulsm/'} + server_name = "ash-2.local." + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.async_add(info) + + type_2 = "_otherknown2._tcp.local." + name = "knownname" + registration_name2 = f"{name}.{type_2}" + desc = {'path': '/~paulsm/'} + server_name2 = "ash-3.local." + info2 = ServiceInfo( + type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + ) + zc.registry.async_add(info2) + _clear_cache(zc) + + # Test PTR supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers.ucast + assert not question_answers.mcast_now + assert question_answers.mcast_aggregate + assert not question_answers.mcast_aggregate_last_second + # unregister + zc.registry.async_remove(info) + zc.registry.async_remove(info2) + zc.close() + + # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio From 4deaa6ed7c9161db55bf16ec068ab7260bbd4976 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 07:45:34 -0500 Subject: [PATCH 0826/1433] feat: small speed up to fetch dns addresses from ServiceInfo (#1176) --- src/zeroconf/_services/info.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fd7a9619a..a51001ce5 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -479,12 +479,15 @@ def dns_addresses( created: Optional[float] = None, ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" + name = self.server or self.name + ttl = override_ttl if override_ttl is not None else self.host_ttl + class_ = _CLASS_IN | _CLASS_UNIQUE return [ DNSAddress( - self.server or self.name, + name, _TYPE_AAAA if address.version == 6 else _TYPE_A, - _CLASS_IN | _CLASS_UNIQUE, - override_ttl if override_ttl is not None else self.host_ttl, + class_, + ttl, address.packed, created=created, ) From b356bc8f67fc6061eb97df87e5a159efa270794d Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 25 May 2023 13:00:20 +0000 Subject: [PATCH 0827/1433] 0.63.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fdd5be7..c0540ede7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.63.0 (2023-05-25) +### Feature +* Small speed up to fetch dns addresses from ServiceInfo ([#1176](https://github.com/python-zeroconf/python-zeroconf/issues/1176)) ([`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) +* Speed up the service registry ([#1174](https://github.com/python-zeroconf/python-zeroconf/issues/1174)) ([`360ceb2`](https://github.com/python-zeroconf/python-zeroconf/commit/360ceb2548c4c4974ff798aac43a6fff9803ea0e)) +* Improve dns cache performance ([#1172](https://github.com/python-zeroconf/python-zeroconf/issues/1172)) ([`bb496a1`](https://github.com/python-zeroconf/python-zeroconf/commit/bb496a1dd5fa3562c0412cb064d14639a542592e)) + ## v0.62.0 (2023-05-04) ### Feature * Improve performance of ServiceBrowser outgoing query scheduler ([#1170](https://github.com/python-zeroconf/python-zeroconf/issues/1170)) ([`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) diff --git a/pyproject.toml b/pyproject.toml index 3b0a08651..2b9fd56ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.62.0" +version = "0.63.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 12cb17697..0b8978387 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.62.0' +__version__ = '0.63.0' __license__ = 'LGPL' From 74d7ba1aeeae56be087ee8142ee6ca1219744baa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 18:26:06 -0500 Subject: [PATCH 0828/1433] fix: always answer QU questions when the exact same packet is received from different sources in sequence (#1178) If the exact same packet with a QU question is asked from two different sources in a 1s window we end up ignoring the second one as a duplicate. We should still respond in this case because the client wants a unicast response and the question may not be answered by the previous packet since the response may not be multicast. fix: include NSEC records in initial broadcast when registering a new service This also revealed that we do not send NSEC records in the initial broadcast. This needed to be fixed in this PR as well for everything to work as expected since all the tests would fail with 2 updates otherwise. --- src/zeroconf/_core.py | 86 +++++---- src/zeroconf/_handlers.py | 42 +---- src/zeroconf/_protocol/incoming.pxd | 5 + src/zeroconf/_protocol/incoming.py | 10 + src/zeroconf/_services/info.py | 34 +++- src/zeroconf/const.py | 1 + tests/conftest.py | 13 ++ tests/services/test_browser.py | 4 +- tests/services/test_info.py | 4 - tests/services/test_types.py | 272 +++++++++++++--------------- tests/test_asyncio.py | 17 +- tests/test_core.py | 90 ++++++++- tests/test_exceptions.py | 1 - tests/test_handlers.py | 18 +- tests/test_services.py | 211 +++++++++++---------- 15 files changed, 437 insertions(+), 371 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index a55f55e89..b29fc7905 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -71,6 +71,7 @@ _CHECK_TIME, _CLASS_IN, _CLASS_UNIQUE, + _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -259,26 +260,20 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self.data: Optional[bytes] = None self.last_time: float = 0 + self.last_message: Optional[DNSIncoming] = None self.transport: Optional[_WrappedTransport] = None self.sock_description: Optional[str] = None self._deferred: Dict[str, List[DNSIncoming]] = {} self._timers: Dict[str, asyncio.TimerHandle] = {} super().__init__() - def suppress_duplicate_packet(self, data: bytes, now: float) -> bool: - """Suppress duplicate packet if the last one was the same in the last second.""" - if self.data == data and (now - 1000) < self.last_time: - return True - self.data = data - self.last_time = now - return False - def datagram_received( self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: assert self.transport is not None v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () data_len = len(data) + debug = log.isEnabledFor(logging.DEBUG) if len(addrs) == 2: # https://github.com/python/mypy/issues/1178 @@ -290,19 +285,6 @@ def datagram_received( log.debug('IPv6 scope_id %d associated to the receiving interface', scope) v6_flow_scope = (flow, scope) - now = current_time_millis() - if self.suppress_duplicate_packet(data, now): - # Guard against duplicate packets - log.debug( - 'Ignoring duplicate message received from %r:%r [socket %s] (%d bytes) as [%r]', - addr, - port, - self.sock_description, - data_len, - data, - ) - return - if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm # the system. @@ -314,26 +296,50 @@ def datagram_received( ) return + now = current_time_millis() + if ( + self.data == data + and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time + and self.last_message is not None + and not self.last_message.has_qu_question() + ): + # Guard against duplicate packets + if debug: + log.debug( + 'Ignoring duplicate message with no unicast questions received from %r:%r [socket %s] (%d bytes) as [%r]', + addr, + port, + self.sock_description, + data_len, + data, + ) + return + msg = DNSIncoming(data, (addr, port), scope, now) + self.data = data + self.last_time = now + self.last_message = msg if msg.valid: - log.debug( - 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', - addr, - port, - self.sock_description, - msg, - data_len, - data, - ) + if debug: + log.debug( + 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', + addr, + port, + self.sock_description, + msg, + data_len, + data, + ) else: - log.debug( - 'Received from %r:%r [socket %s]: (%d bytes) [%r]', - addr, - port, - self.sock_description, - data_len, - data, - ) + if debug: + log.debug( + 'Received from %r:%r [socket %s]: (%d bytes) [%r]', + addr, + port, + self.sock_description, + data_len, + data, + ) return if not msg.is_query(): @@ -722,8 +728,8 @@ def _add_broadcast_answer( # pylint: disable=no-self-use out.add_answer_at_time(info.dns_service(override_ttl=host_ttl, created=now), 0) out.add_answer_at_time(info.dns_text(override_ttl=other_ttl, created=now), 0) if broadcast_addresses: - for dns_address in info.dns_addresses(override_ttl=host_ttl, created=now): - out.add_answer_at_time(dns_address, 0) + for record in info.get_address_and_nsec_records(override_ttl=host_ttl, created=now): + out.add_answer_at_time(record, 0) def unregister_service(self, info: ServiceInfo) -> None: """Unregister a service. diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 159fd0d5d..38a1b034b 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -37,19 +37,17 @@ ) from ._cache import DNSCache, _UniqueRecordsType -from ._dns import DNSAddress, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet +from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet from ._history import QuestionHistory from ._logger import log from ._protocol.incoming import DNSIncoming from ._protocol.outgoing import DNSOutgoing -from ._services.info import ServiceInfo from ._services.registry import ServiceRegistry from ._updates import RecordUpdate, RecordUpdateListener from ._utils.time import current_time_millis, millis_to_seconds from .const import ( _ADDRESS_RECORD_TYPES, _CLASS_IN, - _CLASS_UNIQUE, _DNS_OTHER_TTL, _DNS_PTR_MIN_TTL, _FLAGS_AA, @@ -90,15 +88,6 @@ class AnswerGroup(NamedTuple): answers: _AnswerWithAdditionalsType -def construct_nsec_record(name: str, types: List[int], now: float) -> DNSNsec: - """Construct an NSEC record for name and a list of dns types. - - This function should only be used for SRV/A/AAAA records - which have a TTL of _DNS_OTHER_TTL - """ - return DNSNsec(name, _TYPE_NSEC, _CLASS_IN | _CLASS_UNIQUE, _DNS_OTHER_TTL, name, types, created=now) - - def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) @@ -217,20 +206,6 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) -def _get_address_and_nsec_records(service: ServiceInfo, now: float) -> Set[DNSRecord]: - """Build a set of address records and NSEC records for non-present record types.""" - seen_types: Set[int] = set() - records: Set[DNSRecord] = set() - for dns_address in service.dns_addresses(created=now): - seen_types.add(dns_address.type) - records.add(dns_address) - missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types - if missing_types: - assert service.server is not None, "Service server must be set for NSEC record." - records.add(construct_nsec_record(service.server, list(missing_types), now)) - return records - - class QueryHandler: """Query the ServiceRegistry.""" @@ -264,9 +239,10 @@ def _add_pointer_answers( dns_pointer = service.dns_pointer(created=now) if known_answers.suppresses(dns_pointer): continue - additionals: Set[DNSRecord] = {service.dns_service(created=now), service.dns_text(created=now)} - additionals |= _get_address_and_nsec_records(service, now) - answer_set[dns_pointer] = additionals + answer_set[dns_pointer] = { + service.dns_service(created=now), + service.dns_text(created=now), + } | service.get_address_and_nsec_records(created=now) def _add_address_answers( self, @@ -291,12 +267,12 @@ def _add_address_answers( if answers: if missing_types: assert service.server is not None, "Service server must be set for NSEC record." - additionals.add(construct_nsec_record(service.server, list(missing_types), now)) + additionals.add(service.dns_nsec(list(missing_types), created=now)) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: assert service.server is not None, "Service server must be set for NSEC record." - answer_set[construct_nsec_record(service.server, list(missing_types), now)] = set() + answer_set[service.dns_nsec(list(missing_types), created=now)] = set() def _answer_question( self, @@ -327,7 +303,7 @@ def _answer_question( # https://tools.ietf.org/html/rfc6763#section-12.2. dns_service = service.dns_service(created=now) if not known_answers.suppresses(dns_service): - answer_set[dns_service] = _get_address_and_nsec_records(service, now) + answer_set[dns_service] = service.get_address_and_nsec_records(created=now) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service.dns_text(created=now) if not known_answers.suppresses(dns_text): @@ -496,7 +472,7 @@ def async_add_listener( its update_record method called when information is available to answer the question(s). - This function is not threadsafe and must be called in the eventloop. + This function is not thread-safe and must be called in the eventloop. """ if not isinstance(listener, RecordUpdateListener): log.error( # type: ignore[unreachable] diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index fda451cd1..a7130b663 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -65,6 +65,11 @@ cdef class DNSIncoming: cdef public object scope_id cdef public object source + @cython.locals( + question=DNSQuestion + ) + cpdef has_qu_question(self) + @cython.locals( off=cython.uint, label_idx=cython.uint, diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 99a48d9da..e82ddd367 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -135,6 +135,16 @@ def is_response(self) -> bool: """Returns true if this is a response.""" return (self.flags & _FLAGS_QR_MASK) == _FLAGS_QR_RESPONSE + def has_qu_question(self) -> bool: + """Returns true if any question is a QU question.""" + if not self.num_questions: + return False + for question in self.questions: + # QU questions use the same bit as unique + if question.unique: + return True + return False + @property def truncated(self) -> bool: """Returns true if this is a truncated.""" diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index a51001ce5..d5b8408d8 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -24,10 +24,11 @@ import ipaddress import random from functools import lru_cache -from typing import TYPE_CHECKING, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast from .._dns import ( DNSAddress, + DNSNsec, DNSPointer, DNSQuestionType, DNSRecord, @@ -47,6 +48,7 @@ from .._utils.net import IPVersion, _encode_address from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( + _ADDRESS_RECORD_TYPES, _CLASS_IN, _CLASS_UNIQUE, _DNS_HOST_TTL, @@ -55,6 +57,7 @@ _LISTENER_TIME, _TYPE_A, _TYPE_AAAA, + _TYPE_NSEC, _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, @@ -530,6 +533,35 @@ def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] created, ) + def dns_nsec( + self, missing_types: List[int], override_ttl: Optional[int] = None, created: Optional[float] = None + ) -> DNSNsec: + """Return DNSNsec from ServiceInfo.""" + return DNSNsec( + self.name, + _TYPE_NSEC, + _CLASS_IN | _CLASS_UNIQUE, + override_ttl if override_ttl is not None else self.host_ttl, + self.name, + missing_types, + created, + ) + + def get_address_and_nsec_records( + self, override_ttl: Optional[int] = None, created: Optional[float] = None + ) -> Set[DNSRecord]: + """Build a set of address records and NSEC records for non-present record types.""" + seen_types: Set[int] = set() + records: Set[DNSRecord] = set() + for dns_address in self.dns_addresses(override_ttl, IPVersion.All, created): + seen_types.add(dns_address.type) + records.add(dns_address) + missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + if missing_types: + assert self.server is not None, "Service server must be set for NSEC record." + records.add(self.dns_nsec(list(missing_types), override_ttl, created)) + return records + def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int) -> List[DNSAddress]: """Get the addresses from the cache.""" if self.server_key is None: diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 3b2012152..f87c13360 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -31,6 +31,7 @@ _LISTENER_TIME = 200 # ms _BROWSER_TIME = 1000 # ms _DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms +_DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s diff --git a/tests/conftest.py b/tests/conftest.py index 7fde48349..71b00d48d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,3 +27,16 @@ def run_isolated(): const, "_MDNS_PORT", 5454 ): yield + + +@pytest.fixture +def disable_duplicate_packet_suppression(): + """Disable duplicate packet suppress. + + Some tests run too slowly because of the duplicate + packet suppression. + """ + with unittest.mock.patch.object( + _core, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0 + ), unittest.mock.patch.object(const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0): + yield diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 33cc7edeb..72e550c62 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -180,7 +180,6 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de service_updated_event.set() def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) assert generated.is_response() is True @@ -331,7 +330,6 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi class TestServiceBrowserMultipleTypes(unittest.TestCase): def test_update_record(self): - service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] @@ -580,7 +578,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): pass browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change], delay=5) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 120 + 5)) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 120 + 50)) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 1f5729877..f24baa8f2 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -196,7 +196,6 @@ def test_service_info_rejects_expired_records(self): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_get_info_partial(self): - zc = r.Zeroconf(interfaces=['127.0.0.1']) service_name = 'name._type._tcp.local.' @@ -224,7 +223,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): with patch.object(zc, "async_send", send): def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: @@ -343,7 +341,6 @@ def get_service_info_helper(zc, type, name): zc.close() def test_get_info_single(self): - zc = r.Zeroconf(interfaces=['127.0.0.1']) service_name = 'name._type._tcp.local.' @@ -369,7 +366,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): with patch.object(zc, "async_send", send): def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) for record in records: diff --git a/tests/services/test_types.py b/tests/services/test_types.py index e1062b862..a8b36b8e8 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -8,7 +8,6 @@ import socket import sys import unittest -from unittest.mock import patch import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, ZeroconfServiceTypes @@ -30,147 +29,130 @@ def teardown_module(): log.setLevel(original_logging_level) -class ServiceTypesQuery(unittest.TestCase): - def test_integration_with_listener(self): - - type_ = "_test-listen-type._tcp.local." - name = "xxxyyy" - registration_name = f"{name}.{type_}" - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.registry.async_add(info) - try: - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_integration_with_listener_v6_records(self): - - type_ = "_test-listenv6rec-type._tcp.local." - name = "xxxyyy" - registration_name = f"{name}.{type_}" - addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_pton(socket.AF_INET6, addr)], - ) - zeroconf_registrar.registry.async_add(info) - try: - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - @unittest.skipIf(not has_working_ipv6() or sys.platform == 'win32', 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') - def test_integration_with_listener_ipv6(self): - - type_ = "_test-listenv6ip-type._tcp.local." - name = "xxxyyy" - registration_name = f"{name}.{type_}" - addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - - zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_pton(socket.AF_INET6, addr)], - ) - zeroconf_registrar.registry.async_add(info) - try: - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ): - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) - assert type_ in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) - assert type_ in service_types - - finally: - zeroconf_registrar.close() - - def test_integration_with_subtype_and_listener(self): - subtype_ = "_subtype._sub" - type_ = "_listen._tcp.local." - name = "xxxyyy" - # Note: discovery returns only DNS-SD type not subtype - discovery_type = f"{subtype_}.{type_}" - registration_name = f"{name}.{type_}" - - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - discovery_type, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.registry.async_add(info) - try: - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ): - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) - assert discovery_type in service_types - _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) - assert discovery_type in service_types - - finally: - zeroconf_registrar.close() +def test_integration_with_listener(disable_duplicate_packet_suppression): + type_ = "_test-listen-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.registry.async_add(info) + try: + + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + +@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +def test_integration_with_listener_v6_records(disable_duplicate_packet_suppression): + type_ = "_test-listenv6rec-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_pton(socket.AF_INET6, addr)], + ) + zeroconf_registrar.registry.async_add(info) + try: + + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + +@unittest.skipIf(not has_working_ipv6() or sys.platform == 'win32', 'Requires IPv6') +@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): + type_ = "_test-listenv6ip-type._tcp.local." + name = "xxxyyy" + registration_name = f"{name}.{type_}" + addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com + + zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_pton(socket.AF_INET6, addr)], + ) + zeroconf_registrar.registry.async_add(info) + try: + + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) + assert type_ in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + assert type_ in service_types + + finally: + zeroconf_registrar.close() + + +def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppression): + subtype_ = "_subtype._sub" + type_ = "_listen._tcp.local." + name = "xxxyyy" + # Note: discovery returns only DNS-SD type not subtype + discovery_type = f"{subtype_}.{type_}" + registration_name = f"{name}.{type_}" + + zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + discovery_type, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.registry.async_add(info) + try: + + service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + assert discovery_type in service_types + _clear_cache(zeroconf_registrar) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + assert discovery_type in service_types + + finally: + zeroconf_registrar.close() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 53d8e749d..66c81e001 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -236,6 +236,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ) task = await aiozc.async_update_service(new_info) await task + task = await aiozc.async_unregister_service(new_info) await task await aiozc.async_close() @@ -954,21 +955,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL # Disable duplicate question suppression and duplicate packet suppression for this test as it works # by asking the same question over and over - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_browser.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_browser.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_browser.question_history, "suppresses", return_value=False - ), patch.object( + with patch.object(zeroconf_browser.question_history, "suppresses", return_value=False), patch.object( zeroconf_browser, "async_send", send - ), patch( - "zeroconf._services.browser.current_time_millis", _new_current_time_millis - ), patch.object( + ), patch("zeroconf._services.browser.current_time_millis", _new_current_time_millis), patch.object( _services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4) ): service_added = asyncio.Event() diff --git a/tests/test_core.py b/tests/test_core.py index 9e87dba07..93e07b0ae 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,7 +14,7 @@ import unittest import unittest.mock from typing import Set, cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -770,15 +770,85 @@ def test_guard_against_duplicate_packets(): """ zc = Zeroconf(interfaces=['127.0.0.1']) listener = _core.AsyncListener(zc) - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is False - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis() + 1000) is False - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is True - assert listener.suppress_duplicate_packet(b"other packet", current_time_millis()) is False - assert listener.suppress_duplicate_packet(b"other packet", current_time_millis()) is True - assert listener.suppress_duplicate_packet(b"other packet", current_time_millis() + 1000) is False - assert listener.suppress_duplicate_packet(b"first packet", current_time_millis()) is False + listener.transport = MagicMock() + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + packet_with_qm_question = query.packets()[0] + + query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question3 = r.DNSQuestion("x._ay._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query3.add_question(question3) + packet_with_qm_question2 = query3.packets()[0] + + query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question2 = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + question2.unicast = True + query2.add_question(question2) + packet_with_qu_question = query2.packets()[0] + + addrs = ("1.2.3.4", 43) + + with patch.object(_core, "current_time_millis") as _current_time_millis, patch.object( + listener, "handle_query_or_defer" + ) as _handle_query_or_defer: + start_time = current_time_millis() + + _current_time_millis.return_value = start_time + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now walk time forward 1000 seconds + _current_time_millis.return_value = start_time + 1000 + # Now call with the same packet again and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question2, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet with qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call again with the same packet that has a qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + log.setLevel(logging.WARNING) + + # Call with the QM packet again + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now call with garbage + listener.datagram_received(b'garbage', addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + zc.close() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index aa5086c58..6a37c6dbc 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -26,7 +26,6 @@ def teardown_module(): class Exceptions(unittest.TestCase): - browser = None # type: Zeroconf @classmethod diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c1c0a9a78..2aa5caa1c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -42,7 +42,6 @@ def teardown_module(): class TestRegistrar(unittest.TestCase): def test_ttl(self): - # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) @@ -68,7 +67,7 @@ def test_ttl(self): def get_ttl(record_type): if expected_ttl is not None: return expected_ttl - elif record_type in [const._TYPE_A, const._TYPE_SRV]: + elif record_type in [const._TYPE_A, const._TYPE_SRV, const._TYPE_NSEC]: return const._DNS_HOST_TTL else: return const._DNS_OTHER_TTL @@ -94,7 +93,7 @@ def _process_outgoing_packet(out): zc.registry.async_add(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, None)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 + assert nbr_answers == 15 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 # query @@ -120,7 +119,7 @@ def _process_outgoing_packet(out): zc.registry.async_remove(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 + assert nbr_answers == 15 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 expected_ttl = None @@ -132,7 +131,7 @@ def _process_outgoing_packet(out): assert expected_ttl != const._DNS_HOST_TTL for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, expected_ttl)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 3 + assert nbr_answers == 15 and nbr_additionals == 0 and nbr_authorities == 3 nbr_answers = nbr_additionals = nbr_authorities = 0 # query @@ -156,7 +155,7 @@ def _process_outgoing_packet(out): zc.registry.async_remove(info) for _ in range(3): _process_outgoing_packet(zc.generate_service_broadcast(info, 0)) - assert nbr_answers == 12 and nbr_additionals == 0 and nbr_authorities == 0 + assert nbr_answers == 15 and nbr_additionals == 0 and nbr_authorities == 0 nbr_answers = nbr_additionals = nbr_authorities = 0 zc.close() @@ -222,7 +221,6 @@ def test_register_and_lookup_type_by_uppercase_name(self): def test_ptr_optimization(): - # instantiate a zeroconf instance zc = Zeroconf(interfaces=['127.0.0.1']) @@ -1467,7 +1465,7 @@ async def test_response_aggregation_timings(run_isolated): @pytest.mark.asyncio -async def test_response_aggregation_timings_multiple(run_isolated): +async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): """Verify multicast responses that are aggregated do not take longer than 620ms to send. 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" @@ -1492,9 +1490,7 @@ async def test_response_aggregation_timings_multiple(run_isolated): zc = aiozc.zeroconf protocol = zc.engine.protocols[0] - with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock, unittest.mock.patch.object( - protocol, "suppress_duplicate_packet", return_value=False - ): + with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) await asyncio.sleep(0.2) diff --git a/tests/test_services.py b/tests/test_services.py index 8371a7967..e21c23d94 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -10,7 +10,6 @@ import unittest from threading import Event from typing import Dict -from unittest.mock import patch import pytest @@ -37,7 +36,6 @@ def teardown_module(): class ListenerTest(unittest.TestCase): def test_integration_with_listener_class(self): - sub_service_added = Event() service_added = Event() service_removed = Event() @@ -107,113 +105,108 @@ def update_service(self, zeroconf, type, name): ) zeroconf_registrar.register_service(info_service) - with patch.object( - zeroconf_registrar.engine.protocols[0], "suppress_duplicate_packet", return_value=False - ), patch.object( - zeroconf_registrar.engine.protocols[1], "suppress_duplicate_packet", return_value=False - ): - try: - service_added.wait(1) - assert service_added.is_set() - - # short pause to allow multicast timers to expire - time.sleep(3) - - zeroconf_browser.add_service_listener(type_, DuplicateListener()) - duplicate_service_added.wait( - 1 - ) # Ensure a listener for the same type calls back right away from cache - - # clear the answer cache to force query - _clear_cache(zeroconf_browser) - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties == {} - - # get service info without answer cache - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_none'] is None - assert info.properties[b'prop_string'] == properties['prop_string'] - assert info.properties[b'prop_float'] == b'1.0' - assert info.properties[b'prop_blank'] == properties['prop_blank'] - assert info.properties[b'prop_true'] == b'1' - assert info.properties[b'prop_false'] == b'0' - assert info.addresses == addresses[:1] # no V6 by default - assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) - - cached_info = ServiceInfo(type_, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - - # Populate the cache - zeroconf_browser.get_service_info(subtype, registration_name) - - # get service info with only the cache - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - # get service info with only the cache with the lowercase name - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - # Ensure uppercase output is preserved - assert cached_info.name == registration_name - assert cached_info.key == registration_name.lower() - assert cached_info.properties is not None - assert cached_info.properties[b'prop_float'] == b'1.0' - - info = zeroconf_browser.get_service_info(subtype, registration_name) - assert info is not None - assert info.properties is not None - assert info.properties[b'prop_none'] is None - - cached_info = ServiceInfo(subtype, registration_name.lower()) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_none'] is None - - # test TXT record update - sublistener = MySubListener() - - zeroconf_browser.add_service_listener(subtype, sublistener) - - properties['prop_blank'] = b'an updated string' - desc.update(properties) - info_service = ServiceInfo( - subtype, - registration_name, - 80, - 0, - 0, - desc, - "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.2")], - ) - zeroconf_registrar.update_service(info_service) - - sub_service_added.wait(1) # we cleared the cache above - assert sub_service_added.is_set() - - info = zeroconf_browser.get_service_info(type_, registration_name) - assert info is not None - assert info.properties[b'prop_blank'] == properties['prop_blank'] - - cached_info = ServiceInfo(subtype, registration_name) - cached_info.load_from_cache(zeroconf_browser) - assert cached_info.properties is not None - assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] - - zeroconf_registrar.unregister_service(info_service) - service_removed.wait(1) - assert service_removed.is_set() - - finally: - zeroconf_registrar.close() - zeroconf_browser.remove_service_listener(listener) - zeroconf_browser.close() + try: + service_added.wait(1) + assert service_added.is_set() + + # short pause to allow multicast timers to expire + time.sleep(3) + + zeroconf_browser.add_service_listener(type_, DuplicateListener()) + duplicate_service_added.wait( + 1 + ) # Ensure a listener for the same type calls back right away from cache + + # clear the answer cache to force query + _clear_cache(zeroconf_browser) + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties == {} + + # get service info without answer cache + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_none'] is None + assert info.properties[b'prop_string'] == properties['prop_string'] + assert info.properties[b'prop_float'] == b'1.0' + assert info.properties[b'prop_blank'] == properties['prop_blank'] + assert info.properties[b'prop_true'] == b'1' + assert info.properties[b'prop_false'] == b'0' + assert info.addresses == addresses[:1] # no V6 by default + assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) + + cached_info = ServiceInfo(type_, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + + # Populate the cache + zeroconf_browser.get_service_info(subtype, registration_name) + + # get service info with only the cache + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + # get service info with only the cache with the lowercase name + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + # Ensure uppercase output is preserved + assert cached_info.name == registration_name + assert cached_info.key == registration_name.lower() + assert cached_info.properties is not None + assert cached_info.properties[b'prop_float'] == b'1.0' + + info = zeroconf_browser.get_service_info(subtype, registration_name) + assert info is not None + assert info.properties is not None + assert info.properties[b'prop_none'] is None + + cached_info = ServiceInfo(subtype, registration_name.lower()) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_none'] is None + + # test TXT record update + sublistener = MySubListener() + + zeroconf_browser.add_service_listener(subtype, sublistener) + + properties['prop_blank'] = b'an updated string' + desc.update(properties) + info_service = ServiceInfo( + subtype, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zeroconf_registrar.update_service(info_service) + + sub_service_added.wait(1) # we cleared the cache above + assert sub_service_added.is_set() + + info = zeroconf_browser.get_service_info(type_, registration_name) + assert info is not None + assert info.properties[b'prop_blank'] == properties['prop_blank'] + + cached_info = ServiceInfo(subtype, registration_name) + cached_info.load_from_cache(zeroconf_browser) + assert cached_info.properties is not None + assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] + + zeroconf_registrar.unregister_service(info_service) + service_removed.wait(1) + assert service_removed.is_set() + + finally: + zeroconf_registrar.close() + zeroconf_browser.remove_service_listener(listener) + zeroconf_browser.close() def test_servicelisteners_raise_not_implemented(): From d9193160b05beeca3755e19fd377ba13fe37b071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 19:14:31 -0500 Subject: [PATCH 0829/1433] feat: speed up processing incoming records (#1179) --- src/zeroconf/_cache.pxd | 11 +++++++++ src/zeroconf/_cache.py | 47 ++++++++++++++++++++++++++++++++------- src/zeroconf/_handlers.py | 17 +------------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index ea436be70..07eeb8079 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -13,6 +13,7 @@ from ._dns cimport ( cdef object _UNIQUE_RECORD_TYPES cdef object _TYPE_PTR +cdef object _ONE_SECOND cdef _remove_key(cython.dict cache, object key, DNSRecord record) @@ -22,9 +23,19 @@ cdef class DNSCache: cdef public cython.dict cache cdef public cython.dict service_cache + @cython.locals( + records=cython.dict, + record=DNSRecord, + ) + cdef _async_all_by_details(self, object name, object type_, object class_) + cdef _async_add(self, DNSRecord record) cdef _async_remove(self, DNSRecord record) + @cython.locals( + record=DNSRecord, + ) + cdef _async_mark_unique_records_older_than_1s_to_expire(self, object unique_types, object answers, object now) cdef _dns_record_matches(DNSRecord record, object key, object type_, object class_) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 49f92f911..505143b3f 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -21,7 +21,7 @@ """ import itertools -from typing import Dict, Iterable, Iterator, List, Optional, Union, cast +from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast from ._dns import ( DNSAddress, @@ -34,13 +34,15 @@ DNSText, ) from ._utils.time import current_time_millis -from .const import _TYPE_PTR +from .const import _ONE_SECOND, _TYPE_PTR _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) _UniqueRecordsType = Union[DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService] _DNSRecordCacheType = Dict[str, Dict[DNSRecord, DNSRecord]] _DNSRecord = DNSRecord _str = str +_float = float +_int = int def _remove_key(cache: _DNSRecordCacheType, key: _str, record: _DNSRecord) -> None: @@ -134,19 +136,29 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details(self, name: _str, type_: int, class_: int) -> Iterator[DNSRecord]: + def async_all_by_details(self, name: _str, type_: int, class_: int) -> Iterable[DNSRecord]: """Gets all matching entries by details. - This function is not threadsafe and must be called from + This function is not thread-safe and must be called from + the event loop. + """ + return self._async_all_by_details(name, type_, class_) + + def _async_all_by_details(self, name: _str, type_: int, class_: int) -> List[DNSRecord]: + """Gets all matching entries by details. + + This function is not thread-safe and must be called from the event loop. """ key = name.lower() records = self.cache.get(key) + matches: List[DNSRecord] = [] if records is None: - return - for entry in records: - if _dns_record_matches(entry, key, type_, class_): - yield entry + return matches + for record in records: + if _dns_record_matches(record, key, type_, class_): + matches.append(record) + return matches def async_entries_with_name(self, name: str) -> Dict[DNSRecord, DNSRecord]: """Returns a dict of entries whose key matches the name. @@ -226,6 +238,25 @@ def names(self) -> List[str]: """Return a copy of the list of current cache names.""" return list(self.cache) + def async_mark_unique_records_older_than_1s_to_expire( + self, unique_types: Set[Tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float + ) -> None: + self._async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) + + def _async_mark_unique_records_older_than_1s_to_expire( + self, unique_types: Set[Tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float + ) -> None: + # rfc6762#section-10.2 para 2 + # Since unique is set, all old records with that name, rrtype, + # and rrclass that were received more than one second ago are declared + # invalid, and marked to expire from the cache in one second. + answers_rrset = set(answers) + for name, type_, class_ in unique_types: + for record in self._async_all_by_details(name, type_, class_): + if (now - record.created > _ONE_SECOND) and record not in answers_rrset: + # Expire in 1s + record.set_created_ttl(now, 1) + def _dns_record_matches(record: _DNSRecord, key: _str, type_: int, class_: int) -> bool: return key == record.key and type_ == record.type and class_ == record.class_ diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 38a1b034b..fb5ed7c71 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -26,7 +26,6 @@ from typing import ( TYPE_CHECKING, Dict, - Iterable, List, NamedTuple, Optional, @@ -421,7 +420,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - self._async_mark_unique_cached_records_older_than_1s_to_expire(unique_types, msg.answers, now) + self.cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) if updates: self.async_updates(now, updates) @@ -451,20 +450,6 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: if updates: self.async_updates_complete(new) - def _async_mark_unique_cached_records_older_than_1s_to_expire( - self, unique_types: Set[Tuple[str, int, int]], answers: Iterable[DNSRecord], now: float - ) -> None: - # rfc6762#section-10.2 para 2 - # Since unique is set, all old records with that name, rrtype, - # and rrclass that were received more than one second ago are declared - # invalid, and marked to expire from the cache in one second. - answers_rrset = set(answers) - for name, type_, class_ in unique_types: - for entry in self.cache.async_all_by_details(name, type_, class_): - if (now - entry.created > _ONE_SECOND) and entry not in answers_rrset: - # Expire in 1s - entry.set_created_ttl(now, 1) - def async_add_listener( self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] ) -> None: From dbd8018166ce22be4b550e3ff67ffdcce961c6d3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 5 Jun 2023 00:23:52 +0000 Subject: [PATCH 0830/1433] 0.64.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0540ede7..d472c628b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.64.0 (2023-06-05) +### Feature +* Speed up processing incoming records ([#1179](https://github.com/python-zeroconf/python-zeroconf/issues/1179)) ([`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) + +### Fix +* Always answer QU questions when the exact same packet is received from different sources in sequence ([#1178](https://github.com/python-zeroconf/python-zeroconf/issues/1178)) ([`74d7ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/74d7ba1aeeae56be087ee8142ee6ca1219744baa)) + ## v0.63.0 (2023-05-25) ### Feature * Small speed up to fetch dns addresses from ServiceInfo ([#1176](https://github.com/python-zeroconf/python-zeroconf/issues/1176)) ([`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) diff --git a/pyproject.toml b/pyproject.toml index 2b9fd56ac..f09641613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.63.0" +version = "0.64.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0b8978387..6468b9627 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.63.0' +__version__ = '0.64.0' __license__ = 'LGPL' From f03e511f7aae72c5ccd4f7514d89e168847bd7a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jun 2023 09:40:54 -0500 Subject: [PATCH 0831/1433] fix: small internal typing cleanups (#1180) --- src/zeroconf/_handlers.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index fb5ed7c71..5cb192de1 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -193,7 +193,9 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: SHOULD instead multicast the response so as to keep all the peer caches up to date """ - maybe_entry = self._cache.async_get_unique(cast(_UniqueRecordsType, record)) + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + maybe_entry = self._cache.async_get_unique(record) return bool(maybe_entry and maybe_entry.is_recent(self._now)) def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: @@ -201,7 +203,9 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: Protect the network against excessive packet flooding https://datatracker.ietf.org/doc/html/rfc6762#section-14 """ - maybe_entry = self._cache.async_get_unique(cast(_UniqueRecordsType, record)) + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + maybe_entry = self._cache.async_get_unique(record) return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) @@ -403,7 +407,10 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record.type, record.class_)) - maybe_entry = self.cache.async_get_unique(cast(_UniqueRecordsType, record)) + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + + maybe_entry = self.cache.async_get_unique(record) if not record.is_expired(now): if maybe_entry is not None: maybe_entry.reset_ttl(record) From 8b44947f9e87dd5ac277c3f1e4facbd97e9bc216 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 5 Jun 2023 14:49:16 +0000 Subject: [PATCH 0832/1433] 0.64.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d472c628b..0e060f4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.64.1 (2023-06-05) +### Fix +* Small internal typing cleanups ([#1180](https://github.com/python-zeroconf/python-zeroconf/issues/1180)) ([`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) + ## v0.64.0 (2023-06-05) ### Feature * Speed up processing incoming records ([#1179](https://github.com/python-zeroconf/python-zeroconf/issues/1179)) ([`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) diff --git a/pyproject.toml b/pyproject.toml index f09641613..3cc631a52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.64.0" +version = "0.64.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6468b9627..462c08556 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.64.0' +__version__ = '0.64.1' __license__ = 'LGPL' From 6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 14:18:25 -1000 Subject: [PATCH 0833/1433] feat: reduce overhead to enumerate ip addresses in ServiceInfo (#1181) --- src/zeroconf/_services/info.py | 37 +++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index d5b8408d8..f7d33b673 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -63,6 +63,8 @@ _TYPE_TXT, ) +_IPVersion_All_value = IPVersion.All.value +_IPVersion_V4Only_value = IPVersion.V4Only.value # https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 # The most common case for calling ServiceInfo is from a # ServiceBrowser. After the first request we add a few random @@ -245,14 +247,15 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: This means the first address will always be the most recently added address of the given IP version. """ - if version == IPVersion.V4Only: + version_value = version.value + if version_value == _IPVersion_All_value: + return [ + *(addr.packed for addr in self._ipv4_addresses), + *(addr.packed for addr in self._ipv6_addresses), + ] + if version_value == _IPVersion_V4Only_value: return [addr.packed for addr in self._ipv4_addresses] - if version == IPVersion.V6Only: - return [addr.packed for addr in self._ipv6_addresses] - return [ - *(addr.packed for addr in self._ipv4_addresses), - *(addr.packed for addr in self._ipv6_addresses), - ] + return [addr.packed for addr in self._ipv6_addresses] def ip_addresses_by_version( self, version: IPVersion @@ -265,11 +268,17 @@ def ip_addresses_by_version( This means the first address will always be the most recently added address of the given IP version. """ - if version == IPVersion.V4Only: + return self._ip_addresses_by_version_value(version.value) + + def _ip_addresses_by_version_value( + self, version_value: int + ) -> Union[List[ipaddress.IPv4Address], List[ipaddress.IPv6Address], List[ipaddress._BaseAddress]]: + """Backend for addresses_by_version that uses the raw value.""" + if version_value == _IPVersion_All_value: + return [*self._ipv4_addresses, *self._ipv6_addresses] + if version_value == _IPVersion_V4Only_value: return self._ipv4_addresses - if version == IPVersion.V6Only: - return self._ipv6_addresses - return [*self._ipv4_addresses, *self._ipv6_addresses] + return self._ipv6_addresses def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """List addresses in their parsed string form. @@ -280,7 +289,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: This means the first address will always be the most recently added address of the given IP version. """ - return [str(addr) for addr in self.ip_addresses_by_version(version)] + return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local @@ -296,7 +305,7 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st return self.parsed_addresses(version) return [ f"{addr}%{self.interface_index}" if addr.version == 6 and addr.is_link_local else str(addr) - for addr in self.ip_addresses_by_version(version) + for addr in self._ip_addresses_by_version_value(version.value) ] def _set_properties(self, properties: Dict) -> None: @@ -494,7 +503,7 @@ def dns_addresses( address.packed, created=created, ) - for address in self.ip_addresses_by_version(version) + for address in self._ip_addresses_by_version_value(version.value) ] def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: From efa1e452bfee5343573e0f48ba69989d943d975d Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 13 Jun 2023 00:26:07 +0000 Subject: [PATCH 0834/1433] 0.65.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e060f4f3..288f0d46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.65.0 (2023-06-13) +### Feature +* Reduce overhead to enumerate ip addresses in ServiceInfo ([#1181](https://github.com/python-zeroconf/python-zeroconf/issues/1181)) ([`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) + ## v0.64.1 (2023-06-05) ### Fix * Small internal typing cleanups ([#1180](https://github.com/python-zeroconf/python-zeroconf/issues/1180)) ([`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) diff --git a/pyproject.toml b/pyproject.toml index 3cc631a52..b3cf6ef3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.64.1" +version = "0.65.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 462c08556..7109fbfa7 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.64.1' +__version__ = '0.65.0' __license__ = 'LGPL' From fc0341f281cdb71428c0f1cf90c12d34cbb4acae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jun 2023 15:15:38 -1000 Subject: [PATCH 0835/1433] feat: optimize construction of outgoing dns records (#1182) --- build_ext.py | 2 +- src/zeroconf/_protocol/outgoing.pxd | 16 +++++++++++++++- src/zeroconf/_protocol/outgoing.py | 19 +++++++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/build_ext.py b/build_ext.py index 430ad9f1e..0020f9fe6 100644 --- a/build_ext.py +++ b/build_ext.py @@ -23,8 +23,8 @@ def build(setup_kwargs: Any) -> None: dict( ext_modules=cythonize( [ - "src/zeroconf/_cache.py", "src/zeroconf/_dns.py", + "src/zeroconf/_cache.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", ], diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 061fbfade..e7da04b3b 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -1,6 +1,7 @@ import cython +from .._dns cimport DNSEntry, DNSQuestion, DNSRecord from .incoming cimport DNSIncoming @@ -41,7 +42,14 @@ cdef class DNSOutgoing: cdef _write_int(self, object value) - cdef _write_question(self, object question) + cdef _write_question(self, DNSQuestion question) + + @cython.locals( + d=cython.bytes, + data_view=cython.list, + length=cython.uint + ) + cdef _write_record(self, DNSRecord record, object now) cdef _write_record_class(self, object record) @@ -55,6 +63,12 @@ cdef class DNSOutgoing: cdef _has_more_to_add(self, object questions_offset, object answer_offset, object authority_offset, object additional_offset) + cdef _write_ttl(self, DNSRecord record, object now) + + cpdef write_name(self, object name) + + cpdef write_short(self, object value) + @cython.locals( questions_offset=cython.uint, answer_offset=cython.uint, diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 46d7434cd..630907115 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -40,6 +40,11 @@ ) from .incoming import DNSIncoming +str_ = str +float_ = float +DNSQuestion_ = DNSQuestion +DNSRecord_ = DNSRecord + class State(enum.Enum): init = 0 @@ -238,7 +243,7 @@ def write_character_string(self, value: bytes) -> None: self._write_byte(length) self.write_string(value) - def write_name(self, name: str) -> None: + def write_name(self, name: str_) -> None: """ Write names to packet @@ -276,7 +281,7 @@ def write_name(self, name: str) -> None: # this is the end of a name self._write_byte(0) - def _write_question(self, question: DNSQuestion) -> bool: + def _write_question(self, question: DNSQuestion_) -> bool: """Writes a question to the packet""" start_data_length, start_size = len(self.data), self.size self.write_name(question.name) @@ -284,18 +289,18 @@ def _write_question(self, question: DNSQuestion) -> bool: self._write_record_class(question) return self._check_data_limit_or_rollback(start_data_length, start_size) - def _write_record_class(self, record: Union[DNSQuestion, DNSRecord]) -> None: + def _write_record_class(self, record: Union[DNSQuestion_, DNSRecord_]) -> None: """Write out the record class including the unique/unicast (QU) bit.""" if record.unique and self.multicast: self.write_short(record.class_ | _CLASS_UNIQUE) else: self.write_short(record.class_) - def _write_ttl(self, record: DNSRecord, now: float) -> None: + def _write_ttl(self, record: DNSRecord_, now: float_) -> None: """Write out the record ttl.""" self._write_int(record.ttl if now == 0 else record.get_remaining_ttl(now)) - def _write_record(self, record: DNSRecord, now: float) -> bool: + def _write_record(self, record: DNSRecord_, now: float_) -> bool: """Writes a record (answer, authoritative answer, additional) to the packet. Returns True on success, or False if we did not because the packet because the record does not fit.""" @@ -308,7 +313,9 @@ def _write_record(self, record: DNSRecord, now: float) -> bool: self.write_short(0) # Will get replaced with the actual size record.write(self) # Adjust size for the short we will write before this record - length = sum(len(d) for d in self.data[index + 1 :]) + length = 0 + for d in self.data[index + 1 :]: + length += len(d) # Here we replace the 0 length short we wrote # before with the actual length self._replace_short(index, length) From 8cf5b876e22a1317fa94eff15cd3f0f7d9181525 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 13 Jun 2023 01:24:09 +0000 Subject: [PATCH 0836/1433] 0.66.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288f0d46f..ee3232c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +## v0.66.0 (2023-06-13) +### Feature +* Optimize construction of outgoing dns records ([#1182](https://github.com/python-zeroconf/python-zeroconf/issues/1182)) ([`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) + ## v0.65.0 (2023-06-13) ### Feature * Reduce overhead to enumerate ip addresses in ServiceInfo ([#1181](https://github.com/python-zeroconf/python-zeroconf/issues/1181)) ([`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) diff --git a/pyproject.toml b/pyproject.toml index b3cf6ef3f..1477effc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.65.0" +version = "0.66.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7109fbfa7..76b07c190 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.65.0' +__version__ = '0.66.0' __license__ = 'LGPL' From 8f376658d2a3bef0353646e6fddfda15626b73a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 12:37:05 -0500 Subject: [PATCH 0837/1433] feat: speed up answering incoming questions (#1186) --- src/zeroconf/_dns.pxd | 7 +++++-- src/zeroconf/_dns.py | 9 ++++++--- src/zeroconf/_handlers.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index b28f73237..cd4f1f9e3 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -98,11 +98,14 @@ cdef class DNSNsec(DNSRecord): cdef class DNSRRSet: - cdef _record_sets + cdef cython.list _record_sets cdef cython.dict _lookup @cython.locals(other=DNSRecord) cpdef suppresses(self, DNSRecord record) - @cython.locals(lookup=cython.dict) + @cython.locals( + record=DNSRecord, + record_sets=cython.list, + ) cdef cython.dict _get_lookup(self) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index ada6e9df4..34d7fdb24 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -22,7 +22,7 @@ import enum import socket -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -516,7 +516,7 @@ class DNSRRSet: __slots__ = ('_record_sets', '_lookup') - def __init__(self, record_sets: Iterable[List[DNSRecord]]) -> None: + def __init__(self, record_sets: List[List[DNSRecord]]) -> None: """Create an RRset from records sets.""" self._record_sets = record_sets self._lookup: Optional[Dict[DNSRecord, float]] = None @@ -530,7 +530,10 @@ def _get_lookup(self) -> Dict[DNSRecord, float]: """Return the lookup table, building it if needed.""" if self._lookup is None: # Build the hash table so we can lookup the record ttl - self._lookup = {record: record.ttl for record_sets in self._record_sets for record in record_sets} + self._lookup = {} + for record_sets in self._record_sets: + for record in record_sets: + self._lookup[record] = record.ttl return self._lookup def suppresses(self, record: _DNSRecord) -> bool: diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 5cb192de1..bf1f6acb7 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -322,7 +322,7 @@ def async_response( # pylint: disable=unused-argument This function must be run in the event loop as it is not threadsafe. """ - known_answers = DNSRRSet(msg.answers for msg in msgs if not msg.is_probe) + known_answers = DNSRRSet([msg.answers for msg in msgs if not msg.is_probe]) query_res = _QueryResponse(self.cache, msgs) for msg in msgs: From 9ecce3ae74d7fa67383b14460c6dd95bb1fe8078 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 13:15:19 -0500 Subject: [PATCH 0838/1433] chore: bump python-semantic-release to fix release process (#1187) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index addd97502..d46552da3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: # - Create GitHub release # - Publish to PyPI - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.33.1 + uses: relekang/python-semantic-release@v7.34.6 # env: # REPOSITORY_URL: https://test.pypi.org/legacy/ # TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ From 1a1036def27322b83e161b2b644ac98050f1fe4e Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 17 Jun 2023 18:26:40 +0000 Subject: [PATCH 0839/1433] 0.67.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3232c67..ea3e46bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.67.0 (2023-06-17) + +### Feature + +* Speed up answering incoming questions ([#1186](https://github.com/python-zeroconf/python-zeroconf/issues/1186)) ([`8f37665`](https://github.com/python-zeroconf/python-zeroconf/commit/8f376658d2a3bef0353646e6fddfda15626b73a9)) + ## v0.66.0 (2023-06-13) ### Feature * Optimize construction of outgoing dns records ([#1182](https://github.com/python-zeroconf/python-zeroconf/issues/1182)) ([`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) diff --git a/pyproject.toml b/pyproject.toml index 1477effc9..6b5db968e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.66.0" +version = "0.67.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 76b07c190..893dbee03 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.66.0' +__version__ = '0.67.0' __license__ = 'LGPL' From 81126b7600f94848ef8c58b70bac0c6ab993c6ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Jun 2023 13:44:35 -0500 Subject: [PATCH 0840/1433] feat: reduce overhead to handle queries and responses (#1184) - adds slots to handler classes - avoid any expression overhead and inline instead --- src/zeroconf/_handlers.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index bf1f6acb7..192496c17 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -124,9 +124,25 @@ def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsTy class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" + __slots__ = ( + "_is_probe", + "_msg", + "_now", + "_cache", + "_additionals", + "_ucast", + "_mcast_now", + "_mcast_aggregate", + "_mcast_aggregate_last_second", + ) + def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: """Build a query response.""" - self._is_probe = any(msg.is_probe for msg in msgs) + self._is_probe = False + for msg in msgs: + if msg.is_probe: + self._is_probe = True + break self._msg = msgs[0] self._now = self._msg.now self._cache = cache @@ -212,6 +228,8 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" + __slots__ = ("registry", "cache", "question_history") + def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: """Init the query handler.""" self.registry = registry @@ -345,6 +363,8 @@ def async_response( # pylint: disable=unused-argument class RecordManager: """Process records into the cache and notify listeners.""" + __slots__ = ("zc", "cache", "listeners") + def __init__(self, zeroconf: 'Zeroconf') -> None: """Init the record manager.""" self.zc = zeroconf @@ -516,6 +536,8 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" + __slots__ = ("zc", "queue", "additional_delay", "aggregation_delay") + def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: self.zc = zeroconf self.queue: deque = deque() From 9ee301957ee1ffbcb614f24f9b3c658265471a82 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 17 Jun 2023 18:52:40 +0000 Subject: [PATCH 0841/1433] 0.68.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3e46bd9..bf03d3bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.68.0 (2023-06-17) + +### Feature + +* Reduce overhead to handle queries and responses ([#1184](https://github.com/python-zeroconf/python-zeroconf/issues/1184)) ([`81126b7`](https://github.com/python-zeroconf/python-zeroconf/commit/81126b7600f94848ef8c58b70bac0c6ab993c6ae)) + ## v0.67.0 (2023-06-17) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 6b5db968e..2df873377 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.67.0" +version = "0.68.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 893dbee03..b07975631 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.67.0' +__version__ = '0.68.0' __license__ = 'LGPL' From ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jun 2023 15:16:15 -0500 Subject: [PATCH 0842/1433] fix: reduce debug logging overhead by adding missing checks to datagram_received (#1188) --- src/zeroconf/_core.py | 16 +++++++++------- tests/test_core.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index b29fc7905..7067466f9 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -282,18 +282,20 @@ def datagram_received( else: # https://github.com/python/mypy/issues/1178 addr, port, flow, scope = addrs # type: ignore - log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + if debug: + log.debug('IPv6 scope_id %d associated to the receiving interface', scope) v6_flow_scope = (flow, scope) if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm # the system. - log.debug( - "Discarding incoming packet with length %s, which is larger " - "than the absolute maximum size of %s", - data_len, - _MAX_MSG_ABSOLUTE, - ) + if debug: + log.debug( + "Discarding incoming packet with length %s, which is larger " + "than the absolute maximum size of %s", + data_len, + _MAX_MSG_ABSOLUTE, + ) return now = current_time_millis() diff --git a/tests/test_core.py b/tests/test_core.py index 93e07b0ae..ad1163284 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -761,6 +761,22 @@ def test_guard_against_oversized_packets(): is None ) + logging.getLogger('zeroconf').setLevel(logging.INFO) + + listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + zc.close() From 8cca755818fbb7c9a1a7b212fba29a15aa496fc7 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Jun 2023 20:28:21 +0000 Subject: [PATCH 0843/1433] 0.68.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf03d3bc6..210dc4784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.68.1 (2023-06-18) + +### Fix + +* Reduce debug logging overhead by adding missing checks to datagram_received ([#1188](https://github.com/python-zeroconf/python-zeroconf/issues/1188)) ([`ac5c50a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3)) + ## v0.68.0 (2023-06-17) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 2df873377..f5e00bee4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.68.0" +version = "0.68.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b07975631..6601fa6f3 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.68.0' +__version__ = '0.68.1' __license__ = 'LGPL' From 32756ff113f675b7a9cf16d3c0ab840ba733e5e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jun 2023 15:29:31 -0500 Subject: [PATCH 0844/1433] feat: reorder incoming data handler to reduce overhead (#1189) --- src/zeroconf/_core.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 7067466f9..1f74dcd5e 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -271,21 +271,9 @@ def datagram_received( self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: assert self.transport is not None - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () data_len = len(data) debug = log.isEnabledFor(logging.DEBUG) - if len(addrs) == 2: - # https://github.com/python/mypy/issues/1178 - addr, port = addrs # type: ignore - scope = None - else: - # https://github.com/python/mypy/issues/1178 - addr, port, flow, scope = addrs # type: ignore - if debug: - log.debug('IPv6 scope_id %d associated to the receiving interface', scope) - v6_flow_scope = (flow, scope) - if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm # the system. @@ -308,15 +296,26 @@ def datagram_received( # Guard against duplicate packets if debug: log.debug( - 'Ignoring duplicate message with no unicast questions received from %r:%r [socket %s] (%d bytes) as [%r]', - addr, - port, + 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', + addrs, self.sock_description, data_len, data, ) return + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + if len(addrs) == 2: + # https://github.com/python/mypy/issues/1178 + addr, port = addrs # type: ignore + scope = None + else: + # https://github.com/python/mypy/issues/1178 + addr, port, flow, scope = addrs # type: ignore + if debug: + log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + v6_flow_scope = (flow, scope) + msg = DNSIncoming(data, (addr, port), scope, now) self.data = data self.last_time = now From 8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jun 2023 15:30:16 -0500 Subject: [PATCH 0845/1433] feat: cython3 support (#1190) --- src/zeroconf/_cache.py | 4 ++-- src/zeroconf/_protocol/incoming.py | 9 +++++---- src/zeroconf/_protocol/outgoing.py | 19 ++++++++++--------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 505143b3f..ad339cd50 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -144,7 +144,7 @@ def async_all_by_details(self, name: _str, type_: int, class_: int) -> Iterable[ """ return self._async_all_by_details(name, type_, class_) - def _async_all_by_details(self, name: _str, type_: int, class_: int) -> List[DNSRecord]: + def _async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: """Gets all matching entries by details. This function is not thread-safe and must be called from @@ -258,5 +258,5 @@ def _async_mark_unique_records_older_than_1s_to_expire( record.set_created_ttl(now, 1) -def _dns_record_matches(record: _DNSRecord, key: _str, type_: int, class_: int) -> bool: +def _dns_record_matches(record: _DNSRecord, key: _str, type_: _int, class_: _int) -> bool: return key == record.key and type_ == record.type and class_ == record.class_ diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index e82ddd367..352a61410 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -67,6 +67,7 @@ _seen_logs: Dict[str, Union[int, tuple]] = {} _str = str +_int = int class DNSIncoming: @@ -231,7 +232,7 @@ def _read_character_string(self) -> bytes: self.offset += 1 return self._read_string(length) - def _read_string(self, length: int) -> bytes: + def _read_string(self, length: _int) -> bytes: """Reads a string of a given length from the packet""" info = self.data[self.offset : self.offset + length] self.offset += length @@ -267,7 +268,7 @@ def _read_others(self) -> None: self._answers.append(rec) def _read_record( - self, domain: _str, type_: int, class_: int, ttl: int, length: int + self, domain: _str, type_: _int, class_: _int, ttl: _int, length: _int ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: @@ -324,7 +325,7 @@ def _read_record( self.offset += length return None - def _read_bitmap(self, end: int) -> List[int]: + def _read_bitmap(self, end: _int) -> List[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] while self.offset < end: @@ -355,7 +356,7 @@ def _read_name(self) -> str: ) return name - def _decode_labels_at_offset(self, off: int, labels: List[str], seen_pointers: Set[int]) -> int: + def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. while off < self._data_len: length = self.data[off] diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 630907115..e13750f6f 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -42,6 +42,7 @@ str_ = str float_ = float +int_ = int DNSQuestion_ = DNSQuestion DNSRecord_ = DNSRecord @@ -197,20 +198,20 @@ def add_question_or_all_cache( for cached_entry in cached_entries: self.add_answer_at_time(cached_entry, now) - def _write_byte(self, value: int) -> None: + def _write_byte(self, value: int_) -> None: """Writes a single byte to the packet""" self.data.append(value.to_bytes(1, 'big')) self.size += 1 - def _insert_short_at_start(self, value: int) -> None: + def _insert_short_at_start(self, value: int_) -> None: """Inserts an unsigned short at the start of the packet""" self.data.insert(0, value.to_bytes(2, 'big')) - def _replace_short(self, index: int, value: int) -> None: + def _replace_short(self, index: int_, value: int_) -> None: """Replaces an unsigned short in a certain position in the packet""" self.data[index] = value.to_bytes(2, 'big') - def write_short(self, value: int) -> None: + def write_short(self, value: int_) -> None: """Writes an unsigned short to the packet""" self.data.append(value.to_bytes(2, 'big')) self.size += 2 @@ -321,7 +322,7 @@ def _write_record(self, record: DNSRecord_, now: float_) -> bool: self._replace_short(index, length) return self._check_data_limit_or_rollback(start_data_length, start_size) - def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) -> bool: + def _check_data_limit_or_rollback(self, start_data_length: int_, start_size: int_) -> bool: """Check data limit, if we go over, then rollback and return False.""" len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL self.allow_long = False @@ -338,7 +339,7 @@ def _check_data_limit_or_rollback(self, start_data_length: int, start_size: int) del self.names[name] return False - def _write_questions_from_offset(self, questions_offset: int) -> int: + def _write_questions_from_offset(self, questions_offset: int_) -> int: questions_written = 0 for question in self.questions[questions_offset:]: if not self._write_question(question): @@ -346,7 +347,7 @@ def _write_questions_from_offset(self, questions_offset: int) -> int: questions_written += 1 return questions_written - def _write_answers_from_offset(self, answer_offset: int) -> int: + def _write_answers_from_offset(self, answer_offset: int_) -> int: answers_written = 0 for answer, time_ in self.answers[answer_offset:]: if not self._write_record(answer, time_): @@ -354,7 +355,7 @@ def _write_answers_from_offset(self, answer_offset: int) -> int: answers_written += 1 return answers_written - def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int) -> int: + def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int_) -> int: records_written = 0 for record in records[offset:]: if not self._write_record(record, 0): @@ -363,7 +364,7 @@ def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int) return records_written def _has_more_to_add( - self, questions_offset: int, answer_offset: int, authority_offset: int, additional_offset: int + self, questions_offset: int_, answer_offset: int_, authority_offset: int_, additional_offset: int_ ) -> bool: """Check if all questions, answers, authority, and additionals have been written to the packet.""" return ( From d713a458daf6a57eea934b384cc9e534b33fd334 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 18 Jun 2023 20:43:33 +0000 Subject: [PATCH 0846/1433] 0.69.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 210dc4784..ad12035d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.69.0 (2023-06-18) + +### Feature + +* Cython3 support ([#1190](https://github.com/python-zeroconf/python-zeroconf/issues/1190)) ([`8ae8ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d)) +* Reorder incoming data handler to reduce overhead ([#1189](https://github.com/python-zeroconf/python-zeroconf/issues/1189)) ([`32756ff`](https://github.com/python-zeroconf/python-zeroconf/commit/32756ff113f675b7a9cf16d3c0ab840ba733e5e4)) + ## v0.68.1 (2023-06-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index f5e00bee4..4c03424b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.68.1" +version = "0.69.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6601fa6f3..b1d3f92e9 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.68.1' +__version__ = '0.69.0' __license__ = 'LGPL' From 405f54762d3f61e97de9c1787e837e953de31412 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Jul 2023 09:33:06 -0500 Subject: [PATCH 0847/1433] feat: add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and `ServiceInfo.request` (#1192) --- src/zeroconf/_services/info.py | 30 ++++++++++-- tests/services/test_info.py | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index f7d33b673..8ff1f6656 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -55,6 +55,7 @@ _DNS_OTHER_TTL, _FLAGS_QR_QUERY, _LISTENER_TIME, + _MDNS_PORT, _TYPE_A, _TYPE_AAAA, _TYPE_NSEC, @@ -616,7 +617,12 @@ def _is_complete(self) -> bool: return bool(self.text is not None and (self._ipv4_addresses or self._ipv6_addresses)) def request( - self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None + self, + zc: 'Zeroconf', + timeout: float, + question_type: Optional[DNSQuestionType] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, ) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. @@ -628,13 +634,29 @@ def request( assert zc.loop is not None and zc.loop.is_running() if zc.loop == get_running_loop(): raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") - return bool(run_coro_with_timeout(self.async_request(zc, timeout, question_type), zc.loop, timeout)) + return bool( + run_coro_with_timeout( + self.async_request(zc, timeout, question_type, addr, port), zc.loop, timeout + ) + ) async def async_request( - self, zc: 'Zeroconf', timeout: float, question_type: Optional[DNSQuestionType] = None + self, + zc: 'Zeroconf', + timeout: float, + question_type: Optional[DNSQuestionType] = None, + addr: Optional[str] = None, + port: int = _MDNS_PORT, ) -> bool: """Returns true if the service could be discovered on the network, and updates this object with details discovered. + + This method will be run in the event loop. + + Passing addr and port is optional, and will default to the + mDNS multicast address and port. This is useful for directing + requests to a specific host that may be able to respond across + subnets. """ if not zc.started: await zc.async_wait_for_start() @@ -658,7 +680,7 @@ async def async_request( first_request = False if not out.questions: return self.load_from_cache(zc) - zc.async_send(out) + zc.async_send(out, addr, port) next_ = now + delay delay *= 2 next_ += random.randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index f24baa8f2..13f48392b 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -956,6 +956,89 @@ async def test_port_changes_are_seen(): await aiozc.async_close() +@pytest.mark.asyncio +async def test_port_changes_are_seen_with_directed_request(): + """Test that port changes are seen by async_request with a directed request.""" + type_ = "_http._tcp.local." + registration_name = "multiarec.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahost.local." + + # New kwarg way + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await aiozc.zeroconf.async_wait_for_start() + await asyncio.sleep(0) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 90, + 90, + 81, + host, + ), + 0, + ) + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + + info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) + await info.async_request(aiozc.zeroconf, timeout=200, addr="127.0.0.1", port=5353) + assert info.port == 81 + assert info.priority == 90 + assert info.weight == 90 + await aiozc.async_close() + + @pytest.mark.asyncio async def test_ipv4_changes_are_seen(): """Test that ipv4 changes are seen by async_request.""" From f0577f080eca5071aea84526f24168e747578e97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Jul 2023 09:44:12 -0500 Subject: [PATCH 0848/1433] chore: fix release (#1193) From 84872bf6869cef13ddc23758f40bbd43eb91d2f3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 2 Jul 2023 14:53:38 +0000 Subject: [PATCH 0849/1433] 0.70.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad12035d5..d286797b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.70.0 (2023-07-02) + +### Feature + +* Add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and `ServiceInfo.request` ([#1192](https://github.com/python-zeroconf/python-zeroconf/issues/1192)) ([`405f547`](https://github.com/python-zeroconf/python-zeroconf/commit/405f54762d3f61e97de9c1787e837e953de31412)) + ## v0.69.0 (2023-06-18) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 4c03424b2..694a59790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.69.0" +version = "0.70.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b1d3f92e9..404220974 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.69.0' +__version__ = '0.70.0' __license__ = 'LGPL' From a56c776008ef86f99db78f5997e45a57551be725 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Jul 2023 12:54:43 -1000 Subject: [PATCH 0850/1433] feat: improve incoming data processing performance (#1194) --- src/zeroconf/_core.py | 12 ++++++++++++ src/zeroconf/_services/__init__.py | 6 ++++++ src/zeroconf/_services/browser.py | 22 +++++++++++++++++++++- src/zeroconf/_services/registry.py | 2 ++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 1f74dcd5e..ab8e72e52 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -151,6 +151,18 @@ def _make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTra class AsyncEngine: """An engine wraps sockets in the event loop.""" + __slots__ = ( + 'loop', + 'zc', + 'protocols', + 'readers', + 'senders', + 'running_event', + '_listen_socket', + '_respond_sockets', + '_cleanup_timer', + ) + def __init__( self, zeroconf: 'Zeroconf', diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 2882fce6a..968b5dafe 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -46,6 +46,9 @@ def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: class Signal: + + __slots__ = ('_handlers',) + def __init__(self) -> None: self._handlers: List[Callable[..., None]] = [] @@ -59,6 +62,9 @@ def registration_interface(self) -> 'SignalRegistrationInterface': class SignalRegistrationInterface: + + __slots__ = ('_handlers',) + def __init__(self, handlers: List[Callable[..., None]]) -> None: self._handlers = handlers diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index fef49383d..a76e98172 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -85,6 +85,8 @@ class _DNSPointerOutgoingBucket: """A DNSOutgoing bucket.""" + __slots__ = ('now', 'out', 'bytes') + def __init__(self, now: float, multicast: bool) -> None: """Create a bucke to wrap a DNSOutgoing.""" self.now = now @@ -196,12 +198,14 @@ class QueryScheduler: """ + __slots__ = ('_schedule_changed_event', '_types', '_next_time', '_first_random_delay_interval', '_delay') + def __init__( self, types: Set[str], delay: int, first_random_delay_interval: Tuple[int, int], - ): + ) -> None: self._schedule_changed_event: Optional[asyncio.Event] = None self._types = types self._next_time: Dict[str, float] = {} @@ -261,6 +265,22 @@ def process_ready_types(self, now: float) -> List[str]: class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" + __slots__ = ( + 'types', + 'zc', + 'addr', + 'port', + 'multicast', + 'question_type', + '_pending_handlers', + '_service_state_changed', + 'query_scheduler', + 'done', + '_first_request', + '_next_send_timer', + '_query_sender_task', + ) + def __init__( self, zc: 'Zeroconf', diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 1c4ad0859..fd2ad5cee 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -33,6 +33,8 @@ class ServiceRegistry: the event loop as it is not thread safe. """ + __slots__ = ("_services", "types", "servers") + def __init__( self, ) -> None: From 109bbe1af010a4aecf12b5b5d697581479af88eb Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 8 Jul 2023 23:02:16 +0000 Subject: [PATCH 0851/1433] 0.71.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d286797b8..c865c949a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.0 (2023-07-08) + +### Feature + +* Improve incoming data processing performance ([#1194](https://github.com/python-zeroconf/python-zeroconf/issues/1194)) ([`a56c776`](https://github.com/python-zeroconf/python-zeroconf/commit/a56c776008ef86f99db78f5997e45a57551be725)) + ## v0.70.0 (2023-07-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 694a59790..e76f855fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.70.0" +version = "0.71.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 404220974..1365c681e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.70.0' +__version__ = '0.71.0' __license__ = 'LGPL' From ac53adf7e71db14c1a0f9adbfd1d74033df36898 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Jul 2023 17:09:12 -0500 Subject: [PATCH 0852/1433] fix: add missing if TYPE_CHECKING guard to generate_service_query (#1198) --- src/zeroconf/_services/browser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index a76e98172..84185f158 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -166,7 +166,11 @@ def generate_service_query( if not qu_question and zc.question_history.suppresses(question, now, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue - questions_with_known_answers[question] = cast(Set[DNSPointer], known_answers) + if TYPE_CHECKING: + pointer_known_answers = cast(Set[DNSPointer], known_answers) + else: + pointer_known_answers = known_answers + questions_with_known_answers[question] = pointer_known_answers if not qu_question: zc.question_history.add_question_at_time(question, now, known_answers) From 49f1b32cd36d1c7c2e22636991eb6ca85515c679 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jul 2023 22:17:32 +0000 Subject: [PATCH 0853/1433] 0.71.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c865c949a..a639173ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.1 (2023-07-23) + +### Fix + +* Add missing if TYPE_CHECKING guard to generate_service_query ([#1198](https://github.com/python-zeroconf/python-zeroconf/issues/1198)) ([`ac53adf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac53adf7e71db14c1a0f9adbfd1d74033df36898)) + ## v0.71.0 (2023-07-08) ### Feature diff --git a/pyproject.toml b/pyproject.toml index e76f855fe..6112fd631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.0" +version = "0.71.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1365c681e..d29065c96 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.0' +__version__ = '0.71.1' __license__ = 'LGPL' From 8c3a4c80c221bea7401c12e1c6a525e75b7ffea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Jul 2023 18:02:26 -0500 Subject: [PATCH 0854/1433] fix: no change re-release to fix wheel builds (#1199) From 030d97a7e0dff4219d7414cb98712560846e03bd Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jul 2023 23:11:52 +0000 Subject: [PATCH 0855/1433] 0.71.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a639173ae..5fc57d158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.2 (2023-07-23) + +### Fix + +* No change re-release to fix wheel builds ([#1199](https://github.com/python-zeroconf/python-zeroconf/issues/1199)) ([`8c3a4c8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c3a4c80c221bea7401c12e1c6a525e75b7ffea2)) + ## v0.71.1 (2023-07-23) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 6112fd631..3966e60a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.1" +version = "0.71.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d29065c96..66fa45d9c 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.1' +__version__ = '0.71.2' __license__ = 'LGPL' From c145a238d768aa17c3aebe120c20a46bfbec6b99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Jul 2023 18:16:14 -0500 Subject: [PATCH 0856/1433] fix: pin python-semantic-release to fix release process (#1200) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d46552da3..c7598b269 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: uses: actions/setup-python@v4 - name: Install python-semantic-release - run: pipx install python-semantic-release + run: pipx install python-semantic-release==7.34.6 - name: Get Release Tag id: release_tag From 249395a6c42a8c4712e62852ec4cbe423111800c Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 23 Jul 2023 23:24:05 +0000 Subject: [PATCH 0857/1433] 0.71.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc57d158..fca089a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.3 (2023-07-23) + +### Fix + +* Pin python-semantic-release to fix release process ([#1200](https://github.com/python-zeroconf/python-zeroconf/issues/1200)) ([`c145a23`](https://github.com/python-zeroconf/python-zeroconf/commit/c145a238d768aa17c3aebe120c20a46bfbec6b99)) + ## v0.71.2 (2023-07-23) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3966e60a4..4f44812cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.2" +version = "0.71.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 66fa45d9c..5a8119e73 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.2' +__version__ = '0.71.3' __license__ = 'LGPL' From fed3dec88fd87c7a5a11bd2513f6c80d9967b15e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Jul 2023 10:52:35 -0500 Subject: [PATCH 0858/1433] chore: add cpython beta to CI (#1203) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7598b269..677dfad19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: - "3.9" - "3.10" - "3.11" + - "3.12.0-beta.4" - "pypy-3.7" os: - ubuntu-latest From b272d75abd982f3be1f4b20f683cac38011cc6f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Jul 2023 11:04:50 -0500 Subject: [PATCH 0859/1433] fix: cleanup naming from previous refactoring in ServiceInfo (#1202) --- src/zeroconf/_services/__init__.py | 2 - src/zeroconf/_services/info.py | 62 ++++++++++++++++-------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 968b5dafe..cf54d7f07 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -46,7 +46,6 @@ def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: class Signal: - __slots__ = ('_handlers',) def __init__(self) -> None: @@ -62,7 +61,6 @@ def registration_interface(self) -> 'SignalRegistrationInterface': class SignalRegistrationInterface: - __slots__ = ('_handlers',) def __init__(self, handlers: List[Callable[..., None]]) -> None: diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 8ff1f6656..d3e6f082b 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -21,9 +21,9 @@ """ import asyncio -import ipaddress import random from functools import lru_cache +from ipaddress import IPv4Address, IPv6Address, _BaseAddress, ip_address from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast from .._dns import ( @@ -90,7 +90,7 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: return info.name[: -len(service_name) - 1] -_cached_ip_addresses = lru_cache(maxsize=256)(ipaddress.ip_address) +_cached_ip_addresses = lru_cache(maxsize=256)(ip_address) class ServiceInfo(RecordUpdateListener): @@ -158,8 +158,8 @@ def __init__( self.type = type_ self._name = name self.key = name.lower() - self._ipv4_addresses: List[ipaddress.IPv4Address] = [] - self._ipv6_addresses: List[ipaddress.IPv6Address] = [] + self._ipv4_addresses: List[IPv4Address] = [] + self._ipv6_addresses: List[IPv6Address] = [] if addresses is not None: self.addresses = addresses elif parsed_addresses is not None: @@ -260,7 +260,7 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: def ip_addresses_by_version( self, version: IPVersion - ) -> Union[List[ipaddress.IPv4Address], List[ipaddress.IPv6Address], List[ipaddress._BaseAddress]]: + ) -> Union[List[IPv4Address], List[IPv6Address], List[_BaseAddress]]: """List ip_address objects matching IP version. Addresses are guaranteed to be returned in LIFO (last in, first out) @@ -273,7 +273,7 @@ def ip_addresses_by_version( def _ip_addresses_by_version_value( self, version_value: int - ) -> Union[List[ipaddress.IPv4Address], List[ipaddress.IPv6Address], List[ipaddress._BaseAddress]]: + ) -> Union[List[IPv4Address], List[IPv6Address], List[_BaseAddress]]: """Backend for addresses_by_version that uses the raw value.""" if version_value == _IPVersion_All_value: return [*self._ipv4_addresses, *self._ipv6_addresses] @@ -366,31 +366,31 @@ def get_name(self) -> str: def _get_ip_addresses_from_cache_lifo( self, zc: 'Zeroconf', now: float, type: int - ) -> List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: + ) -> List[Union[IPv4Address, IPv6Address]]: """Set IPv6 addresses from the cache.""" - address_list: List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]] = [] + address_list: List[Union[IPv4Address, IPv6Address]] = [] for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue try: - ip_address = _cached_ip_addresses(record.address) + ip_addr = _cached_ip_addresses(record.address) except ValueError: continue else: - address_list.append(ip_address) + address_list.append(ip_addr) address_list.reverse() # Reverse to get LIFO order return address_list def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: """Set IPv6 addresses from the cache.""" self._ipv6_addresses = cast( - "List[ipaddress.IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) + "List[IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) ) def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: """Set IPv4 addresses from the cache.""" self._ipv4_addresses = cast( - "List[ipaddress.IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) + "List[IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) ) def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: @@ -431,46 +431,49 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo if record.is_expired(now): return False - if record.key == self.server_key and isinstance(record, DNSAddress): + record_key = record.key + if record_key == self.server_key and type(record) is DNSAddress: try: ip_addr = _cached_ip_addresses(record.address) except ValueError as ex: log.warning("Encountered invalid address while processing %s: %s", record, ex) return False - if ip_addr.version == 4: - if not self._ipv4_addresses: + if type(ip_addr) is IPv4Address: + if self._ipv4_addresses: self._set_ipv4_addresses_from_cache(zc, now) - if ip_addr not in self._ipv4_addresses: - self._ipv4_addresses.insert(0, ip_addr) + ipv4_addresses = self._ipv4_addresses + if ip_addr not in ipv4_addresses: + ipv4_addresses.insert(0, ip_addr) return True - elif ip_addr != self._ipv4_addresses[0]: - self._ipv4_addresses.remove(ip_addr) - self._ipv4_addresses.insert(0, ip_addr) + elif ip_addr != ipv4_addresses[0]: + ipv4_addresses.remove(ip_addr) + ipv4_addresses.insert(0, ip_addr) return False if not self._ipv6_addresses: self._set_ipv6_addresses_from_cache(zc, now) + ipv6_addresses = self._ipv6_addresses if ip_addr not in self._ipv6_addresses: - self._ipv6_addresses.insert(0, ip_addr) + ipv6_addresses.insert(0, ip_addr) return True elif ip_addr != self._ipv6_addresses[0]: - self._ipv6_addresses.remove(ip_addr) - self._ipv6_addresses.insert(0, ip_addr) + ipv6_addresses.remove(ip_addr) + ipv6_addresses.insert(0, ip_addr) return False - if record.key != self.key: + if record_key != self.key: return False - if record.type == _TYPE_TXT and isinstance(record, DNSText): + if record.type == _TYPE_TXT and type(record) is DNSText: self._set_text(record.text) return True - if record.type == _TYPE_SRV and isinstance(record, DNSService): + if record.type == _TYPE_SRV and type(record) is DNSService: old_server_key = self.server_key self.name = record.name self.server = record.server @@ -495,16 +498,17 @@ def dns_addresses( name = self.server or self.name ttl = override_ttl if override_ttl is not None else self.host_ttl class_ = _CLASS_IN | _CLASS_UNIQUE + version_value = version.value return [ DNSAddress( name, - _TYPE_AAAA if address.version == 6 else _TYPE_A, + _TYPE_AAAA if type(ip_addr) is IPv6Address else _TYPE_A, class_, ttl, - address.packed, + ip_addr.packed, created=created, ) - for address in self._ip_addresses_by_version_value(version.value) + for ip_addr in self._ip_addresses_by_version_value(version_value) ] def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: From 391c698c403dcf18debfb57d529d5f2bd4316c73 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 24 Jul 2023 16:13:08 +0000 Subject: [PATCH 0860/1433] 0.71.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fca089a1c..78e89ecc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.4 (2023-07-24) + +### Fix + +* Cleanup naming from previous refactoring in ServiceInfo ([#1202](https://github.com/python-zeroconf/python-zeroconf/issues/1202)) ([`b272d75`](https://github.com/python-zeroconf/python-zeroconf/commit/b272d75abd982f3be1f4b20f683cac38011cc6f4)) + ## v0.71.3 (2023-07-23) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 4f44812cc..a7624b940 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.3" +version = "0.71.4" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 5a8119e73..f427c220e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.3' +__version__ = '0.71.4' __license__ = 'LGPL' From d92aad287ac6bd6394ebf955fe5d1d2b4b8490e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Jul 2023 08:35:51 -0500 Subject: [PATCH 0861/1433] chore: add test for concurrent waiting on AsyncServiceInfo (#1204) --- tests/services/test_info.py | 73 ++++++++++++++++++++++++++++++++++++ tests/services/test_types.py | 4 -- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 13f48392b..64a51bd10 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -18,6 +18,7 @@ import zeroconf as r from zeroconf import DNSAddress, const +from zeroconf._services import info from zeroconf._services.info import ServiceInfo from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf @@ -1427,3 +1428,75 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02'] await aiozc.async_close() + + +@pytest.mark.asyncio +@patch.object(info, "_LISTENER_TIME", 10000000) +async def test_release_wait_when_new_recorded_added_concurrency(): + """Test that concurrent async_request returns as soon as new matching records are added to the cache.""" + type_ = "_http._tcp.local." + registration_name = "multiareccon.%s" % type_ + desc = {'path': '/~paulsm/'} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + host = "multahostcon.local." + await aiozc.zeroconf.async_wait_for_start() + + # New kwarg way + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + tasks = [asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200000)) for _ in range(10)] + await asyncio.sleep(0.1) + for task in tasks: + assert not task.done() + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time( + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + 0, + ) + generated.add_answer_at_time( + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + 0, + 0, + 80, + host, + ), + 0, + ) + generated.add_answer_at_time( + r.DNSAddress( + host, + const._TYPE_A, + const._CLASS_IN, + 10000, + b'\x7f\x00\x00\x01', + ), + 0, + ) + generated.add_answer_at_time( + r.DNSText( + registration_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 10000, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + 0, + ) + await asyncio.sleep(0) + for task in tasks: + assert not task.done() + aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + _, pending = await asyncio.wait(tasks, timeout=2) + assert not pending + assert info.addresses == [b'\x7f\x00\x00\x01'] + await aiozc.async_close() diff --git a/tests/services/test_types.py b/tests/services/test_types.py index a8b36b8e8..1afe6d530 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -48,7 +48,6 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) @@ -81,7 +80,6 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) @@ -114,7 +112,6 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) @@ -147,7 +144,6 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) assert discovery_type in service_types _clear_cache(zeroconf_registrar) From 8019a73c952f2fc4c88d849aab970fafedb316d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Aug 2023 14:35:42 -1000 Subject: [PATCH 0862/1433] fix: improve performance of ServiceInfo.async_request (#1205) --- src/zeroconf/_services/info.py | 41 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index d3e6f082b..cc1db05f2 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -39,11 +39,7 @@ from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.asyncio import ( - get_running_loop, - run_coro_with_timeout, - wait_event_or_timeout, -) +from .._utils.asyncio import get_running_loop, run_coro_with_timeout from .._utils.name import service_type_name from .._utils.net import IPVersion, _encode_address from .._utils.time import current_time_millis, millis_to_seconds @@ -131,6 +127,7 @@ class ServiceInfo(RecordUpdateListener): "host_ttl", "other_ttl", "interface_index", + "_new_records_futures", ) def __init__( @@ -177,7 +174,7 @@ def __init__( self.host_ttl = host_ttl self.other_ttl = other_ttl self.interface_index = interface_index - self._notify_event: Optional[asyncio.Event] = None + self._new_records_futures: List[asyncio.Future] = [] @property def name(self) -> str: @@ -235,9 +232,14 @@ def properties(self) -> Dict: async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - if self._notify_event is None: - self._notify_event = asyncio.Event() - await wait_event_or_timeout(self._notify_event, timeout=millis_to_seconds(timeout)) + loop = asyncio.get_running_loop() + future = loop.create_future() + self._new_records_futures.append(future) + handle = loop.call_later(millis_to_seconds(timeout), future.set_result, None) + try: + await future + finally: + handle.cancel() def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version. @@ -409,9 +411,11 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU This method will be run in the event loop. """ - if self._process_records_threadsafe(zc, now, records) and self._notify_event: - self._notify_event.set() - self._notify_event.clear() + if self._process_records_threadsafe(zc, now, records) and self._new_records_futures: + for future in self._new_records_futures: + if not future.done(): + future.set_result(None) + self._new_records_futures.clear() def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> bool: """Thread safe record updating. @@ -591,12 +595,13 @@ def set_server_if_missing(self) -> None: self.server = self.name self.server_key = self.server.lower() - def load_from_cache(self, zc: 'Zeroconf') -> bool: + def load_from_cache(self, zc: 'Zeroconf', now: Optional[float] = None) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. """ - now = current_time_millis() + if not now: + now = current_time_millis() original_server_key = self.server_key cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: @@ -664,11 +669,13 @@ async def async_request( """ if not zc.started: await zc.async_wait_for_start() - if self.load_from_cache(zc): + + now = current_time_millis() + + if self.load_from_cache(zc, now): return True first_request = True - now = current_time_millis() delay = _LISTENER_TIME next_ = now last = now + timeout @@ -683,7 +690,7 @@ async def async_request( ) first_request = False if not out.questions: - return self.load_from_cache(zc) + return self.load_from_cache(zc, now) zc.async_send(out, addr, port) next_ = now + delay delay *= 2 From 1310f122bca6b283c7b3ac1d1cbcac5dcafb2adb Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 2 Aug 2023 00:43:33 +0000 Subject: [PATCH 0863/1433] 0.71.5 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e89ecc5..f0b297084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.71.5 (2023-08-02) + +### Fix + +* Improve performance of ServiceInfo.async_request ([#1205](https://github.com/python-zeroconf/python-zeroconf/issues/1205)) ([`8019a73`](https://github.com/python-zeroconf/python-zeroconf/commit/8019a73c952f2fc4c88d849aab970fafedb316d8)) + ## v0.71.4 (2023-07-24) ### Fix diff --git a/pyproject.toml b/pyproject.toml index a7624b940..6ce539a61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.4" +version = "0.71.5" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f427c220e..6cc3d2af7 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.4' +__version__ = '0.71.5' __license__ = 'LGPL' From 126849c92be8cec9253fba9faa591029d992fcc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Aug 2023 18:49:18 -1000 Subject: [PATCH 0864/1433] feat: speed up processing incoming records (#1206) --- src/zeroconf/_dns.pxd | 26 +++++++++++++++++++++----- src/zeroconf/_dns.py | 20 +++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index cd4f1f9e3..5908ff1bf 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -1,6 +1,8 @@ import cython +from ._protocol.incoming cimport DNSIncoming + cdef object _LEN_BYTE cdef object _LEN_SHORT @@ -9,9 +11,9 @@ cdef object _LEN_INT cdef object _NAME_COMPRESSION_MIN_SIZE cdef object _BASE_MAX_SIZE -cdef object _EXPIRE_FULL_TIME_MS -cdef object _EXPIRE_STALE_TIME_MS -cdef object _RECENT_TIME_MS +cdef cython.uint _EXPIRE_FULL_TIME_MS +cdef cython.uint _EXPIRE_STALE_TIME_MS +cdef cython.uint _RECENT_TIME_MS cdef object _CLASS_UNIQUE cdef object _CLASS_MASK @@ -34,11 +36,25 @@ cdef class DNSQuestion(DNSEntry): cdef class DNSRecord(DNSEntry): - cdef public object ttl - cdef public object created + cdef public cython.float ttl + cdef public cython.float created cdef _suppressed_by_answer(self, DNSRecord answer) + @cython.locals( + answers=cython.list, + ) + cpdef suppressed_by(self, DNSIncoming msg) + + cpdef get_expiration_time(self, cython.uint percent) + + cpdef is_expired(self, cython.float now) + + cpdef is_stale(self, cython.float now) + + cpdef is_recent(self, cython.float now) + + cpdef reset_ttl(self, DNSRecord other) cdef class DNSAddress(DNSRecord): diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 34d7fdb24..561b16ffc 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -40,6 +40,8 @@ _EXPIRE_STALE_TIME_MS = 500 _RECENT_TIME_MS = 250 +_float = float +_int = int if TYPE_CHECKING: from ._protocol.incoming import DNSIncoming @@ -172,32 +174,36 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" - return any(self._suppressed_by_answer(record) for record in msg.answers) + answers = msg.answers + for record in answers: + if self._suppressed_by_answer(record): + return True + return False - def _suppressed_by_answer(self, other) -> bool: # type: ignore[no-untyped-def] + def _suppressed_by_answer(self, other: 'DNSRecord') -> bool: """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) - def get_expiration_time(self, percent: int) -> float: + def get_expiration_time(self, percent: _int) -> float: """Returns the time at which this record will have expired by a certain percentage.""" return self.created + (percent * self.ttl * 10) # TODO: Switch to just int here - def get_remaining_ttl(self, now: float) -> Union[int, float]: + def get_remaining_ttl(self, now: _float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" return max(0, millis_to_seconds((self.created + (_EXPIRE_FULL_TIME_MS * self.ttl)) - now)) - def is_expired(self, now: float) -> bool: + def is_expired(self, now: _float) -> bool: """Returns true if this record has expired.""" return self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) <= now - def is_stale(self, now: float) -> bool: + def is_stale(self, now: _float) -> bool: """Returns true if this record is at least half way expired.""" return self.created + (_EXPIRE_STALE_TIME_MS * self.ttl) <= now - def is_recent(self, now: float) -> bool: + def is_recent(self, now: _float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" return self.created + (_RECENT_TIME_MS * self.ttl) > now From 063b5d9c8ff9daa4ddd1a9d4f2f3d2a5b2c652c4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 2 Aug 2023 04:57:16 +0000 Subject: [PATCH 0865/1433] 0.72.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b297084..f4549b7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.72.0 (2023-08-02) + +### Feature + +* Speed up processing incoming records ([#1206](https://github.com/python-zeroconf/python-zeroconf/issues/1206)) ([`126849c`](https://github.com/python-zeroconf/python-zeroconf/commit/126849c92be8cec9253fba9faa591029d992fcc3)) + ## v0.71.5 (2023-08-02) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 6ce539a61..1f54db8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.71.5" +version = "0.72.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6cc3d2af7..d4562bae3 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.71.5' +__version__ = '0.72.0' __license__ = 'LGPL' From 2233b6bc4ceeee5524d2ee88ecae8234173feb5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Aug 2023 21:36:52 -1000 Subject: [PATCH 0866/1433] fix: race with InvalidStateError when async_request times out (#1208) --- src/zeroconf/_services/info.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index cc1db05f2..b75d6277e 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -89,6 +89,12 @@ def instance_name_from_service_info(info: "ServiceInfo") -> str: _cached_ip_addresses = lru_cache(maxsize=256)(ip_address) +def _set_future_none_if_not_done(fut: asyncio.Future) -> None: + """Set a future to None if it is not done.""" + if not fut.done(): # pragma: no branch + fut.set_result(None) + + class ServiceInfo(RecordUpdateListener): """Service information. @@ -235,7 +241,7 @@ async def async_wait(self, timeout: float) -> None: loop = asyncio.get_running_loop() future = loop.create_future() self._new_records_futures.append(future) - handle = loop.call_later(millis_to_seconds(timeout), future.set_result, None) + handle = loop.call_later(millis_to_seconds(timeout), _set_future_none_if_not_done, future) try: await future finally: From ffe8fd5ecb6abd11362fea865e81ae987c86f7e7 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Aug 2023 07:44:37 +0000 Subject: [PATCH 0867/1433] 0.72.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4549b7f6..fae7ab17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.72.1 (2023-08-03) + +### Fix + +* Race with InvalidStateError when async_request times out ([#1208](https://github.com/python-zeroconf/python-zeroconf/issues/1208)) ([`2233b6b`](https://github.com/python-zeroconf/python-zeroconf/commit/2233b6bc4ceeee5524d2ee88ecae8234173feb5f)) + ## v0.72.0 (2023-08-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 1f54db8ef..edde15945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.72.0" +version = "0.72.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d4562bae3..81b6ebd4a 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.72.0' +__version__ = '0.72.1' __license__ = 'LGPL' From 5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Aug 2023 22:16:49 -1000 Subject: [PATCH 0868/1433] fix: revert DNSIncoming cimport in _dns.pxd (#1209) --- src/zeroconf/_dns.pxd | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 5908ff1bf..289cd1a1d 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -1,8 +1,6 @@ import cython -from ._protocol.incoming cimport DNSIncoming - cdef object _LEN_BYTE cdef object _LEN_SHORT @@ -44,7 +42,7 @@ cdef class DNSRecord(DNSEntry): @cython.locals( answers=cython.list, ) - cpdef suppressed_by(self, DNSIncoming msg) + cpdef suppressed_by(self, object msg) cpdef get_expiration_time(self, cython.uint percent) From 07cf846cc9d3d9eba32be961df04099e2ba4d9cd Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Aug 2023 08:25:05 +0000 Subject: [PATCH 0869/1433] 0.72.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae7ab17b..3bf73cb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.72.2 (2023-08-03) + +### Fix + +* Revert DNSIncoming cimport in _dns.pxd ([#1209](https://github.com/python-zeroconf/python-zeroconf/issues/1209)) ([`5f14b6d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1)) + ## v0.72.1 (2023-08-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index edde15945..498e1c2fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.72.1" +version = "0.72.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 81b6ebd4a..5b819741f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.72.1' +__version__ = '0.72.2' __license__ = 'LGPL' From 3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Aug 2023 23:16:39 -1000 Subject: [PATCH 0870/1433] fix: revert adding typing to DNSRecord.suppressed_by (#1210) --- src/zeroconf/_dns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 561b16ffc..f26d02b7c 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -180,7 +180,7 @@ def suppressed_by(self, msg: 'DNSIncoming') -> bool: return True return False - def _suppressed_by_answer(self, other: 'DNSRecord') -> bool: + def _suppressed_by_answer(self, other) -> bool: # type: ignore[no-untyped-def] """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) From cbca88cfa6a225bcd193b2144f1d59564adc22ce Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Aug 2023 09:27:33 +0000 Subject: [PATCH 0871/1433] 0.72.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf73cb51..b3951797a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.72.3 (2023-08-03) + +### Fix + +* Revert adding typing to DNSRecord.suppressed_by ([#1210](https://github.com/python-zeroconf/python-zeroconf/issues/1210)) ([`3dba5ae`](https://github.com/python-zeroconf/python-zeroconf/commit/3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a)) + ## v0.72.2 (2023-08-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 498e1c2fd..fcc3757ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.72.2" +version = "0.72.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 5b819741f..77aa1796d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.72.2' +__version__ = '0.72.3' __license__ = 'LGPL' From 53a694f60e675ae0560e727be6b721b401c2b68f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Aug 2023 13:41:35 -1000 Subject: [PATCH 0872/1433] feat: add a cache to service_type_name (#1211) --- src/zeroconf/_utils/name.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index 7fa667a10..adccb3e5e 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -35,6 +35,7 @@ ) +@lru_cache(maxsize=512) def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: disable=too-many-branches """ Validate a fully qualified service name, instance or subtype. [rfc6763] From 0114836a16f88456081836750e08735b443d83b3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Aug 2023 23:49:31 +0000 Subject: [PATCH 0873/1433] 0.73.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3951797a..93b8cd40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.73.0 (2023-08-03) + +### Feature + +* Add a cache to service_type_name ([#1211](https://github.com/python-zeroconf/python-zeroconf/issues/1211)) ([`53a694f`](https://github.com/python-zeroconf/python-zeroconf/commit/53a694f60e675ae0560e727be6b721b401c2b68f)) + ## v0.72.3 (2023-08-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index fcc3757ac..52baeee04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.72.3" +version = "0.73.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 77aa1796d..e5471f858 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.72.3' +__version__ = '0.73.0' __license__ = 'LGPL' From 32a016e0bcaa116d9f98396dc83f73ae27d3e555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Aug 2023 14:19:40 -1000 Subject: [PATCH 0874/1433] chore: fix some legacy python2 formatting in examples (#1214) --- examples/async_apple_scanner.py | 2 +- examples/async_browser.py | 2 +- examples/async_service_info_request.py | 6 +++--- examples/browser.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index 88b54e4a7..ff558f82e 100644 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -59,7 +59,7 @@ async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(f" {key}: {value}") + print(f" {key!r}: {value!r}") else: print(" No properties") else: diff --git a/examples/async_browser.py b/examples/async_browser.py index 71c5e670a..f7fb71514 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -41,7 +41,7 @@ async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(f" {key}: {value}") + print(f" {key!r}: {value!r}") else: print(" No properties") else: diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 5276c122b..5bb247618 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -9,7 +9,7 @@ import argparse import asyncio import logging -from typing import Any, Optional, cast +from typing import Any, List, Optional, cast from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -21,7 +21,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: zeroconf = aiozc.zeroconf while True: await asyncio.sleep(5) - infos = [] + infos: List[AsyncServiceInfo] = [] for name in zeroconf.cache.names(): if not name.endswith(HAP_TYPE): continue @@ -38,7 +38,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(f" {key}: {value}") + print(f" {key!r}: {value!r}") else: print(" No properties") else: diff --git a/examples/browser.py b/examples/browser.py index fc815e3f8..60933e2a4 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -36,7 +36,7 @@ def on_service_state_change( if info.properties: print(" Properties are:") for key, value in info.properties.items(): - print(f" {key}: {value}") + print(f" {key!r}: {value!r}") else: print(" No properties") else: From 99a6f98e44a1287ba537eabb852b1b69923402f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Aug 2023 14:36:40 -1000 Subject: [PATCH 0875/1433] feat: speed up unpacking text records in ServiceInfo (#1212) --- src/zeroconf/_services/info.py | 36 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index b75d6277e..02b7137a6 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -172,7 +172,7 @@ def __init__( self.priority = priority self.server = server if server else None self.server_key = server.lower() if server else None - self._properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} + self._properties: Optional[Dict[Union[str, bytes], Optional[Union[str, bytes]]]] = None if isinstance(properties, bytes): self._set_text(properties) else: @@ -226,7 +226,7 @@ def addresses(self, value: List[bytes]) -> None: self._ipv6_addresses.append(addr) @property - def properties(self) -> Dict: + def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: """If properties were set in the constructor this property returns the original dictionary of type `Dict[Union[bytes, str], Any]`. @@ -234,6 +234,10 @@ def properties(self) -> Dict: bytes and the values are either bytes, if there was a value, even empty, or `None`, if there was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. """ + if self._properties is None: + self._unpack_text_into_properties() + if TYPE_CHECKING: + assert self._properties is not None return self._properties async def async_wait(self, timeout: float) -> None: @@ -317,10 +321,10 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st for addr in self._ip_addresses_by_version_value(version.value) ] - def _set_properties(self, properties: Dict) -> None: + def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: """Sets properties and text of this info from a dictionary""" self._properties = properties - list_ = [] + list_: List[bytes] = [] result = b'' for key, value in properties.items(): if isinstance(key, str): @@ -338,14 +342,25 @@ def _set_properties(self, properties: Dict) -> None: def _set_text(self, text: bytes) -> None: """Sets properties and text given a text field""" + if text == self.text: + return self.text = text + # Clear the properties cache + self._properties = None + + def _unpack_text_into_properties(self) -> None: + """Unpacks the text field into properties""" + text = self.text end = len(text) if end == 0: + # Properties should be set atomically + # in case another thread is reading them self._properties = {} return + result: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} index = 0 - strs = [] + strs: List[bytes] = [] while index < end: length = text[index] index += 1 @@ -355,17 +370,20 @@ def _set_text(self, text: bytes) -> None: key: bytes value: Optional[bytes] for s in strs: - try: - key, value = s.split(b'=', 1) - except ValueError: + key_value = s.split(b'=', 1) + if len(key_value) == 2: + key, value = key_value + else: # No equals sign at all key = s value = None # Only update non-existent properties - if key and result.get(key) is None: + if key and key not in result: result[key] = value + # Properties should be set atomically + # in case another thread is reading them self._properties = result def get_name(self) -> str: From 0094e2684344c6b7edd7948924f093f1b4c19901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Aug 2023 14:36:50 -1000 Subject: [PATCH 0876/1433] fix: remove typing on reset_ttl for cython compat (#1213) --- src/zeroconf/_dns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index f26d02b7c..6c34f9dd6 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -207,7 +207,7 @@ def is_recent(self, now: _float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" return self.created + (_RECENT_TIME_MS * self.ttl) > now - def reset_ttl(self, other: 'DNSRecord') -> None: + def reset_ttl(self, other) -> None: # type: ignore[no-untyped-def] """Sets this record's TTL and created time to that of another record.""" self.set_created_ttl(other.created, other.ttl) From 8a9dc0bf41bfb350e82aaf07a432bf414f6bce87 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 4 Aug 2023 00:45:26 +0000 Subject: [PATCH 0877/1433] 0.74.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b8cd40e..ae8d83686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ +## v0.74.0 (2023-08-04) + +### Feature + +* Speed up unpacking text records in ServiceInfo ([#1212](https://github.com/python-zeroconf/python-zeroconf/issues/1212)) ([`99a6f98`](https://github.com/python-zeroconf/python-zeroconf/commit/99a6f98e44a1287ba537eabb852b1b69923402f0)) + +### Fix + +* Remove typing on reset_ttl for cython compat ([#1213](https://github.com/python-zeroconf/python-zeroconf/issues/1213)) ([`0094e26`](https://github.com/python-zeroconf/python-zeroconf/commit/0094e2684344c6b7edd7948924f093f1b4c19901)) + ## v0.73.0 (2023-08-03) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 52baeee04..40d0341ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.73.0" +version = "0.74.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index e5471f858..4de632fa4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.73.0' +__version__ = '0.74.0' __license__ = 'LGPL' From aff625dc6a5e816dad519644c4adac4f96980c04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Aug 2023 16:03:28 -0500 Subject: [PATCH 0878/1433] feat: speed up processing incoming records (#1216) --- src/zeroconf/_dns.pxd | 4 ++++ src/zeroconf/_dns.py | 7 ++++--- src/zeroconf/_handlers.py | 15 +++++++++------ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 289cd1a1d..5622a5ed3 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -44,6 +44,8 @@ cdef class DNSRecord(DNSEntry): ) cpdef suppressed_by(self, object msg) + cpdef get_remaining_ttl(self, cython.float now) + cpdef get_expiration_time(self, cython.uint percent) cpdef is_expired(self, cython.float now) @@ -54,6 +56,8 @@ cdef class DNSRecord(DNSEntry): cpdef reset_ttl(self, DNSRecord other) + cpdef set_created_ttl(self, cython.float now, cython.float ttl) + cdef class DNSAddress(DNSRecord): cdef public cython.int _hash diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 6c34f9dd6..73b0c7510 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -26,7 +26,7 @@ from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address -from ._utils.time import current_time_millis, millis_to_seconds +from ._utils.time import current_time_millis from .const import _CLASS_MASK, _CLASS_UNIQUE, _CLASSES, _TYPE_ANY, _TYPES _LEN_BYTE = 1 @@ -193,7 +193,8 @@ def get_expiration_time(self, percent: _int) -> float: # TODO: Switch to just int here def get_remaining_ttl(self, now: _float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" - return max(0, millis_to_seconds((self.created + (_EXPIRE_FULL_TIME_MS * self.ttl)) - now)) + remain = (self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) - now) / 1000.0 + return 0 if remain < 0 else remain def is_expired(self, now: _float) -> bool: """Returns true if this record has expired.""" @@ -212,7 +213,7 @@ def reset_ttl(self, other) -> None: # type: ignore[no-untyped-def] another record.""" self.set_created_ttl(other.created, other.ttl) - def set_created_ttl(self, created: float, ttl: Union[float, int]) -> None: + def set_created_ttl(self, created: _float, ttl: Union[float, int]) -> None: """Set the created and ttl of a record.""" self.created = created self.ttl = ttl diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index 192496c17..be0d619f3 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -408,6 +408,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes: Set[DNSRecord] = set() now = msg.now unique_types: Set[Tuple[str, int, int]] = set() + cache = self.cache for record in msg.answers: # Protect zeroconf from records that can cause denial of service. @@ -416,7 +417,9 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # ServiceBrowsers generating excessive queries refresh queries. # Apple uses a 15s minimum TTL, however we do not have the same # level of rate limit and safe guards so we use 1/4 of the recommended value. - if record.ttl and record.type == _TYPE_PTR and record.ttl < _DNS_PTR_MIN_TTL: + record_type = record.type + record_ttl = record.ttl + if record_ttl and record_type == _TYPE_PTR and record_ttl < _DNS_PTR_MIN_TTL: log.debug( "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", record, @@ -425,12 +428,12 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - unique_types.add((record.name, record.type, record.class_)) + unique_types.add((record.name, record_type, record.class_)) if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) - maybe_entry = self.cache.async_get_unique(record) + maybe_entry = cache.async_get_unique(record) if not record.is_expired(now): if maybe_entry is not None: maybe_entry.reset_ttl(record) @@ -447,7 +450,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - self.cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) if updates: self.async_updates(now, updates) @@ -468,12 +471,12 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # processsed. new = False if other_adds or address_adds: - new = self.cache.async_add_records(itertools.chain(address_adds, other_adds)) + new = cache.async_add_records(itertools.chain(address_adds, other_adds)) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. if removes: - self.cache.async_remove_records(removes) + cache.async_remove_records(removes) if updates: self.async_updates_complete(new) From 5df8a57a14d59687a3c22ea8ee063e265031e278 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Sun, 13 Aug 2023 23:03:39 +0200 Subject: [PATCH 0879/1433] feat: expose flag to disable strict name checking in service registration (#1215) --- src/zeroconf/_core.py | 16 +++++++++++----- src/zeroconf/_services/info.py | 4 ++-- src/zeroconf/asyncio.py | 3 ++- tests/test_asyncio.py | 35 ++++++++++++++++++++++++++++++++++ tests/utils/test_name.py | 29 ++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index ab8e72e52..6a9c2c8a9 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -620,6 +620,7 @@ def register_service( ttl: Optional[int] = None, allow_name_change: bool = False, cooperating_responders: bool = False, + strict: bool = True, ) -> None: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that @@ -635,7 +636,7 @@ def register_service( assert self.loop is not None run_coro_with_timeout( await_awaitable( - self.async_register_service(info, ttl, allow_name_change, cooperating_responders) + self.async_register_service(info, ttl, allow_name_change, cooperating_responders, strict) ), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS, @@ -647,6 +648,7 @@ async def async_register_service( ttl: Optional[int] = None, allow_name_change: bool = False, cooperating_responders: bool = False, + strict: bool = True, ) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that @@ -662,7 +664,7 @@ async def async_register_service( info.set_server_if_missing() await self.async_wait_for_start() - await self.async_check_service(info, allow_name_change, cooperating_responders) + await self.async_check_service(info, allow_name_change, cooperating_responders, strict) self.registry.async_add(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) @@ -810,11 +812,15 @@ def unregister_all_services(self) -> None: ) async def async_check_service( - self, info: ServiceInfo, allow_name_change: bool, cooperating_responders: bool = False + self, + info: ServiceInfo, + allow_name_change: bool, + cooperating_responders: bool = False, + strict: bool = True, ) -> None: """Checks the network for a unique service name, modifying the ServiceInfo passed in if it is not unique.""" - instance_name = instance_name_from_service_info(info) + instance_name = instance_name_from_service_info(info, strict=strict) if cooperating_responders: return next_instance_number = 2 @@ -829,7 +835,7 @@ async def async_check_service( # change the name and look for a conflict info.name = f'{instance_name}-{next_instance_number}.{info.type}' next_instance_number += 1 - service_type_name(info.name) + service_type_name(info.name, strict=strict) next_time = now i = 0 diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 02b7137a6..29ddb9a00 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -76,11 +76,11 @@ from .._core import Zeroconf -def instance_name_from_service_info(info: "ServiceInfo") -> str: +def instance_name_from_service_info(info: "ServiceInfo", strict: bool = True) -> str: """Calculate the instance name from the ServiceInfo.""" # This is kind of funky because of the subtype based tests # need to make subtypes a first class citizen - service_name = service_type_name(info.name) + service_name = service_type_name(info.name, strict=strict) if not info.type.endswith(service_name): raise BadTypeInNameException return info.name[: -len(service_name) - 1] diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 7ded0ecb2..755757d77 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -180,6 +180,7 @@ async def async_register_service( ttl: Optional[int] = None, allow_name_change: bool = False, cooperating_responders: bool = False, + strict: bool = True, ) -> Awaitable: """Registers service information to the network with a default TTL. Zeroconf will then respond to requests for information for that @@ -192,7 +193,7 @@ async def async_register_service( and therefore can be awaited if necessary. """ return await self.zeroconf.async_register_service( - info, ttl, allow_name_change, cooperating_responders + info, ttl, allow_name_change, cooperating_responders, strict ) async def async_unregister_all_services(self) -> None: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 66c81e001..cd067ae12 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -456,6 +456,41 @@ async def test_async_service_registration_name_does_not_match_type() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_async_service_registration_name_strict_check() -> None: + """Test registering services throws when the name does not comply.""" + zc = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + type_ = "_ibisip_http._tcp.local." + name = "CustomerInformationService-F4D4895E9EEB" + registration_name = f"{name}.{type_}" + + desc = {'path': '/~paulsm/'} + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + with pytest.raises(BadTypeInNameException): + await zc.async_check_service(info, allow_name_change=False) + + with pytest.raises(BadTypeInNameException): + task = await aiozc.async_register_service(info) + await task + + await zc.async_check_service(info, allow_name_change=False, strict=False) + task = await aiozc.async_register_service(info, strict=False) + await task + + await aiozc.async_unregister_service(info) + await aiozc.async_close() + + @pytest.mark.asyncio async def test_async_tasks() -> None: """Test awaiting broadcast tasks""" diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 3df73f5aa..9604b7758 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -2,10 +2,12 @@ """Unit tests for zeroconf._utils.name.""" +import socket import pytest from zeroconf import BadTypeInNameException +from zeroconf._services.info import ServiceInfo, instance_name_from_service_info from zeroconf._utils import name as nameutils @@ -25,6 +27,33 @@ def test_service_type_name_overlong_full_name(): nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.", strict=False) +@pytest.mark.parametrize( + "instance_name, service_type", + ( + ("CustomerInformationService-F4D4885E9EEB", "_ibisip_http._tcp.local."), + ("DeviceManagementService_F4D4885E9EEB", "_ibisip_http._tcp.local."), + ), +) +def test_service_type_name_non_strict_compliant_names(instance_name, service_type): + """Test service_type_name for valid names, but not strict-compliant.""" + desc = {'path': '/~paulsm/'} + service_name = f'{instance_name}.{service_type}' + service_server = 'ash-1.local.' + service_address = socket.inet_aton("10.0.1.2") + info = ServiceInfo( + service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + ) + assert info.get_name() == instance_name + + with pytest.raises(BadTypeInNameException): + nameutils.service_type_name(service_name) + with pytest.raises(BadTypeInNameException): + instance_name_from_service_info(info) + + nameutils.service_type_name(service_name, strict=False) + assert instance_name_from_service_info(info, strict=False) == instance_name + + def test_possible_types(): """Test possible types from name.""" assert nameutils.possible_types('.') == set() From 844c5544b85ca77303defa68057221273719bebe Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 13 Aug 2023 21:14:18 +0000 Subject: [PATCH 0880/1433] 0.75.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8d83686..5cc53d456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.75.0 (2023-08-13) + +### Feature + +* Expose flag to disable strict name checking in service registration ([#1215](https://github.com/python-zeroconf/python-zeroconf/issues/1215)) ([`5df8a57`](https://github.com/python-zeroconf/python-zeroconf/commit/5df8a57a14d59687a3c22ea8ee063e265031e278)) +* Speed up processing incoming records ([#1216](https://github.com/python-zeroconf/python-zeroconf/issues/1216)) ([`aff625d`](https://github.com/python-zeroconf/python-zeroconf/commit/aff625dc6a5e816dad519644c4adac4f96980c04)) + ## v0.74.0 (2023-08-04) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 40d0341ab..e1963e2fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.74.0" +version = "0.75.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 4de632fa4..82b6096d7 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.74.0' +__version__ = '0.75.0' __license__ = 'LGPL' From 69b33be3b2f9d4a27ef5154cae94afca048efffa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Aug 2023 20:40:46 -0500 Subject: [PATCH 0881/1433] feat: improve performance responding to queries (#1217) --- src/zeroconf/_services/info.py | 73 +++++++++++++++++++--------------- src/zeroconf/const.py | 1 + 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 29ddb9a00..2f4ae59e7 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -46,7 +46,7 @@ from ..const import ( _ADDRESS_RECORD_TYPES, _CLASS_IN, - _CLASS_UNIQUE, + _CLASS_IN_UNIQUE, _DNS_HOST_TTL, _DNS_OTHER_TTL, _FLAGS_QR_QUERY, @@ -388,7 +388,7 @@ def _unpack_text_into_properties(self) -> None: def get_name(self) -> str: """Name accessor""" - return self.name[: len(self.name) - len(self.type) - 1] + return self._name[: len(self._name) - len(self.type) - 1] def _get_ip_addresses_from_cache_lifo( self, zc: 'Zeroconf', now: float, type: int @@ -409,15 +409,21 @@ def _get_ip_addresses_from_cache_lifo( def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: """Set IPv6 addresses from the cache.""" - self._ipv6_addresses = cast( - "List[IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) - ) + if TYPE_CHECKING: + self._ipv6_addresses = cast( + "List[IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) + ) + else: + self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: """Set IPv4 addresses from the cache.""" - self._ipv4_addresses = cast( - "List[IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) - ) + if TYPE_CHECKING: + self._ipv4_addresses = cast( + "List[IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) + ) + else: + self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: """Updates service information from a DNS record. @@ -523,9 +529,9 @@ def dns_addresses( created: Optional[float] = None, ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" - name = self.server or self.name + name = self.server or self._name ttl = override_ttl if override_ttl is not None else self.host_ttl - class_ = _CLASS_IN | _CLASS_UNIQUE + class_ = _CLASS_IN_UNIQUE version_value = version.value return [ DNSAddress( @@ -546,30 +552,33 @@ def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[floa _TYPE_PTR, _CLASS_IN, override_ttl if override_ttl is not None else self.other_ttl, - self.name, + self._name, created, ) def dns_service(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSService: """Return DNSService from ServiceInfo.""" + port = self.port + if TYPE_CHECKING: + assert isinstance(port, int) return DNSService( - self.name, + self._name, _TYPE_SRV, - _CLASS_IN | _CLASS_UNIQUE, + _CLASS_IN_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, self.priority, self.weight, - cast(int, self.port), - self.server or self.name, + port, + self.server or self._name, created, ) def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSText: """Return DNSText from ServiceInfo.""" return DNSText( - self.name, + self._name, _TYPE_TXT, - _CLASS_IN | _CLASS_UNIQUE, + _CLASS_IN_UNIQUE, override_ttl if override_ttl is not None else self.other_ttl, self.text, created, @@ -580,11 +589,11 @@ def dns_nsec( ) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( - self.name, + self._name, _TYPE_NSEC, - _CLASS_IN | _CLASS_UNIQUE, + _CLASS_IN_UNIQUE, override_ttl if override_ttl is not None else self.host_ttl, - self.name, + self._name, missing_types, created, ) @@ -593,12 +602,11 @@ def get_address_and_nsec_records( self, override_ttl: Optional[int] = None, created: Optional[float] = None ) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" - seen_types: Set[int] = set() + missing_types: Set[int] = _ADDRESS_RECORD_TYPES.copy() records: Set[DNSRecord] = set() for dns_address in self.dns_addresses(override_ttl, IPVersion.All, created): - seen_types.add(dns_address.type) + missing_types.discard(dns_address.type) records.add(dns_address) - missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if missing_types: assert self.server is not None, "Service server must be set for NSEC record." records.add(self.dns_nsec(list(missing_types), override_ttl, created)) @@ -616,7 +624,7 @@ def set_server_if_missing(self) -> None: This function is for backwards compatibility. """ if self.server is None: - self.server = self.name + self.server = self._name self.server_key = self.server.lower() def load_from_cache(self, zc: 'Zeroconf', now: Optional[float] = None) -> bool: @@ -627,10 +635,10 @@ def load_from_cache(self, zc: 'Zeroconf', now: Optional[float] = None) -> bool: if not now: now = current_time_millis() original_server_key = self.server_key - cached_srv_record = zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN) + cached_srv_record = zc.cache.get_by_details(self._name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: self._process_record_threadsafe(zc, cached_srv_record, now) - cached_txt_record = zc.cache.get_by_details(self.name, _TYPE_TXT, _CLASS_IN) + cached_txt_record = zc.cache.get_by_details(self._name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: self._process_record_threadsafe(zc, cached_txt_record, now) if original_server_key == self.server_key: @@ -732,10 +740,13 @@ def generate_request_query( ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(zc.cache, now, self.name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server or self.name, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(zc.cache, now, self.server or self.name, _TYPE_AAAA, _CLASS_IN) + name = self._name + server_or_name = self.server or name + cache = zc.cache + out.add_question_or_one_cache(cache, now, name, _TYPE_SRV, _CLASS_IN) + out.add_question_or_one_cache(cache, now, name, _TYPE_TXT, _CLASS_IN) + out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_A, _CLASS_IN) + out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_AAAA, _CLASS_IN) if question_type == DNSQuestionType.QU: for question in out.questions: question.unicast = True @@ -743,7 +754,7 @@ def generate_request_query( def __eq__(self, other: object) -> bool: """Tests equality of service name""" - return isinstance(other, ServiceInfo) and other.name == self.name + return isinstance(other, ServiceInfo) and other._name == self._name def __repr__(self) -> str: """String representation""" diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index f87c13360..ca199df5b 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -84,6 +84,7 @@ _CLASS_ANY = 255 _CLASS_MASK = 0x7FFF _CLASS_UNIQUE = 0x8000 +_CLASS_IN_UNIQUE = _CLASS_IN | _CLASS_UNIQUE _TYPE_A = 1 _TYPE_NS = 2 From 6d83f99e820e2edd2c31969c29b536eb3bb57d9a Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 14 Aug 2023 01:49:12 +0000 Subject: [PATCH 0882/1433] 0.76.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc53d456..ff7ead6e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.76.0 (2023-08-14) + +### Feature + +* Improve performance responding to queries ([#1217](https://github.com/python-zeroconf/python-zeroconf/issues/1217)) ([`69b33be`](https://github.com/python-zeroconf/python-zeroconf/commit/69b33be3b2f9d4a27ef5154cae94afca048efffa)) + ## v0.75.0 (2023-08-13) ### Feature diff --git a/pyproject.toml b/pyproject.toml index e1963e2fd..568f4623c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.75.0" +version = "0.76.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 82b6096d7..39a6b3fc9 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.75.0' +__version__ = '0.76.0' __license__ = 'LGPL' From 12560a70c331e5d5043a06ca2ac50628d4d246f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 10:34:50 -0500 Subject: [PATCH 0883/1433] chore: split AsyncEngine into _engine.py (#1218) --- src/zeroconf/_core.py | 335 +----------------------------- src/zeroconf/_engine.py | 368 +++++++++++++++++++++++++++++++++ tests/conftest.py | 14 +- tests/services/test_browser.py | 4 +- tests/test_core.py | 239 +-------------------- tests/test_engine.py | 271 ++++++++++++++++++++++++ tests/utils/test_asyncio.py | 2 +- 7 files changed, 655 insertions(+), 578 deletions(-) create mode 100644 src/zeroconf/_engine.py create mode 100644 tests/test_engine.py diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 6a9c2c8a9..1548ec5b9 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -21,17 +21,15 @@ """ import asyncio -import itertools import logging -import random -import socket import sys import threading -from types import TracebackType # noqa # used in type hints -from typing import Any, Awaitable, Dict, List, Optional, Tuple, Type, Union, cast +from types import TracebackType +from typing import Awaitable, Dict, List, Optional, Tuple, Type, Union from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType +from ._engine import AsyncEngine, _WrappedTransport from ._exceptions import NonUniqueNameException, NotRunningException from ._handlers import ( MulticastOutgoingQueue, @@ -48,7 +46,7 @@ from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry -from ._updates import RecordUpdate, RecordUpdateListener +from ._updates import RecordUpdateListener from ._utils.asyncio import ( await_awaitable, get_running_loop, @@ -67,11 +65,9 @@ ) from ._utils.time import current_time_millis, millis_to_seconds from .const import ( - _CACHE_CLEANUP_INTERVAL, _CHECK_TIME, _CLASS_IN, _CLASS_UNIQUE, - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -86,7 +82,6 @@ _UNREGISTER_TIME, ) -_TC_DELAY_RANDOM_INTERVAL = (400, 500) # The maximum amont of time to delay a multicast # response in order to aggregate answers _AGGREGATION_DELAY = 500 # ms @@ -102,331 +97,9 @@ # 3000ms _PROTECTED_AGGREGATION_DELAY = 200 # ms -_CLOSE_TIMEOUT = 3000 # ms _REGISTER_BROADCASTS = 3 -class _WrappedTransport: - """A wrapper for transports.""" - - __slots__ = ( - 'transport', - 'is_ipv6', - 'sock', - 'fileno', - 'sock_name', - ) - - def __init__( - self, - transport: asyncio.DatagramTransport, - is_ipv6: bool, - sock: socket.socket, - fileno: int, - sock_name: Any, - ) -> None: - """Initialize the wrapped transport. - - These attributes are used when sending packets. - """ - self.transport = transport - self.is_ipv6 = is_ipv6 - self.sock = sock - self.fileno = fileno - self.sock_name = sock_name - - -def _make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: - """Make a wrapped transport.""" - sock: socket.socket = transport.get_extra_info('socket') - return _WrappedTransport( - transport=transport, - is_ipv6=sock.family == socket.AF_INET6, - sock=sock, - fileno=sock.fileno(), - sock_name=sock.getsockname(), - ) - - -class AsyncEngine: - """An engine wraps sockets in the event loop.""" - - __slots__ = ( - 'loop', - 'zc', - 'protocols', - 'readers', - 'senders', - 'running_event', - '_listen_socket', - '_respond_sockets', - '_cleanup_timer', - ) - - def __init__( - self, - zeroconf: 'Zeroconf', - listen_socket: Optional[socket.socket], - respond_sockets: List[socket.socket], - ) -> None: - self.loop: Optional[asyncio.AbstractEventLoop] = None - self.zc = zeroconf - self.protocols: List[AsyncListener] = [] - self.readers: List[_WrappedTransport] = [] - self.senders: List[_WrappedTransport] = [] - self.running_event: Optional[asyncio.Event] = None - self._listen_socket = listen_socket - self._respond_sockets = respond_sockets - self._cleanup_timer: Optional[asyncio.TimerHandle] = None - - def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: - """Set up the instance.""" - self.loop = loop - self.running_event = asyncio.Event() - self.loop.create_task(self._async_setup(loop_thread_ready)) - - async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: - """Set up the instance.""" - assert self.loop is not None - self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) - await self._async_create_endpoints() - assert self.running_event is not None - self.running_event.set() - if loop_thread_ready: - loop_thread_ready.set() - - async def _async_create_endpoints(self) -> None: - """Create endpoints to send and receive.""" - assert self.loop is not None - loop = self.loop - reader_sockets = [] - sender_sockets = [] - if self._listen_socket: - reader_sockets.append(self._listen_socket) - for s in self._respond_sockets: - if s not in reader_sockets: - reader_sockets.append(s) - sender_sockets.append(s) - - for s in reader_sockets: - transport, protocol = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) - self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) - if s in sender_sockets: - self.senders.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) - - def _async_cache_cleanup(self) -> None: - """Periodic cache cleanup.""" - now = current_time_millis() - self.zc.question_history.async_expire(now) - self.zc.record_manager.async_updates( - now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] - ) - self.zc.record_manager.async_updates_complete(False) - assert self.loop is not None - self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) - - async def _async_close(self) -> None: - """Cancel and wait for the cleanup task to finish.""" - self._async_shutdown() - await asyncio.sleep(0) # flush out any call soons - assert self._cleanup_timer is not None - self._cleanup_timer.cancel() - - def _async_shutdown(self) -> None: - """Shutdown transports and sockets.""" - assert self.running_event is not None - self.running_event.clear() - for wrapped_transport in itertools.chain(self.senders, self.readers): - wrapped_transport.transport.close() - - def close(self) -> None: - """Close from sync context. - - While it is not expected during normal operation, - this function may raise EventLoopBlocked if the underlying - call to `_async_close` cannot be completed. - """ - assert self.loop is not None - # Guard against Zeroconf.close() being called from the eventloop - if get_running_loop() == self.loop: - self._async_shutdown() - return - if not self.loop.is_running(): - return - run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) - - -class AsyncListener(asyncio.Protocol, QuietLogger): - - """A Listener is used by this module to listen on the multicast - group to which DNS messages are sent, allowing the implementation - to cache information as it arrives. - - It requires registration with an Engine object in order to have - the read() method called when a socket is available for reading.""" - - __slots__ = ('zc', 'data', 'last_time', 'transport', 'sock_description', '_deferred', '_timers') - - def __init__(self, zc: 'Zeroconf') -> None: - self.zc = zc - self.data: Optional[bytes] = None - self.last_time: float = 0 - self.last_message: Optional[DNSIncoming] = None - self.transport: Optional[_WrappedTransport] = None - self.sock_description: Optional[str] = None - self._deferred: Dict[str, List[DNSIncoming]] = {} - self._timers: Dict[str, asyncio.TimerHandle] = {} - super().__init__() - - def datagram_received( - self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] - ) -> None: - assert self.transport is not None - data_len = len(data) - debug = log.isEnabledFor(logging.DEBUG) - - if data_len > _MAX_MSG_ABSOLUTE: - # Guard against oversized packets to ensure bad implementations cannot overwhelm - # the system. - if debug: - log.debug( - "Discarding incoming packet with length %s, which is larger " - "than the absolute maximum size of %s", - data_len, - _MAX_MSG_ABSOLUTE, - ) - return - - now = current_time_millis() - if ( - self.data == data - and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time - and self.last_message is not None - and not self.last_message.has_qu_question() - ): - # Guard against duplicate packets - if debug: - log.debug( - 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', - addrs, - self.sock_description, - data_len, - data, - ) - return - - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () - if len(addrs) == 2: - # https://github.com/python/mypy/issues/1178 - addr, port = addrs # type: ignore - scope = None - else: - # https://github.com/python/mypy/issues/1178 - addr, port, flow, scope = addrs # type: ignore - if debug: - log.debug('IPv6 scope_id %d associated to the receiving interface', scope) - v6_flow_scope = (flow, scope) - - msg = DNSIncoming(data, (addr, port), scope, now) - self.data = data - self.last_time = now - self.last_message = msg - if msg.valid: - if debug: - log.debug( - 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', - addr, - port, - self.sock_description, - msg, - data_len, - data, - ) - else: - if debug: - log.debug( - 'Received from %r:%r [socket %s]: (%d bytes) [%r]', - addr, - port, - self.sock_description, - data_len, - data, - ) - return - - if not msg.is_query(): - self.zc.handle_response(msg) - return - - self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) - - def handle_query_or_defer( - self, - msg: DNSIncoming, - addr: str, - port: int, - transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - """Deal with incoming query packets. Provides a response if - possible.""" - if not msg.truncated: - self._respond_query(msg, addr, port, transport, v6_flow_scope) - return - - deferred = self._deferred.setdefault(addr, []) - # If we get the same packet we ignore it - for incoming in reversed(deferred): - if incoming.data == msg.data: - return - deferred.append(msg) - delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) - assert self.zc.loop is not None - self._cancel_any_timers_for_addr(addr) - self._timers[addr] = self.zc.loop.call_later( - delay, self._respond_query, None, addr, port, transport, v6_flow_scope - ) - - def _cancel_any_timers_for_addr(self, addr: str) -> None: - """Cancel any future truncated packet timers for the address.""" - if addr in self._timers: - self._timers.pop(addr).cancel() - - def _respond_query( - self, - msg: Optional[DNSIncoming], - addr: str, - port: int, - transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - """Respond to a query and reassemble any truncated deferred packets.""" - self._cancel_any_timers_for_addr(addr) - packets = self._deferred.pop(addr, []) - if msg: - packets.append(msg) - - self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) - - def error_received(self, exc: Exception) -> None: - """Likely socket closed or IPv6.""" - # We preformat the message string with the socket as we want - # log_exception_once to log a warrning message once PER EACH - # different socket in case there are problems with multiple - # sockets - msg_str = f"Error with socket {self.sock_description}): %s" - self.log_exception_once(exc, msg_str, exc) - - def connection_made(self, transport: asyncio.BaseTransport) -> None: - wrapped_transport = _make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) - self.transport = wrapped_transport - self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" - - def connection_lost(self, exc: Optional[Exception]) -> None: - """Handle connection lost.""" - - def async_send_with_transport( log_debug: bool, transport: _WrappedTransport, diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py new file mode 100644 index 000000000..689b2aa5c --- /dev/null +++ b/src/zeroconf/_engine.py @@ -0,0 +1,368 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import asyncio +import itertools +import logging +import random +import socket +import threading +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast + +from ._logger import QuietLogger, log +from ._protocol.incoming import DNSIncoming +from ._updates import RecordUpdate +from ._utils.asyncio import get_running_loop, run_coro_with_timeout +from ._utils.time import current_time_millis, millis_to_seconds +from .const import ( + _CACHE_CLEANUP_INTERVAL, + _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, + _MAX_MSG_ABSOLUTE, +) + +if TYPE_CHECKING: + from ._core import Zeroconf + +_TC_DELAY_RANDOM_INTERVAL = (400, 500) + +_CLOSE_TIMEOUT = 3000 # ms + + +class _WrappedTransport: + """A wrapper for transports.""" + + __slots__ = ( + 'transport', + 'is_ipv6', + 'sock', + 'fileno', + 'sock_name', + ) + + def __init__( + self, + transport: asyncio.DatagramTransport, + is_ipv6: bool, + sock: socket.socket, + fileno: int, + sock_name: Any, + ) -> None: + """Initialize the wrapped transport. + + These attributes are used when sending packets. + """ + self.transport = transport + self.is_ipv6 = is_ipv6 + self.sock = sock + self.fileno = fileno + self.sock_name = sock_name + + +def _make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: + """Make a wrapped transport.""" + sock: socket.socket = transport.get_extra_info('socket') + return _WrappedTransport( + transport=transport, + is_ipv6=sock.family == socket.AF_INET6, + sock=sock, + fileno=sock.fileno(), + sock_name=sock.getsockname(), + ) + + +class AsyncEngine: + """An engine wraps sockets in the event loop.""" + + __slots__ = ( + 'loop', + 'zc', + 'protocols', + 'readers', + 'senders', + 'running_event', + '_listen_socket', + '_respond_sockets', + '_cleanup_timer', + ) + + def __init__( + self, + zeroconf: 'Zeroconf', + listen_socket: Optional[socket.socket], + respond_sockets: List[socket.socket], + ) -> None: + self.loop: Optional[asyncio.AbstractEventLoop] = None + self.zc = zeroconf + self.protocols: List[AsyncListener] = [] + self.readers: List[_WrappedTransport] = [] + self.senders: List[_WrappedTransport] = [] + self.running_event: Optional[asyncio.Event] = None + self._listen_socket = listen_socket + self._respond_sockets = respond_sockets + self._cleanup_timer: Optional[asyncio.TimerHandle] = None + + def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: + """Set up the instance.""" + self.loop = loop + self.running_event = asyncio.Event() + self.loop.create_task(self._async_setup(loop_thread_ready)) + + async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: + """Set up the instance.""" + assert self.loop is not None + self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) + await self._async_create_endpoints() + assert self.running_event is not None + self.running_event.set() + if loop_thread_ready: + loop_thread_ready.set() + + async def _async_create_endpoints(self) -> None: + """Create endpoints to send and receive.""" + assert self.loop is not None + loop = self.loop + reader_sockets = [] + sender_sockets = [] + if self._listen_socket: + reader_sockets.append(self._listen_socket) + for s in self._respond_sockets: + if s not in reader_sockets: + reader_sockets.append(s) + sender_sockets.append(s) + + for s in reader_sockets: + transport, protocol = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) + self.protocols.append(cast(AsyncListener, protocol)) + self.readers.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + if s in sender_sockets: + self.senders.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + + def _async_cache_cleanup(self) -> None: + """Periodic cache cleanup.""" + now = current_time_millis() + self.zc.question_history.async_expire(now) + self.zc.record_manager.async_updates( + now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] + ) + self.zc.record_manager.async_updates_complete(False) + assert self.loop is not None + self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) + + async def _async_close(self) -> None: + """Cancel and wait for the cleanup task to finish.""" + self._async_shutdown() + await asyncio.sleep(0) # flush out any call soons + assert self._cleanup_timer is not None + self._cleanup_timer.cancel() + + def _async_shutdown(self) -> None: + """Shutdown transports and sockets.""" + assert self.running_event is not None + self.running_event.clear() + for wrapped_transport in itertools.chain(self.senders, self.readers): + wrapped_transport.transport.close() + + def close(self) -> None: + """Close from sync context. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `_async_close` cannot be completed. + """ + assert self.loop is not None + # Guard against Zeroconf.close() being called from the eventloop + if get_running_loop() == self.loop: + self._async_shutdown() + return + if not self.loop.is_running(): + return + run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) + + +class AsyncListener(asyncio.Protocol, QuietLogger): + + """A Listener is used by this module to listen on the multicast + group to which DNS messages are sent, allowing the implementation + to cache information as it arrives. + + It requires registration with an Engine object in order to have + the read() method called when a socket is available for reading.""" + + __slots__ = ('zc', 'data', 'last_time', 'transport', 'sock_description', '_deferred', '_timers') + + def __init__(self, zc: 'Zeroconf') -> None: + self.zc = zc + self.data: Optional[bytes] = None + self.last_time: float = 0 + self.last_message: Optional[DNSIncoming] = None + self.transport: Optional[_WrappedTransport] = None + self.sock_description: Optional[str] = None + self._deferred: Dict[str, List[DNSIncoming]] = {} + self._timers: Dict[str, asyncio.TimerHandle] = {} + super().__init__() + + def datagram_received( + self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] + ) -> None: + assert self.transport is not None + data_len = len(data) + debug = log.isEnabledFor(logging.DEBUG) + + if data_len > _MAX_MSG_ABSOLUTE: + # Guard against oversized packets to ensure bad implementations cannot overwhelm + # the system. + if debug: + log.debug( + "Discarding incoming packet with length %s, which is larger " + "than the absolute maximum size of %s", + data_len, + _MAX_MSG_ABSOLUTE, + ) + return + + now = current_time_millis() + if ( + self.data == data + and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time + and self.last_message is not None + and not self.last_message.has_qu_question() + ): + # Guard against duplicate packets + if debug: + log.debug( + 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', + addrs, + self.sock_description, + data_len, + data, + ) + return + + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + if len(addrs) == 2: + # https://github.com/python/mypy/issues/1178 + addr, port = addrs # type: ignore + scope = None + else: + # https://github.com/python/mypy/issues/1178 + addr, port, flow, scope = addrs # type: ignore + if debug: # pragma: no branch + log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + v6_flow_scope = (flow, scope) + + msg = DNSIncoming(data, (addr, port), scope, now) + self.data = data + self.last_time = now + self.last_message = msg + if msg.valid: + if debug: + log.debug( + 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', + addr, + port, + self.sock_description, + msg, + data_len, + data, + ) + else: + if debug: + log.debug( + 'Received from %r:%r [socket %s]: (%d bytes) [%r]', + addr, + port, + self.sock_description, + data_len, + data, + ) + return + + if not msg.is_query(): + self.zc.handle_response(msg) + return + + self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) + + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Deal with incoming query packets. Provides a response if + possible.""" + if not msg.truncated: + self._respond_query(msg, addr, port, transport, v6_flow_scope) + return + + deferred = self._deferred.setdefault(addr, []) + # If we get the same packet we ignore it + for incoming in reversed(deferred): + if incoming.data == msg.data: + return + deferred.append(msg) + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) + assert self.zc.loop is not None + self._cancel_any_timers_for_addr(addr) + self._timers[addr] = self.zc.loop.call_later( + delay, self._respond_query, None, addr, port, transport, v6_flow_scope + ) + + def _cancel_any_timers_for_addr(self, addr: str) -> None: + """Cancel any future truncated packet timers for the address.""" + if addr in self._timers: + self._timers.pop(addr).cancel() + + def _respond_query( + self, + msg: Optional[DNSIncoming], + addr: str, + port: int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Respond to a query and reassemble any truncated deferred packets.""" + self._cancel_any_timers_for_addr(addr) + packets = self._deferred.pop(addr, []) + if msg: + packets.append(msg) + + self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) + + def error_received(self, exc: Exception) -> None: + """Likely socket closed or IPv6.""" + # We preformat the message string with the socket as we want + # log_exception_once to log a warrning message once PER EACH + # different socket in case there are problems with multiple + # sockets + msg_str = f"Error with socket {self.sock_description}): %s" + self.log_exception_once(exc, msg_str, exc) + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + wrapped_transport = _make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + self.transport = wrapped_transport + self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" + + def connection_lost(self, exc: Optional[Exception]) -> None: + """Handle connection lost.""" diff --git a/tests/conftest.py b/tests/conftest.py index 71b00d48d..34fdeb724 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,11 @@ """ conftest for zeroconf tests. """ import threading -import unittest +from unittest.mock import patch import pytest -from zeroconf import _core, const +from zeroconf import _core, _engine, const @pytest.fixture(autouse=True) @@ -23,9 +23,7 @@ def verify_threads_ended(): @pytest.fixture def run_isolated(): """Change the mDNS port to run the test in isolation.""" - with unittest.mock.patch.object(_core, "_MDNS_PORT", 5454), unittest.mock.patch.object( - const, "_MDNS_PORT", 5454 - ): + with patch.object(_core, "_MDNS_PORT", 5454), patch.object(const, "_MDNS_PORT", 5454): yield @@ -36,7 +34,7 @@ def disable_duplicate_packet_suppression(): Some tests run too slowly because of the duplicate packet suppression. """ - with unittest.mock.patch.object( - _core, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0 - ), unittest.mock.patch.object(const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0): + with patch.object(_engine, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0), patch.object( + const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0 + ): yield diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 72e550c62..d13701ec4 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -20,7 +20,7 @@ DNSPointer, DNSQuestion, Zeroconf, - _core, + _engine, _handlers, const, current_time_millis, @@ -1118,7 +1118,7 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: @patch.object(_handlers, '_DNS_PTR_MIN_TTL', 1) -@patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01) +@patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01) def test_service_browser_expire_callbacks(): """Test that the ServiceBrowser matching does not match partial names.""" # instantiate a zeroconf instance diff --git a/tests/test_core.py b/tests/test_core.py index ad1163284..8f5322bd2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,7 +4,6 @@ """ Unit tests for zeroconf._core """ import asyncio -import itertools import logging import os import socket @@ -13,14 +12,13 @@ import time import unittest import unittest.mock -from typing import Set, cast -from unittest.mock import MagicMock, patch +from typing import cast +from unittest.mock import patch import pytest import zeroconf as r -from zeroconf import NotRunningException, Zeroconf, _core, const, current_time_millis -from zeroconf._protocol import outgoing +from zeroconf import NotRunningException, Zeroconf, const, current_time_millis from zeroconf.asyncio import AsyncZeroconf from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 @@ -47,60 +45,6 @@ async def make_query(): asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() -# This test uses asyncio because it needs to access the cache directly -# which is not threadsafe -@pytest.mark.asyncio -async def test_reaper(): - with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - zeroconf = aiozc.zeroconf - cache = zeroconf.cache - original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) - record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) - question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) - now = r.current_time_millis() - other_known_answers: Set[r.DNSRecord] = { - r.DNSPointer( - "_hap._tcp.local.", - const._TYPE_PTR, - const._CLASS_IN, - 10000, - 'known-to-other._hap._tcp.local.', - ) - } - zeroconf.question_history.add_question_at_time(question, now, other_known_answers) - assert zeroconf.question_history.suppresses(question, now, other_known_answers) - entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) - await asyncio.sleep(1.2) - entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) - assert zeroconf.cache.get(record_with_1s_ttl) is None - await aiozc.async_close() - assert not zeroconf.question_history.suppresses(question, now, other_known_answers) - assert entries != original_entries - assert entries_with_cache != original_entries - assert record_with_10s_ttl in entries - assert record_with_1s_ttl not in entries - - -@pytest.mark.asyncio -async def test_reaper_aborts_when_done(): - """Ensure cache cleanup stops when zeroconf is done.""" - with patch.object(_core, "_CACHE_CLEANUP_INTERVAL", 0.01): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - zeroconf = aiozc.zeroconf - record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') - zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) - assert zeroconf.cache.get(record_with_10s_ttl) is not None - assert zeroconf.cache.get(record_with_1s_ttl) is not None - await aiozc.async_close() - await asyncio.sleep(1.2) - assert zeroconf.cache.get(record_with_10s_ttl) is not None - assert zeroconf.cache.get(record_with_1s_ttl) is not None - - class Framework(unittest.TestCase): def test_launch_and_close(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) @@ -691,183 +635,6 @@ async def test_multiple_sync_instances_stared_from_async_close(): await asyncio.sleep(0) -def test_guard_against_oversized_packets(): - """Ensure we do not process oversized packets. - - These packets can quickly overwhelm the system. - """ - zc = Zeroconf(interfaces=['127.0.0.1']) - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for i in range(5000): - generated.add_answer_at_time( - r.DNSText( - "packet{i}.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ), - 0, - ) - - try: - # We are patching to generate an oversized packet - with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( - outgoing, "_MAX_MSG_TYPICAL", 100000 - ): - over_sized_packet = generated.packets()[0] - assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE - except AttributeError: - # cannot patch with cython - zc.close() - return - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - okpacket_record = r.DNSText( - "okpacket.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - - generated.add_answer_at_time( - okpacket_record, - 0, - ) - ok_packet = generated.packets()[0] - - # We cannot test though the network interface as some operating systems - # will guard against the oversized packet and we won't see it. - listener = _core.AsyncListener(zc) - listener.transport = unittest.mock.MagicMock() - - listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) - assert zc.cache.async_get_unique(okpacket_record) is not None - - listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) - assert ( - zc.cache.async_get_unique( - r.DNSText( - "packet0.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - ) - is None - ) - - logging.getLogger('zeroconf').setLevel(logging.INFO) - - listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) - assert ( - zc.cache.async_get_unique( - r.DNSText( - "packet0.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - ) - is None - ) - - zc.close() - - -def test_guard_against_duplicate_packets(): - """Ensure we do not process duplicate packets. - These packets can quickly overwhelm the system. - """ - zc = Zeroconf(interfaces=['127.0.0.1']) - listener = _core.AsyncListener(zc) - listener.transport = MagicMock() - - query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - query.add_question(question) - packet_with_qm_question = query.packets()[0] - - query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question3 = r.DNSQuestion("x._ay._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - query3.add_question(question3) - packet_with_qm_question2 = query3.packets()[0] - - query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question2 = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - question2.unicast = True - query2.add_question(question2) - packet_with_qu_question = query2.packets()[0] - - addrs = ("1.2.3.4", 43) - - with patch.object(_core, "current_time_millis") as _current_time_millis, patch.object( - listener, "handle_query_or_defer" - ) as _handle_query_or_defer: - start_time = current_time_millis() - - _current_time_millis.return_value = start_time - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - # Now walk time forward 1000 seconds - _current_time_millis.return_value = start_time + 1000 - # Now call with the same packet again and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question2, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet with qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call again with the same packet that has a qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - log.setLevel(logging.WARNING) - - # Call with the QM packet again - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - # Now call with garbage - listener.datagram_received(b'garbage', addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - zc.close() - - def test_shutdown_while_register_in_process(): """Test we can shutdown while registering a service in another thread.""" diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 000000000..6ac00570b --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python + + +""" Unit tests for zeroconf._core """ + +import asyncio +import itertools +import logging +import unittest +import unittest.mock +from typing import Set +from unittest.mock import MagicMock, patch + +import pytest + +import zeroconf as r +from zeroconf import Zeroconf, _engine, const, current_time_millis +from zeroconf._protocol import outgoing +from zeroconf.asyncio import AsyncZeroconf + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +def threadsafe_query(zc, protocol, *args): + async def make_query(): + protocol.handle_query_or_defer(*args) + + asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_reaper(): + with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf = aiozc.zeroconf + cache = zeroconf.cache + original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers: Set[r.DNSRecord] = { + r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + 'known-to-other._hap._tcp.local.', + ) + } + zeroconf.question_history.add_question_at_time(question, now, other_known_answers) + assert zeroconf.question_history.suppresses(question, now, other_known_answers) + entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + await asyncio.sleep(1.2) + entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + assert zeroconf.cache.get(record_with_1s_ttl) is None + await aiozc.async_close() + assert not zeroconf.question_history.suppresses(question, now, other_known_answers) + assert entries != original_entries + assert entries_with_cache != original_entries + assert record_with_10s_ttl in entries + assert record_with_1s_ttl not in entries + + +@pytest.mark.asyncio +async def test_reaper_aborts_when_done(): + """Ensure cache cleanup stops when zeroconf is done.""" + with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf = aiozc.zeroconf + record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') + record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) + assert zeroconf.cache.get(record_with_10s_ttl) is not None + assert zeroconf.cache.get(record_with_1s_ttl) is not None + await aiozc.async_close() + await asyncio.sleep(1.2) + assert zeroconf.cache.get(record_with_10s_ttl) is not None + assert zeroconf.cache.get(record_with_1s_ttl) is not None + + +def test_guard_against_oversized_packets(): + """Ensure we do not process oversized packets. + + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for i in range(5000): + generated.add_answer_at_time( + r.DNSText( + "packet{i}.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ), + 0, + ) + + try: + # We are patching to generate an oversized packet + with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( + outgoing, "_MAX_MSG_TYPICAL", 100000 + ): + over_sized_packet = generated.packets()[0] + assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE + except AttributeError: + # cannot patch with cython + zc.close() + return + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + okpacket_record = r.DNSText( + "okpacket.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + + generated.add_answer_at_time( + okpacket_record, + 0, + ) + ok_packet = generated.packets()[0] + + # We cannot test though the network interface as some operating systems + # will guard against the oversized packet and we won't see it. + listener = _engine.AsyncListener(zc) + listener.transport = unittest.mock.MagicMock() + + listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) + assert zc.cache.async_get_unique(okpacket_record) is not None + + listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + + logging.getLogger('zeroconf').setLevel(logging.INFO) + + listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + + zc.close() + + +def test_guard_against_duplicate_packets(): + """Ensure we do not process duplicate packets. + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + listener = _engine.AsyncListener(zc) + listener.transport = MagicMock() + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + packet_with_qm_question = query.packets()[0] + + query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question3 = r.DNSQuestion("x._ay._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query3.add_question(question3) + packet_with_qm_question2 = query3.packets()[0] + + query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question2 = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + question2.unicast = True + query2.add_question(question2) + packet_with_qu_question = query2.packets()[0] + + addrs = ("1.2.3.4", 43) + + with patch.object(_engine, "current_time_millis") as _current_time_millis, patch.object( + listener, "handle_query_or_defer" + ) as _handle_query_or_defer: + start_time = current_time_millis() + + _current_time_millis.return_value = start_time + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now walk time forward 1000 seconds + _current_time_millis.return_value = start_time + 1000 + # Now call with the same packet again and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question2, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet with qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call again with the same packet that has a qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + log.setLevel(logging.WARNING) + + # Call with the QM packet again + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now call with garbage + listener.datagram_received(b'garbage', addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + zc.close() diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 0bd3d6e40..a03855157 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -14,7 +14,7 @@ import pytest from zeroconf import EventLoopBlocked -from zeroconf._core import _CLOSE_TIMEOUT +from zeroconf._engine import _CLOSE_TIMEOUT from zeroconf._utils import asyncio as aioutils from zeroconf.const import _LOADED_SYSTEM_TIMEOUT From e9cc5c83f3808d23d534de743bd35bc1372c5641 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 10:47:42 -0500 Subject: [PATCH 0884/1433] chore: prepare _engine.py to be able to be cythonized (#1219) --- src/zeroconf/_engine.py | 19 +++++++++++++++---- tests/test_engine.py | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 689b2aa5c..00ecf51ac 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -150,7 +150,9 @@ async def _async_create_endpoints(self) -> None: sender_sockets.append(s) for s in reader_sockets: - transport, protocol = await loop.create_datagram_endpoint(lambda: AsyncListener(self.zc), sock=s) + transport, protocol = await loop.create_datagram_endpoint( + lambda: AsyncListener(self.zc), sock=s # type: ignore[arg-type, return-value] + ) self.protocols.append(cast(AsyncListener, protocol)) self.readers.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) if s in sender_sockets: @@ -198,7 +200,7 @@ def close(self) -> None: run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) -class AsyncListener(asyncio.Protocol, QuietLogger): +class AsyncListener: """A Listener is used by this module to listen on the multicast group to which DNS messages are sent, allowing the implementation @@ -207,7 +209,16 @@ class AsyncListener(asyncio.Protocol, QuietLogger): It requires registration with an Engine object in order to have the read() method called when a socket is available for reading.""" - __slots__ = ('zc', 'data', 'last_time', 'transport', 'sock_description', '_deferred', '_timers') + __slots__ = ( + 'zc', + 'data', + 'last_time', + 'last_message', + 'transport', + 'sock_description', + '_deferred', + '_timers', + ) def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc @@ -357,7 +368,7 @@ def error_received(self, exc: Exception) -> None: # different socket in case there are problems with multiple # sockets msg_str = f"Error with socket {self.sock_description}): %s" - self.log_exception_once(exc, msg_str, exc) + QuietLogger.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: wrapped_transport = _make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) diff --git a/tests/test_engine.py b/tests/test_engine.py index 6ac00570b..2c7e14beb 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,7 +8,7 @@ import logging import unittest import unittest.mock -from typing import Set +from typing import Set, Tuple, Union from unittest.mock import MagicMock, patch import pytest @@ -16,6 +16,7 @@ import zeroconf as r from zeroconf import Zeroconf, _engine, const, current_time_millis from zeroconf._protocol import outgoing +from zeroconf._protocol.incoming import DNSIncoming from zeroconf.asyncio import AsyncZeroconf log = logging.getLogger('zeroconf') @@ -188,7 +189,20 @@ def test_guard_against_duplicate_packets(): These packets can quickly overwhelm the system. """ zc = Zeroconf(interfaces=['127.0.0.1']) - listener = _engine.AsyncListener(zc) + + class SubListener(_engine.AsyncListener): + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _engine._WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Handle a query or defer it for later processing.""" + super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + + listener = SubListener(zc) listener.transport = MagicMock() query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) From f4c17ebc5109afab2afd5432e372c77ec7b673c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 11:49:22 -0500 Subject: [PATCH 0885/1433] chore: split _engine.py into _transport.py and _listener.py (#1222) --- src/zeroconf/_core.py | 3 +- src/zeroconf/_engine.py | 243 ++----------------------------------- src/zeroconf/_listener.py | 216 +++++++++++++++++++++++++++++++++ src/zeroconf/_transport.py | 67 ++++++++++ tests/conftest.py | 4 +- tests/test_engine.py | 209 +------------------------------ tests/test_listener.py | 219 +++++++++++++++++++++++++++++++++ 7 files changed, 518 insertions(+), 443 deletions(-) create mode 100644 src/zeroconf/_listener.py create mode 100644 src/zeroconf/_transport.py create mode 100644 tests/test_listener.py diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 1548ec5b9..0f9b45dfe 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -29,7 +29,7 @@ from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType -from ._engine import AsyncEngine, _WrappedTransport +from ._engine import AsyncEngine from ._exceptions import NonUniqueNameException, NotRunningException from ._handlers import ( MulticastOutgoingQueue, @@ -46,6 +46,7 @@ from ._services.browser import ServiceBrowser from ._services.info import ServiceInfo, instance_name_from_service_info from ._services.registry import ServiceRegistry +from ._transport import _WrappedTransport from ._updates import RecordUpdateListener from ._utils.asyncio import ( await_awaitable, diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 00ecf51ac..44435750e 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -22,71 +22,23 @@ import asyncio import itertools -import logging -import random import socket import threading -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, List, Optional, cast -from ._logger import QuietLogger, log -from ._protocol.incoming import DNSIncoming from ._updates import RecordUpdate from ._utils.asyncio import get_running_loop, run_coro_with_timeout -from ._utils.time import current_time_millis, millis_to_seconds -from .const import ( - _CACHE_CLEANUP_INTERVAL, - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, - _MAX_MSG_ABSOLUTE, -) +from ._utils.time import current_time_millis +from .const import _CACHE_CLEANUP_INTERVAL if TYPE_CHECKING: from ._core import Zeroconf -_TC_DELAY_RANDOM_INTERVAL = (400, 500) - -_CLOSE_TIMEOUT = 3000 # ms - - -class _WrappedTransport: - """A wrapper for transports.""" - - __slots__ = ( - 'transport', - 'is_ipv6', - 'sock', - 'fileno', - 'sock_name', - ) - - def __init__( - self, - transport: asyncio.DatagramTransport, - is_ipv6: bool, - sock: socket.socket, - fileno: int, - sock_name: Any, - ) -> None: - """Initialize the wrapped transport. - - These attributes are used when sending packets. - """ - self.transport = transport - self.is_ipv6 = is_ipv6 - self.sock = sock - self.fileno = fileno - self.sock_name = sock_name +from ._listener import AsyncListener +from ._transport import _WrappedTransport, make_wrapped_transport -def _make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: - """Make a wrapped transport.""" - sock: socket.socket = transport.get_extra_info('socket') - return _WrappedTransport( - transport=transport, - is_ipv6=sock.family == socket.AF_INET6, - sock=sock, - fileno=sock.fileno(), - sock_name=sock.getsockname(), - ) +_CLOSE_TIMEOUT = 3000 # ms class AsyncEngine: @@ -154,9 +106,9 @@ async def _async_create_endpoints(self) -> None: lambda: AsyncListener(self.zc), sock=s # type: ignore[arg-type, return-value] ) self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) if s in sender_sockets: - self.senders.append(_make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" @@ -198,182 +150,3 @@ def close(self) -> None: if not self.loop.is_running(): return run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) - - -class AsyncListener: - - """A Listener is used by this module to listen on the multicast - group to which DNS messages are sent, allowing the implementation - to cache information as it arrives. - - It requires registration with an Engine object in order to have - the read() method called when a socket is available for reading.""" - - __slots__ = ( - 'zc', - 'data', - 'last_time', - 'last_message', - 'transport', - 'sock_description', - '_deferred', - '_timers', - ) - - def __init__(self, zc: 'Zeroconf') -> None: - self.zc = zc - self.data: Optional[bytes] = None - self.last_time: float = 0 - self.last_message: Optional[DNSIncoming] = None - self.transport: Optional[_WrappedTransport] = None - self.sock_description: Optional[str] = None - self._deferred: Dict[str, List[DNSIncoming]] = {} - self._timers: Dict[str, asyncio.TimerHandle] = {} - super().__init__() - - def datagram_received( - self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] - ) -> None: - assert self.transport is not None - data_len = len(data) - debug = log.isEnabledFor(logging.DEBUG) - - if data_len > _MAX_MSG_ABSOLUTE: - # Guard against oversized packets to ensure bad implementations cannot overwhelm - # the system. - if debug: - log.debug( - "Discarding incoming packet with length %s, which is larger " - "than the absolute maximum size of %s", - data_len, - _MAX_MSG_ABSOLUTE, - ) - return - - now = current_time_millis() - if ( - self.data == data - and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time - and self.last_message is not None - and not self.last_message.has_qu_question() - ): - # Guard against duplicate packets - if debug: - log.debug( - 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', - addrs, - self.sock_description, - data_len, - data, - ) - return - - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () - if len(addrs) == 2: - # https://github.com/python/mypy/issues/1178 - addr, port = addrs # type: ignore - scope = None - else: - # https://github.com/python/mypy/issues/1178 - addr, port, flow, scope = addrs # type: ignore - if debug: # pragma: no branch - log.debug('IPv6 scope_id %d associated to the receiving interface', scope) - v6_flow_scope = (flow, scope) - - msg = DNSIncoming(data, (addr, port), scope, now) - self.data = data - self.last_time = now - self.last_message = msg - if msg.valid: - if debug: - log.debug( - 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', - addr, - port, - self.sock_description, - msg, - data_len, - data, - ) - else: - if debug: - log.debug( - 'Received from %r:%r [socket %s]: (%d bytes) [%r]', - addr, - port, - self.sock_description, - data_len, - data, - ) - return - - if not msg.is_query(): - self.zc.handle_response(msg) - return - - self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) - - def handle_query_or_defer( - self, - msg: DNSIncoming, - addr: str, - port: int, - transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - """Deal with incoming query packets. Provides a response if - possible.""" - if not msg.truncated: - self._respond_query(msg, addr, port, transport, v6_flow_scope) - return - - deferred = self._deferred.setdefault(addr, []) - # If we get the same packet we ignore it - for incoming in reversed(deferred): - if incoming.data == msg.data: - return - deferred.append(msg) - delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) - assert self.zc.loop is not None - self._cancel_any_timers_for_addr(addr) - self._timers[addr] = self.zc.loop.call_later( - delay, self._respond_query, None, addr, port, transport, v6_flow_scope - ) - - def _cancel_any_timers_for_addr(self, addr: str) -> None: - """Cancel any future truncated packet timers for the address.""" - if addr in self._timers: - self._timers.pop(addr).cancel() - - def _respond_query( - self, - msg: Optional[DNSIncoming], - addr: str, - port: int, - transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - """Respond to a query and reassemble any truncated deferred packets.""" - self._cancel_any_timers_for_addr(addr) - packets = self._deferred.pop(addr, []) - if msg: - packets.append(msg) - - self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) - - def error_received(self, exc: Exception) -> None: - """Likely socket closed or IPv6.""" - # We preformat the message string with the socket as we want - # log_exception_once to log a warrning message once PER EACH - # different socket in case there are problems with multiple - # sockets - msg_str = f"Error with socket {self.sock_description}): %s" - QuietLogger.log_exception_once(exc, msg_str, exc) - - def connection_made(self, transport: asyncio.BaseTransport) -> None: - wrapped_transport = _make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) - self.transport = wrapped_transport - self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" - - def connection_lost(self, exc: Optional[Exception]) -> None: - """Handle connection lost.""" diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py new file mode 100644 index 000000000..97bcf007e --- /dev/null +++ b/src/zeroconf/_listener.py @@ -0,0 +1,216 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import asyncio +import logging +import random +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union, cast + +from ._logger import QuietLogger, log +from ._protocol.incoming import DNSIncoming +from ._transport import _WrappedTransport, make_wrapped_transport +from ._utils.time import current_time_millis, millis_to_seconds +from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE + +if TYPE_CHECKING: + from ._core import Zeroconf + +_TC_DELAY_RANDOM_INTERVAL = (400, 500) + + +class AsyncListener: + + """A Listener is used by this module to listen on the multicast + group to which DNS messages are sent, allowing the implementation + to cache information as it arrives. + + It requires registration with an Engine object in order to have + the read() method called when a socket is available for reading.""" + + __slots__ = ( + 'zc', + 'data', + 'last_time', + 'last_message', + 'transport', + 'sock_description', + '_deferred', + '_timers', + ) + + def __init__(self, zc: 'Zeroconf') -> None: + self.zc = zc + self.data: Optional[bytes] = None + self.last_time: float = 0 + self.last_message: Optional[DNSIncoming] = None + self.transport: Optional[_WrappedTransport] = None + self.sock_description: Optional[str] = None + self._deferred: Dict[str, List[DNSIncoming]] = {} + self._timers: Dict[str, asyncio.TimerHandle] = {} + super().__init__() + + def datagram_received( + self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] + ) -> None: + assert self.transport is not None + data_len = len(data) + debug = log.isEnabledFor(logging.DEBUG) + + if data_len > _MAX_MSG_ABSOLUTE: + # Guard against oversized packets to ensure bad implementations cannot overwhelm + # the system. + if debug: + log.debug( + "Discarding incoming packet with length %s, which is larger " + "than the absolute maximum size of %s", + data_len, + _MAX_MSG_ABSOLUTE, + ) + return + + now = current_time_millis() + if ( + self.data == data + and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time + and self.last_message is not None + and not self.last_message.has_qu_question() + ): + # Guard against duplicate packets + if debug: + log.debug( + 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', + addrs, + self.sock_description, + data_len, + data, + ) + return + + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + if len(addrs) == 2: + # https://github.com/python/mypy/issues/1178 + addr, port = addrs # type: ignore + scope = None + else: + # https://github.com/python/mypy/issues/1178 + addr, port, flow, scope = addrs # type: ignore + if debug: # pragma: no branch + log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + v6_flow_scope = (flow, scope) + + msg = DNSIncoming(data, (addr, port), scope, now) + self.data = data + self.last_time = now + self.last_message = msg + if msg.valid: + if debug: + log.debug( + 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', + addr, + port, + self.sock_description, + msg, + data_len, + data, + ) + else: + if debug: + log.debug( + 'Received from %r:%r [socket %s]: (%d bytes) [%r]', + addr, + port, + self.sock_description, + data_len, + data, + ) + return + + if not msg.is_query(): + self.zc.handle_response(msg) + return + + self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) + + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Deal with incoming query packets. Provides a response if + possible.""" + if not msg.truncated: + self._respond_query(msg, addr, port, transport, v6_flow_scope) + return + + deferred = self._deferred.setdefault(addr, []) + # If we get the same packet we ignore it + for incoming in reversed(deferred): + if incoming.data == msg.data: + return + deferred.append(msg) + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) + assert self.zc.loop is not None + self._cancel_any_timers_for_addr(addr) + self._timers[addr] = self.zc.loop.call_later( + delay, self._respond_query, None, addr, port, transport, v6_flow_scope + ) + + def _cancel_any_timers_for_addr(self, addr: str) -> None: + """Cancel any future truncated packet timers for the address.""" + if addr in self._timers: + self._timers.pop(addr).cancel() + + def _respond_query( + self, + msg: Optional[DNSIncoming], + addr: str, + port: int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Respond to a query and reassemble any truncated deferred packets.""" + self._cancel_any_timers_for_addr(addr) + packets = self._deferred.pop(addr, []) + if msg: + packets.append(msg) + + self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) + + def error_received(self, exc: Exception) -> None: + """Likely socket closed or IPv6.""" + # We preformat the message string with the socket as we want + # log_exception_once to log a warrning message once PER EACH + # different socket in case there are problems with multiple + # sockets + msg_str = f"Error with socket {self.sock_description}): %s" + QuietLogger.log_exception_once(exc, msg_str, exc) + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + wrapped_transport = make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + self.transport = wrapped_transport + self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" + + def connection_lost(self, exc: Optional[Exception]) -> None: + """Handle connection lost.""" diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py new file mode 100644 index 000000000..7f6d7ac8c --- /dev/null +++ b/src/zeroconf/_transport.py @@ -0,0 +1,67 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import asyncio +import socket +from typing import Any + + +class _WrappedTransport: + """A wrapper for transports.""" + + __slots__ = ( + 'transport', + 'is_ipv6', + 'sock', + 'fileno', + 'sock_name', + ) + + def __init__( + self, + transport: asyncio.DatagramTransport, + is_ipv6: bool, + sock: socket.socket, + fileno: int, + sock_name: Any, + ) -> None: + """Initialize the wrapped transport. + + These attributes are used when sending packets. + """ + self.transport = transport + self.is_ipv6 = is_ipv6 + self.sock = sock + self.fileno = fileno + self.sock_name = sock_name + + +def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: + """Make a wrapped transport.""" + sock: socket.socket = transport.get_extra_info('socket') + return _WrappedTransport( + transport=transport, + is_ipv6=sock.family == socket.AF_INET6, + sock=sock, + fileno=sock.fileno(), + sock_name=sock.getsockname(), + ) diff --git a/tests/conftest.py b/tests/conftest.py index 34fdeb724..5cdff18e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest -from zeroconf import _core, _engine, const +from zeroconf import _core, _listener, const @pytest.fixture(autouse=True) @@ -34,7 +34,7 @@ def disable_duplicate_packet_suppression(): Some tests run too slowly because of the duplicate packet suppression. """ - with patch.object(_engine, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0), patch.object( + with patch.object(_listener, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0), patch.object( const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0 ): yield diff --git a/tests/test_engine.py b/tests/test_engine.py index 2c7e14beb..dc6674dd2 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,22 +1,18 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._core """ +""" Unit tests for zeroconf._engine """ import asyncio import itertools import logging -import unittest -import unittest.mock -from typing import Set, Tuple, Union -from unittest.mock import MagicMock, patch +from typing import Set +from unittest.mock import patch import pytest import zeroconf as r -from zeroconf import Zeroconf, _engine, const, current_time_millis -from zeroconf._protocol import outgoing -from zeroconf._protocol.incoming import DNSIncoming +from zeroconf import _engine, const from zeroconf.asyncio import AsyncZeroconf log = logging.getLogger('zeroconf') @@ -34,13 +30,6 @@ def teardown_module(): log.setLevel(original_logging_level) -def threadsafe_query(zc, protocol, *args): - async def make_query(): - protocol.handle_query_or_defer(*args) - - asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() - - # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio @@ -93,193 +82,3 @@ async def test_reaper_aborts_when_done(): await asyncio.sleep(1.2) assert zeroconf.cache.get(record_with_10s_ttl) is not None assert zeroconf.cache.get(record_with_1s_ttl) is not None - - -def test_guard_against_oversized_packets(): - """Ensure we do not process oversized packets. - - These packets can quickly overwhelm the system. - """ - zc = Zeroconf(interfaces=['127.0.0.1']) - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for i in range(5000): - generated.add_answer_at_time( - r.DNSText( - "packet{i}.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ), - 0, - ) - - try: - # We are patching to generate an oversized packet - with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( - outgoing, "_MAX_MSG_TYPICAL", 100000 - ): - over_sized_packet = generated.packets()[0] - assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE - except AttributeError: - # cannot patch with cython - zc.close() - return - - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - okpacket_record = r.DNSText( - "okpacket.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - - generated.add_answer_at_time( - okpacket_record, - 0, - ) - ok_packet = generated.packets()[0] - - # We cannot test though the network interface as some operating systems - # will guard against the oversized packet and we won't see it. - listener = _engine.AsyncListener(zc) - listener.transport = unittest.mock.MagicMock() - - listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) - assert zc.cache.async_get_unique(okpacket_record) is not None - - listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) - assert ( - zc.cache.async_get_unique( - r.DNSText( - "packet0.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - ) - is None - ) - - logging.getLogger('zeroconf').setLevel(logging.INFO) - - listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) - assert ( - zc.cache.async_get_unique( - r.DNSText( - "packet0.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - 500, - b'path=/~paulsm/', - ) - ) - is None - ) - - zc.close() - - -def test_guard_against_duplicate_packets(): - """Ensure we do not process duplicate packets. - These packets can quickly overwhelm the system. - """ - zc = Zeroconf(interfaces=['127.0.0.1']) - - class SubListener(_engine.AsyncListener): - def handle_query_or_defer( - self, - msg: DNSIncoming, - addr: str, - port: int, - transport: _engine._WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - ) -> None: - """Handle a query or defer it for later processing.""" - super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) - - listener = SubListener(zc) - listener.transport = MagicMock() - - query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - query.add_question(question) - packet_with_qm_question = query.packets()[0] - - query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question3 = r.DNSQuestion("x._ay._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - query3.add_question(question3) - packet_with_qm_question2 = query3.packets()[0] - - query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) - question2 = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) - question2.unicast = True - query2.add_question(question2) - packet_with_qu_question = query2.packets()[0] - - addrs = ("1.2.3.4", 43) - - with patch.object(_engine, "current_time_millis") as _current_time_millis, patch.object( - listener, "handle_query_or_defer" - ) as _handle_query_or_defer: - start_time = current_time_millis() - - _current_time_millis.return_value = start_time - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - # Now walk time forward 1000 seconds - _current_time_millis.return_value = start_time + 1000 - # Now call with the same packet again and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question2, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the different packet with qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call again with the same packet that has a qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - log.setLevel(logging.WARNING) - - # Call with the QM packet again - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - # Now call with garbage - listener.datagram_received(b'garbage', addrs) - _handle_query_or_defer.assert_not_called() - _handle_query_or_defer.reset_mock() - - zc.close() diff --git a/tests/test_listener.py b/tests/test_listener.py new file mode 100644 index 000000000..737b81118 --- /dev/null +++ b/tests/test_listener.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python + + +""" Unit tests for zeroconf._listener """ + +import logging +import unittest +import unittest.mock +from typing import Tuple, Union +from unittest.mock import MagicMock, patch + +import zeroconf as r +from zeroconf import Zeroconf, _engine, _listener, const, current_time_millis +from zeroconf._protocol import outgoing +from zeroconf._protocol.incoming import DNSIncoming + +log = logging.getLogger('zeroconf') +original_logging_level = logging.NOTSET + + +def setup_module(): + global original_logging_level + original_logging_level = log.level + log.setLevel(logging.DEBUG) + + +def teardown_module(): + if original_logging_level != logging.NOTSET: + log.setLevel(original_logging_level) + + +def test_guard_against_oversized_packets(): + """Ensure we do not process oversized packets. + + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for i in range(5000): + generated.add_answer_at_time( + r.DNSText( + "packet{i}.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ), + 0, + ) + + try: + # We are patching to generate an oversized packet + with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( + outgoing, "_MAX_MSG_TYPICAL", 100000 + ): + over_sized_packet = generated.packets()[0] + assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE + except AttributeError: + # cannot patch with cython + zc.close() + return + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + okpacket_record = r.DNSText( + "okpacket.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + + generated.add_answer_at_time( + okpacket_record, + 0, + ) + ok_packet = generated.packets()[0] + + # We cannot test though the network interface as some operating systems + # will guard against the oversized packet and we won't see it. + listener = _listener.AsyncListener(zc) + listener.transport = unittest.mock.MagicMock() + + listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) + assert zc.cache.async_get_unique(okpacket_record) is not None + + listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + + logging.getLogger('zeroconf').setLevel(logging.INFO) + + listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) + assert ( + zc.cache.async_get_unique( + r.DNSText( + "packet0.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + 500, + b'path=/~paulsm/', + ) + ) + is None + ) + + zc.close() + + +def test_guard_against_duplicate_packets(): + """Ensure we do not process duplicate packets. + These packets can quickly overwhelm the system. + """ + zc = Zeroconf(interfaces=['127.0.0.1']) + + class SubListener(_listener.AsyncListener): + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _engine._WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + ) -> None: + """Handle a query or defer it for later processing.""" + super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + + listener = SubListener(zc) + listener.transport = MagicMock() + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query.add_question(question) + packet_with_qm_question = query.packets()[0] + + query3 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question3 = r.DNSQuestion("x._ay._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + query3.add_question(question3) + packet_with_qm_question2 = query3.packets()[0] + + query2 = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + question2 = r.DNSQuestion("x._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + question2.unicast = True + query2.add_question(question2) + packet_with_qu_question = query2.packets()[0] + + addrs = ("1.2.3.4", 43) + + with patch.object(_listener, "current_time_millis") as _current_time_millis, patch.object( + listener, "handle_query_or_defer" + ) as _handle_query_or_defer: + start_time = current_time_millis() + + _current_time_millis.return_value = start_time + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now walk time forward 1000 seconds + _current_time_millis.return_value = start_time + 1000 + # Now call with the same packet again and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question2, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet and handle_query_or_defer should fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the different packet with qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call again with the same packet that has a qu question and handle_query_or_defer should fire + listener.datagram_received(packet_with_qu_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + log.setLevel(logging.WARNING) + + # Call with the QM packet again + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.reset_mock() + + # Now call with the same packet again and handle_query_or_defer should not fire + listener.datagram_received(packet_with_qm_question, addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + # Now call with garbage + listener.datagram_received(b'garbage', addrs) + _handle_query_or_defer.assert_not_called() + _handle_query_or_defer.reset_mock() + + zc.close() From 9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 12:02:42 -0500 Subject: [PATCH 0886/1433] feat: cythonize _listener.py to improve incoming message processing performance (#1220) --- build_ext.py | 1 + tests/test_core.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/build_ext.py b/build_ext.py index 0020f9fe6..c0042df2b 100644 --- a/build_ext.py +++ b/build_ext.py @@ -25,6 +25,7 @@ def build(setup_kwargs: Any) -> None: [ "src/zeroconf/_dns.py", "src/zeroconf/_cache.py", + "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", ], diff --git a/tests/test_core.py b/tests/test_core.py index 8f5322bd2..303e28ef3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,13 @@ from typing import cast from unittest.mock import patch +if sys.version_info[:3][1] < 8: + from unittest.mock import Mock + + AsyncMock = Mock +else: + from unittest.mock import AsyncMock + import pytest import zeroconf as r @@ -669,7 +676,7 @@ def _background_register(): @pytest.mark.asyncio @unittest.skipIf(sys.version_info[:3][1] < 8, 'Requires Python 3.8 or later to patch _async_setup') @patch("zeroconf._core._STARTUP_TIMEOUT", 0) -@patch("zeroconf._core.AsyncEngine._async_setup") +@patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): """Test we raise NotRunningException when waiting for startup that times out.""" aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) From 1901fb45b06ad2534e9455e50a44cd6608629ad9 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 14 Aug 2023 17:11:08 +0000 Subject: [PATCH 0887/1433] 0.77.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7ead6e9..c1ebcb0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.77.0 (2023-08-14) + +### Feature + +* Cythonize _listener.py to improve incoming message processing performance ([#1220](https://github.com/python-zeroconf/python-zeroconf/issues/1220)) ([`9efde8c`](https://github.com/python-zeroconf/python-zeroconf/commit/9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9)) + ## v0.76.0 (2023-08-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 568f4623c..f9d95b071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.76.0" +version = "0.77.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 39a6b3fc9..1c4807b16 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.76.0' +__version__ = '0.77.0' __license__ = 'LGPL' From f459856a0a61b8afa8a541926d7e15d51f8e4aea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 12:24:47 -0500 Subject: [PATCH 0888/1433] feat: add cython pxd file for _listener.py to improve incoming message processing performance (#1221) --- src/zeroconf/_listener.pxd | 24 ++++++++++++++++++++++++ src/zeroconf/_listener.py | 9 +++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/zeroconf/_listener.pxd diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd new file mode 100644 index 000000000..0f32a44a7 --- /dev/null +++ b/src/zeroconf/_listener.pxd @@ -0,0 +1,24 @@ + +import cython + + +cdef object millis_to_seconds +cdef object log +cdef object logging_DEBUG + +from ._protocol.incoming cimport DNSIncoming + + +cdef class AsyncListener: + + cdef public object zc + cdef public cython.bytes data + cdef public cython.float last_time + cdef public DNSIncoming last_message + cdef public object transport + cdef public object sock_description + cdef public cython.dict _deferred + cdef public cython.dict _timers + + @cython.locals(now=cython.float, msg=DNSIncoming) + cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 97bcf007e..bc0af296f 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -37,6 +37,11 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) +_bytes = bytes + +logging_DEBUG = logging.DEBUG + + class AsyncListener: """A Listener is used by this module to listen on the multicast @@ -69,11 +74,11 @@ def __init__(self, zc: 'Zeroconf') -> None: super().__init__() def datagram_received( - self, data: bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] + self, data: _bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: assert self.transport is not None data_len = len(data) - debug = log.isEnabledFor(logging.DEBUG) + debug = log.isEnabledFor(logging_DEBUG) if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm From 13d9aa5815b1b5a03000de2aaa62d106fe5e26a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 12:27:11 -0500 Subject: [PATCH 0889/1433] chore: empty commit to re-run release (#1223) From 0e962201facea2f022bb21d292d17c700c4dbf92 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 14 Aug 2023 17:42:15 +0000 Subject: [PATCH 0890/1433] 0.78.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ebcb0c4..3fbe7d4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.78.0 (2023-08-14) + +### Feature + +* Add cython pxd file for _listener.py to improve incoming message processing performance ([#1221](https://github.com/python-zeroconf/python-zeroconf/issues/1221)) ([`f459856`](https://github.com/python-zeroconf/python-zeroconf/commit/f459856a0a61b8afa8a541926d7e15d51f8e4aea)) + ## v0.77.0 (2023-08-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index f9d95b071..958a6e18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.77.0" +version = "0.78.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1c4807b16..f6393b284 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.77.0' +__version__ = '0.78.0' __license__ = 'LGPL' From ceb92cfe42d885dbb38cee7aaeebf685d97627a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Aug 2023 14:49:54 -0500 Subject: [PATCH 0891/1433] feat: refactor notify implementation to reduce overhead of adding and removing listeners (#1224) --- src/zeroconf/_core.py | 19 +++++++++--------- src/zeroconf/_services/info.py | 36 +++++++++++++--------------------- src/zeroconf/_utils/asyncio.py | 27 +++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 0f9b45dfe..173a29d00 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -25,7 +25,7 @@ import sys import threading from types import TracebackType -from typing import Awaitable, Dict, List, Optional, Tuple, Type, Union +from typing import Awaitable, Dict, List, Optional, Set, Tuple, Type, Union from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType @@ -49,11 +49,13 @@ from ._transport import _WrappedTransport from ._updates import RecordUpdateListener from ._utils.asyncio import ( + _resolve_all_futures_to_none, await_awaitable, get_running_loop, run_coro_with_timeout, shutdown_loop, wait_event_or_timeout, + wait_for_future_set_or_timeout, ) from ._utils.name import service_type_name from ._utils.net import ( @@ -188,7 +190,7 @@ def __init__( self.query_handler = QueryHandler(self.registry, self.cache, self.question_history) self.record_manager = RecordManager(self) - self.notify_event: Optional[asyncio.Event] = None + self._notify_futures: Set[asyncio.Future] = set() self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None @@ -206,7 +208,6 @@ def start(self) -> None: """Start Zeroconf.""" self.loop = get_running_loop() if self.loop: - self.notify_event = asyncio.Event() self.engine.setup(self.loop, None) return self._start_thread() @@ -218,7 +219,6 @@ def _start_thread(self) -> None: def _run_loop() -> None: self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) - self.notify_event = asyncio.Event() self.engine.setup(self.loop, loop_thread_ready) self.loop.run_forever() @@ -245,8 +245,9 @@ def listeners(self) -> List[RecordUpdateListener]: async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - assert self.notify_event is not None - await wait_event_or_timeout(self.notify_event, timeout=millis_to_seconds(timeout)) + loop = self.loop + assert loop is not None + await wait_for_future_set_or_timeout(loop, self._notify_futures, timeout) def notify_all(self) -> None: """Notifies all waiting threads and notify listeners.""" @@ -255,9 +256,9 @@ def notify_all(self) -> None: def async_notify_all(self) -> None: """Schedule an async_notify_all.""" - assert self.notify_event is not None - self.notify_event.set() - self.notify_event.clear() + notify_futures = self._notify_futures + if notify_futures: + _resolve_all_futures_to_none(notify_futures) def get_service_info( self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 2f4ae59e7..9b986404f 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -39,10 +39,15 @@ from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._updates import RecordUpdate, RecordUpdateListener -from .._utils.asyncio import get_running_loop, run_coro_with_timeout +from .._utils.asyncio import ( + _resolve_all_futures_to_none, + get_running_loop, + run_coro_with_timeout, + wait_for_future_set_or_timeout, +) from .._utils.name import service_type_name from .._utils.net import IPVersion, _encode_address -from .._utils.time import current_time_millis, millis_to_seconds +from .._utils.time import current_time_millis from ..const import ( _ADDRESS_RECORD_TYPES, _CLASS_IN, @@ -89,12 +94,6 @@ def instance_name_from_service_info(info: "ServiceInfo", strict: bool = True) -> _cached_ip_addresses = lru_cache(maxsize=256)(ip_address) -def _set_future_none_if_not_done(fut: asyncio.Future) -> None: - """Set a future to None if it is not done.""" - if not fut.done(): # pragma: no branch - fut.set_result(None) - - class ServiceInfo(RecordUpdateListener): """Service information. @@ -180,7 +179,7 @@ def __init__( self.host_ttl = host_ttl self.other_ttl = other_ttl self.interface_index = interface_index - self._new_records_futures: List[asyncio.Future] = [] + self._new_records_futures: Set[asyncio.Future] = set() @property def name(self) -> str: @@ -242,14 +241,9 @@ def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: async def async_wait(self, timeout: float) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - loop = asyncio.get_running_loop() - future = loop.create_future() - self._new_records_futures.append(future) - handle = loop.call_later(millis_to_seconds(timeout), _set_future_none_if_not_done, future) - try: - await future - finally: - handle.cancel() + loop = get_running_loop() + assert loop is not None + await wait_for_future_set_or_timeout(loop, self._new_records_futures, timeout) def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version. @@ -441,11 +435,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU This method will be run in the event loop. """ - if self._process_records_threadsafe(zc, now, records) and self._new_records_futures: - for future in self._new_records_futures: - if not future.done(): - future.set_result(None) - self._new_records_futures.clear() + new_records_futures = self._new_records_futures + if self._process_records_threadsafe(zc, now, records) and new_records_futures: + _resolve_all_futures_to_none(new_records_futures) def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> bool: """Thread safe record updating. diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index 3a5beb5a0..358ef37ea 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -41,6 +41,33 @@ _WAIT_FOR_LOOP_TASKS_TIMEOUT = 3 # Must be larger than _TASK_AWAIT_TIMEOUT +def _set_future_none_if_not_done(fut: asyncio.Future) -> None: + """Set a future to None if it is not done.""" + if not fut.done(): # pragma: no branch + fut.set_result(None) + + +def _resolve_all_futures_to_none(futures: Set[asyncio.Future]) -> None: + """Resolve all futures to None.""" + for fut in futures: + _set_future_none_if_not_done(fut) + futures.clear() + + +async def wait_for_future_set_or_timeout( + loop: asyncio.AbstractEventLoop, future_set: Set[asyncio.Future], timeout: float +) -> None: + """Wait for a future or timeout (in milliseconds).""" + future = loop.create_future() + future_set.add(future) + handle = loop.call_later(millis_to_seconds(timeout), _set_future_none_if_not_done, future) + try: + await future + finally: + handle.cancel() + future_set.discard(future) + + async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: """Wait for an event or timeout.""" with contextlib.suppress(asyncio.TimeoutError): From 5406f30a32f8efc8de15da70f9e61be8bb893163 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 14 Aug 2023 20:04:43 +0000 Subject: [PATCH 0892/1433] 0.79.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbe7d4c3..54c19900e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.79.0 (2023-08-14) + +### Feature + +* Refactor notify implementation to reduce overhead of adding and removing listeners ([#1224](https://github.com/python-zeroconf/python-zeroconf/issues/1224)) ([`ceb92cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb92cfe42d885dbb38cee7aaeebf685d97627a9)) + ## v0.78.0 (2023-08-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 958a6e18c..1f985c98c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.78.0" +version = "0.79.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f6393b284..09dd2b2ad 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.78.0' +__version__ = '0.79.0' __license__ = 'LGPL' From 1492e41b3d5cba5598cc9dd6bd2bc7d238f13555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Aug 2023 15:07:20 -0500 Subject: [PATCH 0893/1433] feat: optimize unpacking properties in ServiceInfo (#1225) --- src/zeroconf/_services/info.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 9b986404f..f8be5b389 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -361,20 +361,11 @@ def _unpack_text_into_properties(self) -> None: strs.append(text[index : index + length]) index += length - key: bytes - value: Optional[bytes] for s in strs: - key_value = s.split(b'=', 1) - if len(key_value) == 2: - key, value = key_value - else: - # No equals sign at all - key = s - value = None - + key, _, value = s.partition(b'=') # Only update non-existent properties if key and key not in result: - result[key] = value + result[key] = value or None # Properties should be set atomically # in case another thread is reading them From 0c5e5cf363ae3a2dabd8da6e193c9e6726725b61 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 15 Aug 2023 20:18:24 +0000 Subject: [PATCH 0894/1433] 0.80.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c19900e..b061cc260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.80.0 (2023-08-15) + +### Feature + +* Optimize unpacking properties in ServiceInfo ([#1225](https://github.com/python-zeroconf/python-zeroconf/issues/1225)) ([`1492e41`](https://github.com/python-zeroconf/python-zeroconf/commit/1492e41b3d5cba5598cc9dd6bd2bc7d238f13555)) + ## v0.79.0 (2023-08-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 1f985c98c..82d16c5f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.79.0" +version = "0.80.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 09dd2b2ad..8cb7ec11f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.79.0' +__version__ = '0.80.0' __license__ = 'LGPL' From 7b00b261839bad6f57854a0f709a53165a8f7c2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 09:46:10 -0500 Subject: [PATCH 0895/1433] chore: add missing typing to handler deque (#1228) --- src/zeroconf/_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index be0d619f3..bd33cc688 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -543,7 +543,7 @@ class MulticastOutgoingQueue: def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: self.zc = zeroconf - self.queue: deque = deque() + self.queue: deque[AnswerGroup] = deque() # Additional delay is used to implement # Protect the network against excessive packet flooding # https://datatracker.ietf.org/doc/html/rfc6762#section-14 From a0e754c6a599e585f37943c158a589827ac58421 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 09:46:18 -0500 Subject: [PATCH 0896/1433] chore: remove default calls to .keys() (#1229) --- src/zeroconf/_handlers.py | 2 +- src/zeroconf/_services/registry.py | 2 +- tests/test_handlers.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index bd33cc688..e1aba9731 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -166,7 +166,7 @@ def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: def add_ucast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a unicast query.""" self._additionals.update(answers) - self._ucast.update(answers.keys()) + self._ucast.update(answers) def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast query.""" diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index fd2ad5cee..64f135126 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -66,7 +66,7 @@ def async_get_info_name(self, name: str) -> Optional[ServiceInfo]: def async_get_types(self) -> List[str]: """Return all types.""" - return list(self.types.keys()) + return list(self.types) def async_get_infos_type(self, type_: str) -> List[ServiceInfo]: """Return all ServiceInfo matching type.""" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 2aa5caa1c..66ed811aa 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -577,7 +577,7 @@ def test_qu_response(): def _validate_complete_response(answers): has_srv = has_txt = has_a = has_aaaa = has_nsec = False - nbr_answers = len(answers.keys()) + nbr_answers = len(answers) additionals = set().union(*answers.values()) nbr_additionals = len(additionals) From cd7b56b2aa0c8ee429da430e9a36abd515512011 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 09:46:26 -0500 Subject: [PATCH 0897/1433] feat: optimizing sending answers to questions (#1227) --- src/zeroconf/_handlers.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py index e1aba9731..02f0d141d 100644 --- a/src/zeroconf/_handlers.py +++ b/src/zeroconf/_handlers.py @@ -23,6 +23,7 @@ import itertools import random from collections import deque +from operator import attrgetter from typing import ( TYPE_CHECKING, Dict, @@ -71,6 +72,8 @@ _MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} +NAME_GETTER = attrgetter('name') + class QuestionAnswers(NamedTuple): ucast: _AnswerWithAdditionalsType @@ -109,13 +112,13 @@ def construct_outgoing_unicast_answers( def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: # Find additionals and suppress any additionals that are already in answers - sending: Set[DNSRecord] = set(answers.keys()) + sending: Set[DNSRecord] = set(answers) # Answers are sorted to group names together to increase the chance # that similar names will end up in the same packet and can reduce the # overall size of the outgoing response via name compression - for answer, additionals in sorted(answers.items(), key=lambda kv: kv[0].name): + for answer in sorted(answers, key=NAME_GETTER): out.add_answer_at_time(answer, 0) - for additional in additionals: + for additional in answers[answer]: if additional not in sending: out.add_additional_answer(additional) sending.add(additional) From 47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 09:46:33 -0500 Subject: [PATCH 0898/1433] feat: speed up the service registry with a cython pxd (#1226) --- build_ext.py | 1 + src/zeroconf/_services/registry.pxd | 18 ++++++++++++++++++ src/zeroconf/_services/registry.py | 5 ++++- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/zeroconf/_services/registry.pxd diff --git a/build_ext.py b/build_ext.py index c0042df2b..38c8127ad 100644 --- a/build_ext.py +++ b/build_ext.py @@ -28,6 +28,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", + "src/zeroconf/_services/registry.py", ], compiler_directives={"language_level": "3"}, # Python 3 ), diff --git a/src/zeroconf/_services/registry.pxd b/src/zeroconf/_services/registry.pxd new file mode 100644 index 000000000..722ef0ecd --- /dev/null +++ b/src/zeroconf/_services/registry.pxd @@ -0,0 +1,18 @@ + +import cython + + +cdef class ServiceRegistry: + + cdef cython.dict _services + cdef public cython.dict types + cdef public cython.dict servers + + @cython.locals( + record_list=cython.list, + ) + cdef _async_get_by_index(self, cython.dict records, str key) + + cdef _add(self, object info) + + cdef _remove(self, cython.list infos) diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 64f135126..1f2f1d528 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -78,7 +78,10 @@ def async_get_infos_server(self, server: str) -> List[ServiceInfo]: def _async_get_by_index(self, records: Dict[str, List], key: str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" - return [self._services[name] for name in records.get(key, [])] + record_list = records.get(key) + if record_list is None: + return [] + return [self._services[name] for name in record_list] def _add(self, info: ServiceInfo) -> None: """Add a new service under the lock.""" From b492eb4204e433dec7b9f9a1c79525649ef33b5c Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 22 Aug 2023 14:58:49 +0000 Subject: [PATCH 0899/1433] 0.81.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b061cc260..2e4d245a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.81.0 (2023-08-22) + +### Feature + +* Speed up the service registry with a cython pxd ([#1226](https://github.com/python-zeroconf/python-zeroconf/issues/1226)) ([`47d3c7a`](https://github.com/python-zeroconf/python-zeroconf/commit/47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e)) +* Optimizing sending answers to questions ([#1227](https://github.com/python-zeroconf/python-zeroconf/issues/1227)) ([`cd7b56b`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7b56b2aa0c8ee429da430e9a36abd515512011)) + ## v0.80.0 (2023-08-15) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 82d16c5f0..7ccfb43ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.80.0" +version = "0.81.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 8cb7ec11f..316da1a2f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.80.0' +__version__ = '0.81.0' __license__ = 'LGPL' From 3e89294ea0ecee1122e1c1ffdc78925add8ca40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 13:23:55 -0500 Subject: [PATCH 0900/1433] feat: optimize processing of records in RecordUpdateListener subclasses (#1231) --- src/zeroconf/_services/browser.py | 70 ++++++------- src/zeroconf/_services/info.py | 22 +--- src/zeroconf/_updates.py | 2 +- tests/services/test_info.py | 165 ++++++++++++++++++------------ 4 files changed, 136 insertions(+), 123 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 84185f158..60c0439e9 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -39,7 +39,7 @@ cast, ) -from .._dns import DNSPointer, DNSQuestion, DNSQuestionType, DNSRecord +from .._dns import DNSPointer, DNSQuestion, DNSQuestionType from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._services import ( @@ -383,50 +383,46 @@ def _enqueue_callback( ): self._pending_handlers[key] = state_change - def _async_process_record_update( - self, now: float, record: DNSRecord, old_record: Optional[DNSRecord] - ) -> None: - """Process a single record update from a batch of updates.""" - record_type = record.type - - if record_type is _TYPE_PTR: - if TYPE_CHECKING: - record = cast(DNSPointer, record) - for type_ in self.types.intersection(cached_possible_types(record.name)): - if old_record is None: - self._enqueue_callback(ServiceStateChange.Added, type_, record.alias) - elif record.is_expired(now): - self._enqueue_callback(ServiceStateChange.Removed, type_, record.alias) - else: - self.reschedule_type(type_, now, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)) - return - - # If its expired or already exists in the cache it cannot be updated. - if old_record or record.is_expired(now): - return - - if record_type in _ADDRESS_RECORD_TYPES: - # Iterate through the DNSCache and callback any services that use this address - for type_, name in self._names_matching_types( - {service.name for service in self.zc.cache.async_entries_with_server(record.name)} - ): - self._enqueue_callback(ServiceStateChange.Updated, type_, name) - return - - for type_, name in self._names_matching_types((record.name,)): - self._enqueue_callback(ServiceStateChange.Updated, type_, name) - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. - Ensures that there is are no unecessary duplicates in the list. + Ensures that there is are no unnecessary duplicates in the list. This method will be run in the event loop. """ - for record in records: - self._async_process_record_update(now, record[0], record[1]) + for record_update in records: + record, old_record = record_update + record_type = record.type + + if record_type is _TYPE_PTR: + if TYPE_CHECKING: + record = cast(DNSPointer, record) + for type_ in self.types.intersection(cached_possible_types(record.name)): + if old_record is None: + self._enqueue_callback(ServiceStateChange.Added, type_, record.alias) + elif record.is_expired(now): + self._enqueue_callback(ServiceStateChange.Removed, type_, record.alias) + else: + expire_time = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + self.reschedule_type(type_, now, expire_time) + continue + + # If its expired or already exists in the cache it cannot be updated. + if old_record or record.is_expired(now): + continue + + if record_type in _ADDRESS_RECORD_TYPES: + # Iterate through the DNSCache and callback any services that use this address + for type_, name in self._names_matching_types( + {service.name for service in self.zc.cache.async_entries_with_server(record.name)} + ): + self._enqueue_callback(ServiceStateChange.Updated, type_, name) + continue + + for type_, name in self._names_matching_types((record.name,)): + self._enqueue_callback(ServiceStateChange.Updated, type_, name) @abstractmethod def async_update_records_complete(self) -> None: diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index f8be5b389..705f37238 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -410,35 +410,17 @@ def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: else: self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) - def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord]) -> None: - """Updates service information from a DNS record. - - This method is deprecated and will be removed in a future version. - update_records should be implemented instead. - - This method will be run in the event loop. - """ - if record is not None: - self._process_record_threadsafe(zc, record, now) - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: """Updates service information from a DNS record. This method will be run in the event loop. """ new_records_futures = self._new_records_futures - if self._process_records_threadsafe(zc, now, records) and new_records_futures: - _resolve_all_futures_to_none(new_records_futures) - - def _process_records_threadsafe(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> bool: - """Thread safe record updating. - - Returns True if new records were added. - """ updated: bool = False for record_update in records: updated |= self._process_record_threadsafe(zc, record_update.new, now) - return updated + if updated and new_records_futures: + _resolve_all_futures_to_none(new_records_futures) def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: float) -> bool: """Thread safe record updating. diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index 1a1e028d7..b760daf90 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -56,7 +56,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU All records that are received in a single packet are passed to update_records. - This implementation is a compatiblity shim to ensure older code + This implementation is a compatibility shim to ensure older code that uses RecordUpdateListener as a base class will continue to get calls to update_record. This method will raise NotImplementedError in a future version. diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 64a51bd10..1fc3bd014 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -17,7 +17,7 @@ import pytest import zeroconf as r -from zeroconf import DNSAddress, const +from zeroconf import DNSAddress, RecordUpdate, const from zeroconf._services import info from zeroconf._services.info import ServiceInfo from zeroconf._utils.net import IPVersion @@ -68,89 +68,119 @@ def test_service_info_rejects_non_matching_updates(self): service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] ) # Verify backwards compatiblity with calling with None - info.update_record(zc, now, None) + info.async_update_records(zc, now, []) # Matching updates - info.update_record( + info.async_update_records( zc, now, - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), + [ + RecordUpdate( + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + None, + ) + ], ) assert info.properties[b"ci"] == b"2" - info.update_record( + info.async_update_records( zc, now, - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), + [ + RecordUpdate( + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + None, + ) + ], ) assert info.server_key == 'ash-2.local.' assert info.server == 'ASH-2.local.' new_address = socket.inet_aton("10.0.1.3") - info.update_record( + info.async_update_records( zc, now, - r.DNSAddress( - 'ASH-2.local.', - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - new_address, - ), + [ + RecordUpdate( + r.DNSAddress( + 'ASH-2.local.', + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + new_address, + ), + None, + ) + ], ) assert new_address in info.addresses # Non-matching updates - info.update_record( + info.async_update_records( zc, now, - r.DNSText( - "incorrect.name.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', - ), + [ + RecordUpdate( + r.DNSText( + "incorrect.name.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + ), + None, + ) + ], ) assert info.properties[b"ci"] == b"2" - info.update_record( + info.async_update_records( zc, now, - r.DNSService( - "incorrect.name.", - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - 'ASH-2.local.', - ), + [ + RecordUpdate( + r.DNSService( + "incorrect.name.", + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + 'ASH-2.local.', + ), + None, + ) + ], ) assert info.server_key == 'ash-2.local.' assert info.server == 'ASH-2.local.' new_address = socket.inet_aton("10.0.1.4") - info.update_record( + info.async_update_records( zc, now, - r.DNSAddress( - "incorrect.name.", - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - new_address, - ), + [ + RecordUpdate( + r.DNSAddress( + "incorrect.name.", + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + new_address, + ), + None, + ) + ], ) assert new_address not in info.addresses zc.close() @@ -169,16 +199,21 @@ def test_service_info_rejects_expired_records(self): service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] ) # Matching updates - info.update_record( + info.async_update_records( zc, now, - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', - ), + [ + RecordUpdate( + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + ), + None, + ) + ], ) assert info.properties[b"ci"] == b"2" # Expired record @@ -190,7 +225,7 @@ def test_service_info_rejects_expired_records(self): b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', ) expired_record.set_created_ttl(1000, 1) - info.update_record(zc, now, expired_record) + info.async_update_records(zc, now, [RecordUpdate(expired_record, None)]) assert info.properties[b"ci"] == b"2" zc.close() From 8644173c3ba576e95dc90879f4d94da59d464702 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 22 Aug 2023 18:35:47 +0000 Subject: [PATCH 0901/1433] 0.82.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4d245a8..43632cba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.82.0 (2023-08-22) + +### Feature + +* Optimize processing of records in RecordUpdateListener subclasses ([#1231](https://github.com/python-zeroconf/python-zeroconf/issues/1231)) ([`3e89294`](https://github.com/python-zeroconf/python-zeroconf/commit/3e89294ea0ecee1122e1c1ffdc78925add8ca40e)) + ## v0.81.0 (2023-08-22) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 7ccfb43ca..873dff4e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.81.0" +version = "0.82.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 316da1a2f..2928f1d12 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.81.0' +__version__ = '0.82.0' __license__ = 'LGPL' From 30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Aug 2023 14:06:49 -0500 Subject: [PATCH 0902/1433] fix: build failures with older cython 0.29 series (#1232) --- src/zeroconf/_services/registry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 1f2f1d528..12051275e 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -25,6 +25,8 @@ from .._exceptions import ServiceNameAlreadyRegistered from .info import ServiceInfo +_str = str + class ServiceRegistry: """A registry to keep track of services. @@ -76,7 +78,7 @@ def async_get_infos_server(self, server: str) -> List[ServiceInfo]: """Return all ServiceInfo matching server.""" return self._async_get_by_index(self.servers, server) - def _async_get_by_index(self, records: Dict[str, List], key: str) -> List[ServiceInfo]: + def _async_get_by_index(self, records: Dict[str, List], key: _str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" record_list = records.get(key) if record_list is None: From 84054cea08c3947381e869d89e2b1a073f47eb79 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 22 Aug 2023 19:15:40 +0000 Subject: [PATCH 0903/1433] 0.82.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43632cba1..99e769b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.82.1 (2023-08-22) + +### Fix + +* Build failures with older cython 0.29 series ([#1232](https://github.com/python-zeroconf/python-zeroconf/issues/1232)) ([`30c3ad9`](https://github.com/python-zeroconf/python-zeroconf/commit/30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced)) + ## v0.82.0 (2023-08-22) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 873dff4e9..bdae72d6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.82.0" +version = "0.82.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2928f1d12..074427183 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.82.0' +__version__ = '0.82.1' __license__ = 'LGPL' From 703ecb2901b2150fb72fac3deed61d7302561298 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Aug 2023 08:59:17 -0500 Subject: [PATCH 0904/1433] feat: speed up question and answer history with a cython pxd (#1234) --- build_ext.py | 1 + src/zeroconf/_history.pxd | 16 ++++++++++++++++ src/zeroconf/_history.py | 27 ++++++++++++++++++--------- tests/__init__.py | 12 +++++++++--- tests/services/test_browser.py | 14 +++++++++----- tests/test_asyncio.py | 11 +++++------ tests/test_handlers.py | 4 ++-- tests/test_listener.py | 3 +++ 8 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 src/zeroconf/_history.pxd diff --git a/build_ext.py b/build_ext.py index 38c8127ad..f2c592884 100644 --- a/build_ext.py +++ b/build_ext.py @@ -25,6 +25,7 @@ def build(setup_kwargs: Any) -> None: [ "src/zeroconf/_dns.py", "src/zeroconf/_cache.py", + "src/zeroconf/_history.py", "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd new file mode 100644 index 000000000..6e4e374f7 --- /dev/null +++ b/src/zeroconf/_history.pxd @@ -0,0 +1,16 @@ +import cython + + +cdef cython.double _DUPLICATE_QUESTION_INTERVAL + +cdef class QuestionHistory: + + cdef cython.dict _history + + + @cython.locals(than=cython.double, previous_question=cython.tuple, previous_known_answers=cython.set) + cpdef suppresses(self, object question, cython.double now, cython.set known_answers) + + + @cython.locals(than=cython.double, now_known_answers=cython.tuple) + cpdef async_expire(self, cython.double now) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index cbb36144a..db6a394d7 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -20,7 +20,7 @@ USA """ -from typing import Dict, Set, Tuple +from typing import Dict, List, Set, Tuple from ._dns import DNSQuestion, DNSRecord from .const import _DUPLICATE_QUESTION_INTERVAL @@ -28,16 +28,21 @@ # The QuestionHistory is used to implement Duplicate Question Suppression # https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 +_float = float + class QuestionHistory: + """Remember questions and known answers.""" + def __init__(self) -> None: + """Init a new QuestionHistory.""" self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} - def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: + def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> None: """Remember a question with known answers.""" self._history[question] = (now, known_answers) - def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + def suppresses(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> bool: """Check to see if a question should be suppressed. https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 @@ -59,12 +64,16 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRe return False return True - def async_expire(self, now: float) -> None: + def async_expire(self, now: _float) -> None: """Expire the history of old questions.""" - removes = [ - question - for question, now_known_answers in self._history.items() - if now - now_known_answers[0] > _DUPLICATE_QUESTION_INTERVAL - ] + removes: List[DNSQuestion] = [] + for question, now_known_answers in self._history.items(): + than, _ = now_known_answers + if now - than > _DUPLICATE_QUESTION_INTERVAL: + removes.append(question) for question in removes: del self._history[question] + + def clear(self) -> None: + """Clear the history.""" + self._history.clear() diff --git a/tests/__init__.py b/tests/__init__.py index 8f216c990..959cc3f39 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,11 +23,17 @@ import asyncio import socket from functools import lru_cache -from typing import List +from typing import List, Set import ifaddr -from zeroconf import DNSIncoming, Zeroconf +from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf +from zeroconf._history import QuestionHistory + + +class QuestionHistoryWithoutSuppression(QuestionHistory): + def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + return False def _inject_responses(zc: Zeroconf, msgs: List[DNSIncoming]) -> None: @@ -77,4 +83,4 @@ def has_working_ipv6(): def _clear_cache(zc): zc.cache.cache.clear() - zc.question_history._history.clear() + zc.question_history.clear() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index d13701ec4..215fcc0ce 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -31,7 +31,12 @@ from zeroconf._services.info import ServiceInfo from zeroconf.asyncio import AsyncZeroconf -from .. import _inject_response, _wait_for_start, has_working_ipv6 +from .. import ( + QuestionHistoryWithoutSuppression, + _inject_response, + _wait_for_start, + has_working_ipv6, +) log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -444,6 +449,7 @@ def test_backoff(): type_ = "_http._tcp.local." zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) _wait_for_start(zeroconf_browser) + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.async_send @@ -465,10 +471,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf current_time_millis # patch the backoff limit to prevent test running forever with patch.object(zeroconf_browser, "async_send", send), patch.object( - zeroconf_browser.question_history, "suppresses", return_value=False - ), patch.object(_services_browser, "current_time_millis", current_time_millis), patch.object( - _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 - ), patch.object( + _services_browser, "current_time_millis", current_time_millis + ), patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10), patch.object( _services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0) ): # dummy service callback diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index cd067ae12..88ea9fce6 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -43,7 +43,7 @@ ) from zeroconf.const import _LISTENER_TIME -from . import _clear_cache, has_working_ipv6 +from . import QuestionHistoryWithoutSuppression, _clear_cache, has_working_ipv6 log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -951,6 +951,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) zeroconf_browser = aiozc.zeroconf + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() # we are going to patch the zeroconf send to check packet sizes @@ -990,11 +991,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL # Disable duplicate question suppression and duplicate packet suppression for this test as it works # by asking the same question over and over - with patch.object(zeroconf_browser.question_history, "suppresses", return_value=False), patch.object( - zeroconf_browser, "async_send", send - ), patch("zeroconf._services.browser.current_time_millis", _new_current_time_millis), patch.object( - _services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4) - ): + with patch.object(zeroconf_browser, "async_send", send), patch( + "zeroconf._services.browser.current_time_millis", _new_current_time_millis + ), patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): service_added = asyncio.Event() service_removed = asyncio.Event() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 66ed811aa..607f68193 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1131,7 +1131,7 @@ async def test_cache_flush_bit(): for record in new_records: assert zc.cache.async_get_unique(record) is not None - original_a_record.created = current_time_millis() - 1001 + original_a_record.created = current_time_millis() - 1500 # Do the run within 1s to verify the original record is not going to be expired out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA, multicast=True) @@ -1146,7 +1146,7 @@ async def test_cache_flush_bit(): cached_records = [zc.cache.async_get_unique(record) for record in new_records] for cached_record in cached_records: assert cached_record is not None - cached_record.created = current_time_millis() - 1001 + cached_record.created = current_time_millis() - 1500 fresh_address = socket.inet_aton("4.4.4.4") info.addresses = [fresh_address] diff --git a/tests/test_listener.py b/tests/test_listener.py index 737b81118..914b4a130 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -14,6 +14,8 @@ from zeroconf._protocol import outgoing from zeroconf._protocol.incoming import DNSIncoming +from . import QuestionHistoryWithoutSuppression + log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -123,6 +125,7 @@ def test_guard_against_duplicate_packets(): These packets can quickly overwhelm the system. """ zc = Zeroconf(interfaces=['127.0.0.1']) + zc.question_history = QuestionHistoryWithoutSuppression() class SubListener(_listener.AsyncListener): def handle_query_or_defer( From bfb3fe2bc36262fe2922028d9ce44c6d2f76f829 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 26 Aug 2023 14:22:49 +0000 Subject: [PATCH 0905/1433] 0.83.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e769b7d..1e71654c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.83.0 (2023-08-26) + +### Feature + +* Speed up question and answer history with a cython pxd ([#1234](https://github.com/python-zeroconf/python-zeroconf/issues/1234)) ([`703ecb2`](https://github.com/python-zeroconf/python-zeroconf/commit/703ecb2901b2150fb72fac3deed61d7302561298)) + ## v0.82.1 (2023-08-22) ### Fix diff --git a/pyproject.toml b/pyproject.toml index bdae72d6c..643f99538 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.82.1" +version = "0.83.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 074427183..66fe5cd86 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.82.1' +__version__ = '0.83.0' __license__ = 'LGPL' From dd637fb2e5a87ba283750e69d116e124bef54e7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Aug 2023 08:29:12 -0500 Subject: [PATCH 0906/1433] fix: rebuild wheels with cython 3.0.2 (#1236) From 041549c7f55503259e30b2f4725bee3ef2c6921e Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 27 Aug 2023 13:37:59 +0000 Subject: [PATCH 0907/1433] 0.83.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e71654c6..76dda68fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.83.1 (2023-08-27) + +### Fix + +* Rebuild wheels with cython 3.0.2 ([#1236](https://github.com/python-zeroconf/python-zeroconf/issues/1236)) ([`dd637fb`](https://github.com/python-zeroconf/python-zeroconf/commit/dd637fb2e5a87ba283750e69d116e124bef54e7c)) + ## v0.83.0 (2023-08-26) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 643f99538..0681f64b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.83.0" +version = "0.83.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 66fe5cd86..657c150aa 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.83.0' +__version__ = '0.83.1' __license__ = 'LGPL' From bd8d8467dec2a39a0b525043ea1051259100fded Mon Sep 17 00:00:00 2001 From: Tenebrosus3141 <105437363+Tenebrosus3141@users.noreply.github.com> Date: Sun, 27 Aug 2023 10:26:34 -0400 Subject: [PATCH 0908/1433] feat: context managers in ServiceBrowser and AsyncServiceBrowser (#1233) Co-authored-by: J. Nick Koston --- src/zeroconf/_services/browser.py | 14 ++++++++++++++ src/zeroconf/asyncio.py | 12 ++++++++++++ tests/services/test_browser.py | 32 ++++++++++++++++++++++++++++++- tests/test_asyncio.py | 27 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 60c0439e9..17307c991 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -26,6 +26,7 @@ import threading import warnings from abc import abstractmethod +from types import TracebackType # noqa # used in type hints from typing import ( TYPE_CHECKING, Callable, @@ -35,6 +36,7 @@ Optional, Set, Tuple, + Type, Union, cast, ) @@ -576,3 +578,15 @@ def async_update_records_complete(self) -> None: for pending in self._pending_handlers.items(): self.queue.put(pending) self._pending_handlers.clear() + + def __enter__(self) -> 'ServiceBrowser': + return self + + def __exit__( # pylint: disable=useless-return + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + self.cancel() + return None diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 755757d77..5aaee35fb 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -93,6 +93,18 @@ def async_update_records_complete(self) -> None: self._fire_service_state_changed_event(pending) self._pending_handlers.clear() + async def __aenter__(self) -> 'AsyncServiceBrowser': + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Optional[bool]: + await self.async_cancel() + return None + class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): """An async version of ZeroconfServiceTypes.""" diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 215fcc0ce..d49295fa7 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -3,13 +3,14 @@ """ Unit tests for zeroconf._services.browser. """ +import asyncio import logging import os import socket import time import unittest from threading import Event -from typing import Iterable, Set +from typing import Iterable, Set, cast from unittest.mock import patch import pytest @@ -75,6 +76,35 @@ class MyServiceListener(r.ServiceListener): zc.close() +def test_service_browser_cancel_context_manager(): + """Test we can cancel a ServiceBrowser with it being used as a context manager.""" + + # instantiate a zeroconf instance + zc = Zeroconf(interfaces=['127.0.0.1']) + # start a browser + type_ = "_hap._tcp.local." + + class MyServiceListener(r.ServiceListener): + pass + + listener = MyServiceListener() + + browser = r.ServiceBrowser(zc, type_, None, listener) + + assert cast(bool, browser.done) is False + + with browser: + pass + + # ensure call_soon_threadsafe in ServiceBrowser.cancel is run + assert zc.loop is not None + asyncio.run_coroutine_threadsafe(asyncio.sleep(0), zc.loop).result() + + assert cast(bool, browser.done) is True + + zc.close() + + def test_service_browser_cancel_multiple_times_after_close(): """Test we can cancel a ServiceBrowser multiple times after close.""" diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 88ea9fce6..395a16ea0 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -9,6 +9,7 @@ import socket import threading import time +from typing import cast from unittest.mock import ANY, call, patch import pytest @@ -779,6 +780,32 @@ async def test_async_context_manager() -> None: assert aiosinfo is not None +@pytest.mark.asyncio +async def test_service_browser_cancel_async_context_manager(): + """Test we can cancel an AsyncServiceBrowser with it being used as an async context manager.""" + + # instantiate a zeroconf instance + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + type_ = "_hap._tcp.local." + + class MyServiceListener(ServiceListener): + pass + + listener = MyServiceListener() + + browser = AsyncServiceBrowser(zc, type_, None, listener) + + assert cast(bool, browser.done) is False + + async with browser: + pass + + assert cast(bool, browser.done) is True + + await aiozc.async_close() + + @pytest.mark.asyncio async def test_async_unregister_all_services() -> None: """Test unregistering all services.""" From a78ea54fe6ccf8e4941facc85168496f66922533 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 27 Aug 2023 14:35:45 +0000 Subject: [PATCH 0909/1433] 0.84.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76dda68fb..065fb6b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.84.0 (2023-08-27) + +### Feature + +* Context managers in ServiceBrowser and AsyncServiceBrowser ([#1233](https://github.com/python-zeroconf/python-zeroconf/issues/1233)) ([`bd8d846`](https://github.com/python-zeroconf/python-zeroconf/commit/bd8d8467dec2a39a0b525043ea1051259100fded)) + ## v0.83.1 (2023-08-27) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0681f64b4..4e50bc1e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.83.1" +version = "0.84.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 657c150aa..1233dc419 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.83.1' +__version__ = '0.84.0' __license__ = 'LGPL' From 68d99985a0e9d2c72ff670b2e2af92271a6fe934 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Aug 2023 14:22:57 -0500 Subject: [PATCH 0910/1433] feat: simplify code to unpack properties (#1237) --- src/zeroconf/_services/info.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 705f37238..440a4b9de 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -345,31 +345,27 @@ def _set_text(self, text: bytes) -> None: def _unpack_text_into_properties(self) -> None: """Unpacks the text field into properties""" text = self.text - end = len(text) - if end == 0: + if not text: # Properties should be set atomically # in case another thread is reading them self._properties = {} return - result: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} index = 0 - strs: List[bytes] = [] + pairs: List[bytes] = [] + end = len(text) while index < end: length = text[index] index += 1 - strs.append(text[index : index + length]) + pairs.append(text[index : index + length]) index += length - for s in strs: - key, _, value = s.partition(b'=') - # Only update non-existent properties - if key and key not in result: - result[key] = value or None - - # Properties should be set atomically - # in case another thread is reading them - self._properties = result + # Reverse the list so that the first item in the list + # is the last item in the text field. This is important + # to preserve backwards compatibility where the first + # key always wins if the key is seen multiple times. + pairs.reverse() + self._properties = {key: value or None for key, _, value in (pair.partition(b'=') for pair in pairs)} def get_name(self) -> str: """Name accessor""" From 55f719dbf9288c5b809e78560e468e1cf686cb11 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 27 Aug 2023 19:31:12 +0000 Subject: [PATCH 0911/1433] 0.85.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 065fb6b81..9395aea65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.85.0 (2023-08-27) + +### Feature + +* Simplify code to unpack properties ([#1237](https://github.com/python-zeroconf/python-zeroconf/issues/1237)) ([`68d9998`](https://github.com/python-zeroconf/python-zeroconf/commit/68d99985a0e9d2c72ff670b2e2af92271a6fe934)) + ## v0.84.0 (2023-08-27) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 4e50bc1e3..b2f873309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.84.0" +version = "0.85.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1233dc419..6b2bd87e5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.84.0' +__version__ = '0.85.0' __license__ = 'LGPL' From cc8feb110fefc3fb714fd482a52f16e2b620e8c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Aug 2023 21:14:12 -0500 Subject: [PATCH 0912/1433] feat: use server_key when processing DNSService records (#1238) --- src/zeroconf/_core.py | 4 ++-- src/zeroconf/_services/info.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 173a29d00..672122860 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -440,8 +440,8 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: # If another server uses the same addresses, we do not want to send # goodbye packets for the address records - assert info.server is not None - entries = self.registry.async_get_infos_server(info.server.lower()) + assert info.server_key is not None + entries = self.registry.async_get_infos_server(info.server_key) broadcast_addresses = not bool(entries) return asyncio.ensure_future( self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 440a4b9de..19e4ce29e 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -472,7 +472,7 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo old_server_key = self.server_key self.name = record.name self.server = record.server - self.server_key = record.server.lower() + self.server_key = record.server_key self.port = record.port self.weight = record.weight self.priority = record.priority @@ -586,7 +586,7 @@ def set_server_if_missing(self) -> None: """ if self.server is None: self.server = self._name - self.server_key = self.server.lower() + self.server_key = self.key def load_from_cache(self, zc: 'Zeroconf', now: Optional[float] = None) -> bool: """Populate the service info from the cache. From 58bc154f55b06b4ddfc4a141592488abe76f062a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 27 Aug 2023 21:14:23 -0500 Subject: [PATCH 0913/1433] feat: build wheels for cpython 3.12 (#1239) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 677dfad19..ff79b70b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - "3.9" - "3.10" - "3.11" - - "3.12.0-beta.4" + - "3.12.0-rc.1" - "pypy-3.7" os: - ubuntu-latest @@ -145,7 +145,7 @@ jobs: fetch-depth: 0 - name: Build wheels - uses: pypa/cibuildwheel@v2.11.3 + uses: pypa/cibuildwheel@v2.15.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* From b88c8dd51784c93ba928f522b14ec53ec5c57f1c Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 28 Aug 2023 02:23:45 +0000 Subject: [PATCH 0914/1433] 0.86.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9395aea65..87aebdc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.86.0 (2023-08-28) + +### Feature + +* Build wheels for cpython 3.12 ([#1239](https://github.com/python-zeroconf/python-zeroconf/issues/1239)) ([`58bc154`](https://github.com/python-zeroconf/python-zeroconf/commit/58bc154f55b06b4ddfc4a141592488abe76f062a)) +* Use server_key when processing DNSService records ([#1238](https://github.com/python-zeroconf/python-zeroconf/issues/1238)) ([`cc8feb1`](https://github.com/python-zeroconf/python-zeroconf/commit/cc8feb110fefc3fb714fd482a52f16e2b620e8c4)) + ## v0.85.0 (2023-08-27) ### Feature diff --git a/pyproject.toml b/pyproject.toml index b2f873309..f3c7e7b71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.85.0" +version = "0.86.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6b2bd87e5..cbd3dce53 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.85.0' +__version__ = '0.86.0' __license__ = 'LGPL' From 9da99d706d1c8b97e19856e8d83784c6cf8211d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Aug 2023 16:49:25 -0500 Subject: [PATCH 0915/1433] chore: split up handlers into seperate modules (#1240) --- src/zeroconf/_core.py | 8 +- src/zeroconf/_handlers.py | 605 ------------------ src/zeroconf/_handlers/__init__.py | 21 + src/zeroconf/_handlers/answers.py | 84 +++ .../_handlers/multicast_outgoing_queue.py | 102 +++ src/zeroconf/_handlers/query_handler.py | 286 +++++++++ src/zeroconf/_handlers/record_manager.py | 211 ++++++ tests/__init__.py | 2 +- tests/services/test_browser.py | 4 +- tests/test_handlers.py | 21 +- 10 files changed, 723 insertions(+), 621 deletions(-) delete mode 100644 src/zeroconf/_handlers.py create mode 100644 src/zeroconf/_handlers/__init__.py create mode 100644 src/zeroconf/_handlers/answers.py create mode 100644 src/zeroconf/_handlers/multicast_outgoing_queue.py create mode 100644 src/zeroconf/_handlers/query_handler.py create mode 100644 src/zeroconf/_handlers/record_manager.py diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 672122860..4960f1e07 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -31,13 +31,13 @@ from ._dns import DNSQuestion, DNSQuestionType from ._engine import AsyncEngine from ._exceptions import NonUniqueNameException, NotRunningException -from ._handlers import ( - MulticastOutgoingQueue, - QueryHandler, - RecordManager, +from ._handlers.answers import ( construct_outgoing_multicast_answers, construct_outgoing_unicast_answers, ) +from ._handlers.multicast_outgoing_queue import MulticastOutgoingQueue +from ._handlers.query_handler import QueryHandler +from ._handlers.record_manager import RecordManager from ._history import QuestionHistory from ._logger import QuietLogger, log from ._protocol.incoming import DNSIncoming diff --git a/src/zeroconf/_handlers.py b/src/zeroconf/_handlers.py deleted file mode 100644 index 02f0d141d..000000000 --- a/src/zeroconf/_handlers.py +++ /dev/null @@ -1,605 +0,0 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA -""" - -import itertools -import random -from collections import deque -from operator import attrgetter -from typing import ( - TYPE_CHECKING, - Dict, - List, - NamedTuple, - Optional, - Set, - Tuple, - Union, - cast, -) - -from ._cache import DNSCache, _UniqueRecordsType -from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet -from ._history import QuestionHistory -from ._logger import log -from ._protocol.incoming import DNSIncoming -from ._protocol.outgoing import DNSOutgoing -from ._services.registry import ServiceRegistry -from ._updates import RecordUpdate, RecordUpdateListener -from ._utils.time import current_time_millis, millis_to_seconds -from .const import ( - _ADDRESS_RECORD_TYPES, - _CLASS_IN, - _DNS_OTHER_TTL, - _DNS_PTR_MIN_TTL, - _FLAGS_AA, - _FLAGS_QR_RESPONSE, - _ONE_SECOND, - _SERVICE_TYPE_ENUMERATION_NAME, - _TYPE_A, - _TYPE_AAAA, - _TYPE_ANY, - _TYPE_NSEC, - _TYPE_PTR, - _TYPE_SRV, - _TYPE_TXT, -) - -if TYPE_CHECKING: - from ._core import Zeroconf - - -_AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] - -_MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) -_RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} - -NAME_GETTER = attrgetter('name') - - -class QuestionAnswers(NamedTuple): - ucast: _AnswerWithAdditionalsType - mcast_now: _AnswerWithAdditionalsType - mcast_aggregate: _AnswerWithAdditionalsType - mcast_aggregate_last_second: _AnswerWithAdditionalsType - - -class AnswerGroup(NamedTuple): - """A group of answers scheduled to be sent at the same time.""" - - send_after: float # Must be sent after this time - send_before: float # Must be sent before this time - answers: _AnswerWithAdditionalsType - - -def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: - """Add answers and additionals to a DNSOutgoing.""" - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) - _add_answers_additionals(out, answers) - return out - - -def construct_outgoing_unicast_answers( - answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int -) -> DNSOutgoing: - """Add answers and additionals to a DNSOutgoing.""" - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=id_) - # Adding the questions back when the source is legacy unicast behavior - if ucast_source: - for question in questions: - out.add_question(question) - _add_answers_additionals(out, answers) - return out - - -def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: - # Find additionals and suppress any additionals that are already in answers - sending: Set[DNSRecord] = set(answers) - # Answers are sorted to group names together to increase the chance - # that similar names will end up in the same packet and can reduce the - # overall size of the outgoing response via name compression - for answer in sorted(answers, key=NAME_GETTER): - out.add_answer_at_time(answer, 0) - for additional in answers[answer]: - if additional not in sending: - out.add_additional_answer(additional) - sending.add(additional) - - -class _QueryResponse: - """A pair for unicast and multicast DNSOutgoing responses.""" - - __slots__ = ( - "_is_probe", - "_msg", - "_now", - "_cache", - "_additionals", - "_ucast", - "_mcast_now", - "_mcast_aggregate", - "_mcast_aggregate_last_second", - ) - - def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: - """Build a query response.""" - self._is_probe = False - for msg in msgs: - if msg.is_probe: - self._is_probe = True - break - self._msg = msgs[0] - self._now = self._msg.now - self._cache = cache - self._additionals: _AnswerWithAdditionalsType = {} - self._ucast: Set[DNSRecord] = set() - self._mcast_now: Set[DNSRecord] = set() - self._mcast_aggregate: Set[DNSRecord] = set() - self._mcast_aggregate_last_second: Set[DNSRecord] = set() - - def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: - """Generate a response to a multicast QU query.""" - for record, additionals in answers.items(): - self._additionals[record] = additionals - if self._is_probe: - self._ucast.add(record) - if not self._has_mcast_within_one_quarter_ttl(record): - self._mcast_now.add(record) - elif not self._is_probe: - self._ucast.add(record) - - def add_ucast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: - """Generate a response to a unicast query.""" - self._additionals.update(answers) - self._ucast.update(answers) - - def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: - """Generate a response to a multicast query.""" - self._additionals.update(answers) - for answer in answers: - if self._is_probe: - self._mcast_now.add(answer) - continue - - if self._has_mcast_record_in_last_second(answer): - self._mcast_aggregate_last_second.add(answer) - elif len(self._msg.questions) == 1 and self._msg.questions[0].type in _RESPOND_IMMEDIATE_TYPES: - self._mcast_now.add(answer) - else: - self._mcast_aggregate.add(answer) - - def _generate_answers_with_additionals(self, rrset: Set[DNSRecord]) -> _AnswerWithAdditionalsType: - """Create answers with additionals from an rrset.""" - return {record: self._additionals[record] for record in rrset} - - def answers( - self, - ) -> QuestionAnswers: - """Return answer sets that will be queued.""" - return QuestionAnswers( - self._generate_answers_with_additionals(self._ucast), - self._generate_answers_with_additionals(self._mcast_now), - self._generate_answers_with_additionals(self._mcast_aggregate), - self._generate_answers_with_additionals(self._mcast_aggregate_last_second), - ) - - def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: - """Check to see if a record has been mcasted recently. - - https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 - When receiving a question with the unicast-response bit set, a - responder SHOULD usually respond with a unicast packet directed back - to the querier. However, if the responder has not multicast that - record recently (within one quarter of its TTL), then the responder - SHOULD instead multicast the response so as to keep all the peer - caches up to date - """ - if TYPE_CHECKING: - record = cast(_UniqueRecordsType, record) - maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry and maybe_entry.is_recent(self._now)) - - def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: - """Check if an answer was seen in the last second. - Protect the network against excessive packet flooding - https://datatracker.ietf.org/doc/html/rfc6762#section-14 - """ - if TYPE_CHECKING: - record = cast(_UniqueRecordsType, record) - maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) - - -class QueryHandler: - """Query the ServiceRegistry.""" - - __slots__ = ("registry", "cache", "question_history") - - def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: - """Init the query handler.""" - self.registry = registry - self.cache = cache - self.question_history = question_history - - def _add_service_type_enumeration_query_answers( - self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float - ) -> None: - """Provide an answer to a service type enumeration query. - - https://datatracker.ietf.org/doc/html/rfc6763#section-9 - """ - for stype in self.registry.async_get_types(): - dns_pointer = DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, now - ) - if not known_answers.suppresses(dns_pointer): - answer_set[dns_pointer] = set() - - def _add_pointer_answers( - self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float - ) -> None: - """Answer PTR/ANY question.""" - for service in self.registry.async_get_infos_type(lower_name): - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.1. - dns_pointer = service.dns_pointer(created=now) - if known_answers.suppresses(dns_pointer): - continue - answer_set[dns_pointer] = { - service.dns_service(created=now), - service.dns_text(created=now), - } | service.get_address_and_nsec_records(created=now) - - def _add_address_answers( - self, - lower_name: str, - answer_set: _AnswerWithAdditionalsType, - known_answers: DNSRRSet, - now: float, - type_: int, - ) -> None: - """Answer A/AAAA/ANY question.""" - for service in self.registry.async_get_infos_server(lower_name): - answers: List[DNSAddress] = [] - additionals: Set[DNSRecord] = set() - seen_types: Set[int] = set() - for dns_address in service.dns_addresses(created=now): - seen_types.add(dns_address.type) - if dns_address.type != type_: - additionals.add(dns_address) - elif not known_answers.suppresses(dns_address): - answers.append(dns_address) - missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types - if answers: - if missing_types: - assert service.server is not None, "Service server must be set for NSEC record." - additionals.add(service.dns_nsec(list(missing_types), created=now)) - for answer in answers: - answer_set[answer] = additionals - elif type_ in missing_types: - assert service.server is not None, "Service server must be set for NSEC record." - answer_set[service.dns_nsec(list(missing_types), created=now)] = set() - - def _answer_question( - self, - question: DNSQuestion, - known_answers: DNSRRSet, - now: float, - ) -> _AnswerWithAdditionalsType: - answer_set: _AnswerWithAdditionalsType = {} - question_lower_name = question.name.lower() - - if question.type == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: - self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) - return answer_set - - type_ = question.type - - if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question_lower_name, answer_set, known_answers, now) - - if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question_lower_name, answer_set, known_answers, now, type_) - - if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): - service = self.registry.async_get_info_name(question_lower_name) - if service is not None: - if type_ in (_TYPE_SRV, _TYPE_ANY): - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.2. - dns_service = service.dns_service(created=now) - if not known_answers.suppresses(dns_service): - answer_set[dns_service] = service.get_address_and_nsec_records(created=now) - if type_ in (_TYPE_TXT, _TYPE_ANY): - dns_text = service.dns_text(created=now) - if not known_answers.suppresses(dns_text): - answer_set[dns_text] = set() - - return answer_set - - def async_response( # pylint: disable=unused-argument - self, msgs: List[DNSIncoming], ucast_source: bool - ) -> QuestionAnswers: - """Deal with incoming query packets. Provides a response if possible. - - This function must be run in the event loop as it is not - threadsafe. - """ - known_answers = DNSRRSet([msg.answers for msg in msgs if not msg.is_probe]) - query_res = _QueryResponse(self.cache, msgs) - - for msg in msgs: - for question in msg.questions: - if not question.unicast: - self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) - answer_set = self._answer_question(question, known_answers, msg.now) - if not ucast_source and question.unicast: - query_res.add_qu_question_response(answer_set) - continue - if ucast_source: - query_res.add_ucast_question_response(answer_set) - # We always multicast as well even if its a unicast - # source as long as we haven't done it recently (75% of ttl) - query_res.add_mcast_question_response(answer_set) - - return query_res.answers() - - -class RecordManager: - """Process records into the cache and notify listeners.""" - - __slots__ = ("zc", "cache", "listeners") - - def __init__(self, zeroconf: 'Zeroconf') -> None: - """Init the record manager.""" - self.zc = zeroconf - self.cache = zeroconf.cache - self.listeners: List[RecordUpdateListener] = [] - - def async_updates(self, now: float, records: List[RecordUpdate]) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called before the cache is updated. - - This method will be run in the event loop. - """ - for listener in self.listeners: - listener.async_update_records(self.zc, now, records) - - def async_updates_complete(self, notify: bool) -> None: - """Used to notify listeners of new information that has updated - a record. - - This method must be called after the cache is updated. - - This method will be run in the event loop. - """ - for listener in self.listeners: - listener.async_update_records_complete() - if notify: - self.zc.async_notify_all() - - def async_updates_from_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified. - - This function must be run in the event loop as it is not - threadsafe. - """ - updates: List[RecordUpdate] = [] - address_adds: List[DNSRecord] = [] - other_adds: List[DNSRecord] = [] - removes: Set[DNSRecord] = set() - now = msg.now - unique_types: Set[Tuple[str, int, int]] = set() - cache = self.cache - - for record in msg.answers: - # Protect zeroconf from records that can cause denial of service. - # - # We enforce a minimum TTL for PTR records to avoid - # ServiceBrowsers generating excessive queries refresh queries. - # Apple uses a 15s minimum TTL, however we do not have the same - # level of rate limit and safe guards so we use 1/4 of the recommended value. - record_type = record.type - record_ttl = record.ttl - if record_ttl and record_type == _TYPE_PTR and record_ttl < _DNS_PTR_MIN_TTL: - log.debug( - "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", - record, - _DNS_PTR_MIN_TTL, - ) - record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) - - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 - unique_types.add((record.name, record_type, record.class_)) - - if TYPE_CHECKING: - record = cast(_UniqueRecordsType, record) - - maybe_entry = cache.async_get_unique(record) - if not record.is_expired(now): - if maybe_entry is not None: - maybe_entry.reset_ttl(record) - else: - if record.type in _ADDRESS_RECORD_TYPES: - address_adds.append(record) - else: - other_adds.append(record) - updates.append(RecordUpdate(record, maybe_entry)) - # This is likely a goodbye since the record is - # expired and exists in the cache - elif maybe_entry is not None: - updates.append(RecordUpdate(record, maybe_entry)) - removes.add(record) - - if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) - - if updates: - self.async_updates(now, updates) - # The cache adds must be processed AFTER we trigger - # the updates since we compare existing data - # with the new data and updating the cache - # ahead of update_record will cause listeners - # to miss changes - # - # We must process address adds before non-addresses - # otherwise a fetch of ServiceInfo may miss an address - # because it thinks the cache is complete - # - # The cache is processed under the context manager to ensure - # that any ServiceBrowser that is going to call - # zc.get_service_info will see the cached value - # but ONLY after all the record updates have been - # processsed. - new = False - if other_adds or address_adds: - new = cache.async_add_records(itertools.chain(address_adds, other_adds)) - # Removes are processed last since - # ServiceInfo could generate an un-needed query - # because the data was not yet populated. - if removes: - cache.async_remove_records(removes) - if updates: - self.async_updates_complete(new) - - def async_add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] - ) -> None: - """Adds a listener for a given question. The listener will have - its update_record method called when information is available to - answer the question(s). - - This function is not thread-safe and must be called in the eventloop. - """ - if not isinstance(listener, RecordUpdateListener): - log.error( # type: ignore[unreachable] - "listeners passed to async_add_listener must inherit from RecordUpdateListener;" - " In the future this will fail" - ) - - self.listeners.append(listener) - - if question is None: - return - - questions = [question] if isinstance(question, DNSQuestion) else question - assert self.zc.loop is not None - self._async_update_matching_records(listener, questions) - - def _async_update_matching_records( - self, listener: RecordUpdateListener, questions: List[DNSQuestion] - ) -> None: - """Calls back any existing entries in the cache that answer the question. - - This function must be run from the event loop. - """ - now = current_time_millis() - records: List[RecordUpdate] = [ - RecordUpdate(record, None) - for question in questions - for record in self.cache.async_entries_with_name(question.name) - if not record.is_expired(now) and question.answered_by(record) - ] - if not records: - return - listener.async_update_records(self.zc, now, records) - listener.async_update_records_complete() - self.zc.async_notify_all() - - def async_remove_listener(self, listener: RecordUpdateListener) -> None: - """Removes a listener. - - This function is not threadsafe and must be called in the eventloop. - """ - try: - self.listeners.remove(listener) - self.zc.async_notify_all() - except ValueError as e: - log.exception('Failed to remove listener: %r', e) - - -class MulticastOutgoingQueue: - """An outgoing queue used to aggregate multicast responses.""" - - __slots__ = ("zc", "queue", "additional_delay", "aggregation_delay") - - def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: - self.zc = zeroconf - self.queue: deque[AnswerGroup] = deque() - # Additional delay is used to implement - # Protect the network against excessive packet flooding - # https://datatracker.ietf.org/doc/html/rfc6762#section-14 - self.additional_delay = additional_delay - self.aggregation_delay = max_aggregation_delay - - def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: - """Add a group of answers with additionals to the outgoing queue.""" - assert self.zc.loop is not None - random_delay = random.randint(*_MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay - send_after = now + random_delay - send_before = now + self.aggregation_delay + self.additional_delay - if len(self.queue): - # If we calculate a random delay for the send after time - # that is less than the last group scheduled to go out, - # we instead add the answers to the last group as this - # allows aggregating additonal responses - last_group = self.queue[-1] - if send_after <= last_group.send_after: - last_group.answers.update(answers) - return - else: - self.zc.loop.call_later(millis_to_seconds(random_delay), self.async_ready) - self.queue.append(AnswerGroup(send_after, send_before, answers)) - - def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: - """Remove a set of answers from the outgoing queue.""" - for pending in self.queue: - for record in answers: - pending.answers.pop(record, None) - - def async_ready(self) -> None: - """Process anything in the queue that is ready.""" - assert self.zc.loop is not None - now = current_time_millis() - - if len(self.queue) > 1 and self.queue[0].send_before > now: - # There is more than one answer in the queue, - # delay until we have to send it (first answer group reaches send_before) - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self.async_ready) - return - - answers: _AnswerWithAdditionalsType = {} - # Add all groups that can be sent now - while len(self.queue) and self.queue[0].send_after <= now: - answers.update(self.queue.popleft().answers) - - if len(self.queue): - # If there are still groups in the queue that are not ready to send - # be sure we schedule them to go out later - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self.async_ready) - - if answers: - # If we have the same answer scheduled to go out, remove them - self._remove_answers_from_queue(answers) - self.zc.async_send(construct_outgoing_multicast_answers(answers)) diff --git a/src/zeroconf/_handlers/__init__.py b/src/zeroconf/_handlers/__init__.py new file mode 100644 index 000000000..2ef4b15b1 --- /dev/null +++ b/src/zeroconf/_handlers/__init__.py @@ -0,0 +1,21 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py new file mode 100644 index 000000000..a80d2367a --- /dev/null +++ b/src/zeroconf/_handlers/answers.py @@ -0,0 +1,84 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from operator import attrgetter +from typing import Dict, List, NamedTuple, Set + +from .._dns import DNSQuestion, DNSRecord +from .._protocol.outgoing import DNSOutgoing +from ..const import _FLAGS_AA, _FLAGS_QR_RESPONSE + +_AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] + + +MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) + +NAME_GETTER = attrgetter('name') + + +class QuestionAnswers(NamedTuple): + ucast: _AnswerWithAdditionalsType + mcast_now: _AnswerWithAdditionalsType + mcast_aggregate: _AnswerWithAdditionalsType + mcast_aggregate_last_second: _AnswerWithAdditionalsType + + +class AnswerGroup(NamedTuple): + """A group of answers scheduled to be sent at the same time.""" + + send_after: float # Must be sent after this time + send_before: float # Must be sent before this time + answers: _AnswerWithAdditionalsType + + +def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: + """Add answers and additionals to a DNSOutgoing.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) + _add_answers_additionals(out, answers) + return out + + +def construct_outgoing_unicast_answers( + answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int +) -> DNSOutgoing: + """Add answers and additionals to a DNSOutgoing.""" + out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=id_) + # Adding the questions back when the source is legacy unicast behavior + if ucast_source: + for question in questions: + out.add_question(question) + _add_answers_additionals(out, answers) + return out + + +def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: + # Find additionals and suppress any additionals that are already in answers + sending: Set[DNSRecord] = set(answers) + # Answers are sorted to group names together to increase the chance + # that similar names will end up in the same packet and can reduce the + # overall size of the outgoing response via name compression + for answer in sorted(answers, key=NAME_GETTER): + out.add_answer_at_time(answer, 0) + for additional in answers[answer]: + if additional not in sending: + out.add_additional_answer(additional) + sending.add(additional) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py new file mode 100644 index 000000000..0e469d288 --- /dev/null +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -0,0 +1,102 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import random +from collections import deque +from typing import TYPE_CHECKING + +from .._utils.time import current_time_millis, millis_to_seconds +from .answers import ( + MULTICAST_DELAY_RANDOM_INTERVAL, + AnswerGroup, + _AnswerWithAdditionalsType, + construct_outgoing_multicast_answers, +) + +if TYPE_CHECKING: + from .._core import Zeroconf + + +class MulticastOutgoingQueue: + """An outgoing queue used to aggregate multicast responses.""" + + __slots__ = ("zc", "queue", "additional_delay", "aggregation_delay") + + def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: + self.zc = zeroconf + self.queue: deque[AnswerGroup] = deque() + # Additional delay is used to implement + # Protect the network against excessive packet flooding + # https://datatracker.ietf.org/doc/html/rfc6762#section-14 + self.additional_delay = additional_delay + self.aggregation_delay = max_aggregation_delay + + def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: + """Add a group of answers with additionals to the outgoing queue.""" + assert self.zc.loop is not None + random_delay = random.randint(*MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay + send_after = now + random_delay + send_before = now + self.aggregation_delay + self.additional_delay + if len(self.queue): + # If we calculate a random delay for the send after time + # that is less than the last group scheduled to go out, + # we instead add the answers to the last group as this + # allows aggregating additonal responses + last_group = self.queue[-1] + if send_after <= last_group.send_after: + last_group.answers.update(answers) + return + else: + self.zc.loop.call_later(millis_to_seconds(random_delay), self.async_ready) + self.queue.append(AnswerGroup(send_after, send_before, answers)) + + def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: + """Remove a set of answers from the outgoing queue.""" + for pending in self.queue: + for record in answers: + pending.answers.pop(record, None) + + def async_ready(self) -> None: + """Process anything in the queue that is ready.""" + assert self.zc.loop is not None + now = current_time_millis() + + if len(self.queue) > 1 and self.queue[0].send_before > now: + # There is more than one answer in the queue, + # delay until we have to send it (first answer group reaches send_before) + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self.async_ready) + return + + answers: _AnswerWithAdditionalsType = {} + # Add all groups that can be sent now + while len(self.queue) and self.queue[0].send_after <= now: + answers.update(self.queue.popleft().answers) + + if len(self.queue): + # If there are still groups in the queue that are not ready to send + # be sure we schedule them to go out later + self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self.async_ready) + + if answers: + # If we have the same answer scheduled to go out, remove them + self._remove_answers_from_queue(answers) + self.zc.async_send(construct_outgoing_multicast_answers(answers)) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py new file mode 100644 index 000000000..e4b635081 --- /dev/null +++ b/src/zeroconf/_handlers/query_handler.py @@ -0,0 +1,286 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + + +from typing import TYPE_CHECKING, List, Set, cast + +from .._cache import DNSCache, _UniqueRecordsType +from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet +from .._history import QuestionHistory +from .._protocol.incoming import DNSIncoming +from .._services.registry import ServiceRegistry +from ..const import ( + _ADDRESS_RECORD_TYPES, + _CLASS_IN, + _DNS_OTHER_TTL, + _ONE_SECOND, + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_A, + _TYPE_AAAA, + _TYPE_ANY, + _TYPE_NSEC, + _TYPE_PTR, + _TYPE_SRV, + _TYPE_TXT, +) +from .answers import QuestionAnswers, _AnswerWithAdditionalsType + +_RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} + + +class _QueryResponse: + """A pair for unicast and multicast DNSOutgoing responses.""" + + __slots__ = ( + "_is_probe", + "_msg", + "_now", + "_cache", + "_additionals", + "_ucast", + "_mcast_now", + "_mcast_aggregate", + "_mcast_aggregate_last_second", + ) + + def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: + """Build a query response.""" + self._is_probe = False + for msg in msgs: + if msg.is_probe: + self._is_probe = True + break + self._msg = msgs[0] + self._now = self._msg.now + self._cache = cache + self._additionals: _AnswerWithAdditionalsType = {} + self._ucast: Set[DNSRecord] = set() + self._mcast_now: Set[DNSRecord] = set() + self._mcast_aggregate: Set[DNSRecord] = set() + self._mcast_aggregate_last_second: Set[DNSRecord] = set() + + def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: + """Generate a response to a multicast QU query.""" + for record, additionals in answers.items(): + self._additionals[record] = additionals + if self._is_probe: + self._ucast.add(record) + if not self._has_mcast_within_one_quarter_ttl(record): + self._mcast_now.add(record) + elif not self._is_probe: + self._ucast.add(record) + + def add_ucast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: + """Generate a response to a unicast query.""" + self._additionals.update(answers) + self._ucast.update(answers) + + def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> None: + """Generate a response to a multicast query.""" + self._additionals.update(answers) + for answer in answers: + if self._is_probe: + self._mcast_now.add(answer) + continue + + if self._has_mcast_record_in_last_second(answer): + self._mcast_aggregate_last_second.add(answer) + elif len(self._msg.questions) == 1 and self._msg.questions[0].type in _RESPOND_IMMEDIATE_TYPES: + self._mcast_now.add(answer) + else: + self._mcast_aggregate.add(answer) + + def _generate_answers_with_additionals(self, rrset: Set[DNSRecord]) -> _AnswerWithAdditionalsType: + """Create answers with additionals from an rrset.""" + return {record: self._additionals[record] for record in rrset} + + def answers( + self, + ) -> QuestionAnswers: + """Return answer sets that will be queued.""" + return QuestionAnswers( + self._generate_answers_with_additionals(self._ucast), + self._generate_answers_with_additionals(self._mcast_now), + self._generate_answers_with_additionals(self._mcast_aggregate), + self._generate_answers_with_additionals(self._mcast_aggregate_last_second), + ) + + def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: + """Check to see if a record has been mcasted recently. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 + When receiving a question with the unicast-response bit set, a + responder SHOULD usually respond with a unicast packet directed back + to the querier. However, if the responder has not multicast that + record recently (within one quarter of its TTL), then the responder + SHOULD instead multicast the response so as to keep all the peer + caches up to date + """ + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + maybe_entry = self._cache.async_get_unique(record) + return bool(maybe_entry and maybe_entry.is_recent(self._now)) + + def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: + """Check if an answer was seen in the last second. + Protect the network against excessive packet flooding + https://datatracker.ietf.org/doc/html/rfc6762#section-14 + """ + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + maybe_entry = self._cache.async_get_unique(record) + return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) + + +class QueryHandler: + """Query the ServiceRegistry.""" + + __slots__ = ("registry", "cache", "question_history") + + def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: + """Init the query handler.""" + self.registry = registry + self.cache = cache + self.question_history = question_history + + def _add_service_type_enumeration_query_answers( + self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float + ) -> None: + """Provide an answer to a service type enumeration query. + + https://datatracker.ietf.org/doc/html/rfc6763#section-9 + """ + for stype in self.registry.async_get_types(): + dns_pointer = DNSPointer( + _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, now + ) + if not known_answers.suppresses(dns_pointer): + answer_set[dns_pointer] = set() + + def _add_pointer_answers( + self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float + ) -> None: + """Answer PTR/ANY question.""" + for service in self.registry.async_get_infos_type(lower_name): + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.1. + dns_pointer = service.dns_pointer(created=now) + if known_answers.suppresses(dns_pointer): + continue + answer_set[dns_pointer] = { + service.dns_service(created=now), + service.dns_text(created=now), + } | service.get_address_and_nsec_records(created=now) + + def _add_address_answers( + self, + lower_name: str, + answer_set: _AnswerWithAdditionalsType, + known_answers: DNSRRSet, + now: float, + type_: int, + ) -> None: + """Answer A/AAAA/ANY question.""" + for service in self.registry.async_get_infos_server(lower_name): + answers: List[DNSAddress] = [] + additionals: Set[DNSRecord] = set() + seen_types: Set[int] = set() + for dns_address in service.dns_addresses(created=now): + seen_types.add(dns_address.type) + if dns_address.type != type_: + additionals.add(dns_address) + elif not known_answers.suppresses(dns_address): + answers.append(dns_address) + missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + if answers: + if missing_types: + assert service.server is not None, "Service server must be set for NSEC record." + additionals.add(service.dns_nsec(list(missing_types), created=now)) + for answer in answers: + answer_set[answer] = additionals + elif type_ in missing_types: + assert service.server is not None, "Service server must be set for NSEC record." + answer_set[service.dns_nsec(list(missing_types), created=now)] = set() + + def _answer_question( + self, + question: DNSQuestion, + known_answers: DNSRRSet, + now: float, + ) -> _AnswerWithAdditionalsType: + answer_set: _AnswerWithAdditionalsType = {} + question_lower_name = question.name.lower() + + if question.type == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: + self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) + return answer_set + + type_ = question.type + + if type_ in (_TYPE_PTR, _TYPE_ANY): + self._add_pointer_answers(question_lower_name, answer_set, known_answers, now) + + if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): + self._add_address_answers(question_lower_name, answer_set, known_answers, now, type_) + + if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): + service = self.registry.async_get_info_name(question_lower_name) + if service is not None: + if type_ in (_TYPE_SRV, _TYPE_ANY): + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.2. + dns_service = service.dns_service(created=now) + if not known_answers.suppresses(dns_service): + answer_set[dns_service] = service.get_address_and_nsec_records(created=now) + if type_ in (_TYPE_TXT, _TYPE_ANY): + dns_text = service.dns_text(created=now) + if not known_answers.suppresses(dns_text): + answer_set[dns_text] = set() + + return answer_set + + def async_response( # pylint: disable=unused-argument + self, msgs: List[DNSIncoming], ucast_source: bool + ) -> QuestionAnswers: + """Deal with incoming query packets. Provides a response if possible. + + This function must be run in the event loop as it is not + threadsafe. + """ + known_answers = DNSRRSet([msg.answers for msg in msgs if not msg.is_probe]) + query_res = _QueryResponse(self.cache, msgs) + + for msg in msgs: + for question in msg.questions: + if not question.unicast: + self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) + answer_set = self._answer_question(question, known_answers, msg.now) + if not ucast_source and question.unicast: + query_res.add_qu_question_response(answer_set) + continue + if ucast_source: + query_res.add_ucast_question_response(answer_set) + # We always multicast as well even if its a unicast + # source as long as we haven't done it recently (75% of ttl) + query_res.add_mcast_question_response(answer_set) + + return query_res.answers() diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py new file mode 100644 index 000000000..9f0f4787d --- /dev/null +++ b/src/zeroconf/_handlers/record_manager.py @@ -0,0 +1,211 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +import itertools +from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast + +from .._cache import _UniqueRecordsType +from .._dns import DNSQuestion, DNSRecord +from .._logger import log +from .._protocol.incoming import DNSIncoming +from .._updates import RecordUpdate, RecordUpdateListener +from .._utils.time import current_time_millis +from ..const import _ADDRESS_RECORD_TYPES, _DNS_PTR_MIN_TTL, _TYPE_PTR + +if TYPE_CHECKING: + from .._core import Zeroconf + + +class RecordManager: + """Process records into the cache and notify listeners.""" + + __slots__ = ("zc", "cache", "listeners") + + def __init__(self, zeroconf: 'Zeroconf') -> None: + """Init the record manager.""" + self.zc = zeroconf + self.cache = zeroconf.cache + self.listeners: List[RecordUpdateListener] = [] + + def async_updates(self, now: float, records: List[RecordUpdate]) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called before the cache is updated. + + This method will be run in the event loop. + """ + for listener in self.listeners: + listener.async_update_records(self.zc, now, records) + + def async_updates_complete(self, notify: bool) -> None: + """Used to notify listeners of new information that has updated + a record. + + This method must be called after the cache is updated. + + This method will be run in the event loop. + """ + for listener in self.listeners: + listener.async_update_records_complete() + if notify: + self.zc.async_notify_all() + + def async_updates_from_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified. + + This function must be run in the event loop as it is not + threadsafe. + """ + updates: List[RecordUpdate] = [] + address_adds: List[DNSRecord] = [] + other_adds: List[DNSRecord] = [] + removes: Set[DNSRecord] = set() + now = msg.now + unique_types: Set[Tuple[str, int, int]] = set() + cache = self.cache + + for record in msg.answers: + # Protect zeroconf from records that can cause denial of service. + # + # We enforce a minimum TTL for PTR records to avoid + # ServiceBrowsers generating excessive queries refresh queries. + # Apple uses a 15s minimum TTL, however we do not have the same + # level of rate limit and safe guards so we use 1/4 of the recommended value. + record_type = record.type + record_ttl = record.ttl + if record_ttl and record_type == _TYPE_PTR and record_ttl < _DNS_PTR_MIN_TTL: + log.debug( + "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", + record, + _DNS_PTR_MIN_TTL, + ) + record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) + + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + unique_types.add((record.name, record_type, record.class_)) + + if TYPE_CHECKING: + record = cast(_UniqueRecordsType, record) + + maybe_entry = cache.async_get_unique(record) + if not record.is_expired(now): + if maybe_entry is not None: + maybe_entry.reset_ttl(record) + else: + if record.type in _ADDRESS_RECORD_TYPES: + address_adds.append(record) + else: + other_adds.append(record) + updates.append(RecordUpdate(record, maybe_entry)) + # This is likely a goodbye since the record is + # expired and exists in the cache + elif maybe_entry is not None: + updates.append(RecordUpdate(record, maybe_entry)) + removes.add(record) + + if unique_types: + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) + + if updates: + self.async_updates(now, updates) + # The cache adds must be processed AFTER we trigger + # the updates since we compare existing data + # with the new data and updating the cache + # ahead of update_record will cause listeners + # to miss changes + # + # We must process address adds before non-addresses + # otherwise a fetch of ServiceInfo may miss an address + # because it thinks the cache is complete + # + # The cache is processed under the context manager to ensure + # that any ServiceBrowser that is going to call + # zc.get_service_info will see the cached value + # but ONLY after all the record updates have been + # processsed. + new = False + if other_adds or address_adds: + new = cache.async_add_records(itertools.chain(address_adds, other_adds)) + # Removes are processed last since + # ServiceInfo could generate an un-needed query + # because the data was not yet populated. + if removes: + cache.async_remove_records(removes) + if updates: + self.async_updates_complete(new) + + def async_add_listener( + self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + ) -> None: + """Adds a listener for a given question. The listener will have + its update_record method called when information is available to + answer the question(s). + + This function is not thread-safe and must be called in the eventloop. + """ + if not isinstance(listener, RecordUpdateListener): + log.error( # type: ignore[unreachable] + "listeners passed to async_add_listener must inherit from RecordUpdateListener;" + " In the future this will fail" + ) + + self.listeners.append(listener) + + if question is None: + return + + questions = [question] if isinstance(question, DNSQuestion) else question + assert self.zc.loop is not None + self._async_update_matching_records(listener, questions) + + def _async_update_matching_records( + self, listener: RecordUpdateListener, questions: List[DNSQuestion] + ) -> None: + """Calls back any existing entries in the cache that answer the question. + + This function must be run from the event loop. + """ + now = current_time_millis() + records: List[RecordUpdate] = [ + RecordUpdate(record, None) + for question in questions + for record in self.cache.async_entries_with_name(question.name) + if not record.is_expired(now) and question.answered_by(record) + ] + if not records: + return + listener.async_update_records(self.zc, now, records) + listener.async_update_records_complete() + self.zc.async_notify_all() + + def async_remove_listener(self, listener: RecordUpdateListener) -> None: + """Removes a listener. + + This function is not threadsafe and must be called in the eventloop. + """ + try: + self.listeners.remove(listener) + self.zc.async_notify_all() + except ValueError as e: + log.exception('Failed to remove listener: %r', e) diff --git a/tests/__init__.py b/tests/__init__.py index 959cc3f39..f203ff07d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -81,6 +81,6 @@ def has_working_ipv6(): return False -def _clear_cache(zc): +def _clear_cache(zc: Zeroconf) -> None: zc.cache.cache.clear() zc.question_history.clear() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index d49295fa7..d269f85cf 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -22,11 +22,11 @@ DNSQuestion, Zeroconf, _engine, - _handlers, const, current_time_millis, millis_to_seconds, ) +from zeroconf._handlers import record_manager from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo @@ -1151,7 +1151,7 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: zc.close() -@patch.object(_handlers, '_DNS_PTR_MIN_TTL', 1) +@patch.object(record_manager, '_DNS_PTR_MIN_TTL', 1) @patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01) def test_service_browser_expire_callbacks(): """Test that the ServiceBrowser matching does not match partial names.""" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 607f68193..4cfdd8e98 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -15,8 +15,9 @@ import pytest import zeroconf as r -from zeroconf import ServiceInfo, Zeroconf, _handlers, const, current_time_millis -from zeroconf._handlers import ( +from zeroconf import ServiceInfo, Zeroconf, const, current_time_millis +from zeroconf._handlers import multicast_outgoing_queue +from zeroconf._handlers.multicast_outgoing_queue import ( MulticastOutgoingQueue, construct_outgoing_multicast_answers, ) @@ -1575,15 +1576,15 @@ async def test_response_aggregation_random_delay(): outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 500) now = current_time_millis() - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (500, 600)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (500, 600)): outgoing_queue.async_add(now, {info.dns_pointer(): set()}) # The second group should always be coalesced into first group since it will always come before - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (300, 400)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (300, 400)): outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) # The third group should always be coalesced into first group since it will always come before - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (100, 200)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (100, 200)): outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 @@ -1593,7 +1594,7 @@ async def test_response_aggregation_random_delay(): assert info4.dns_pointer() in outgoing_queue.queue[0].answers # The forth group should not be coalesced because its scheduled after the last group in the queue - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (700, 800)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (700, 800)): outgoing_queue.async_add(now, {info5.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 2 @@ -1624,17 +1625,19 @@ async def test_future_answers_are_removed_on_send(): outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) now = current_time_millis() - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (1, 1)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (1, 1)): outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (2, 2)): + with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (2, 2)): outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 2 - with unittest.mock.patch.object(_handlers, "_MULTICAST_DELAY_RANDOM_INTERVAL", (1000, 1000)): + with unittest.mock.patch.object( + multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (1000, 1000) + ): outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) outgoing_queue.async_add(now, {info.dns_pointer(): set()}) From a7dad3d9743586f352e21eea1e129c6875f9a713 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Aug 2023 20:58:19 -0500 Subject: [PATCH 0916/1433] feat: improve performance by adding cython pxd for RecordManager (#1241) --- build_ext.py | 1 + src/zeroconf/_handlers/record_manager.pxd | 23 +++++++++++++++++++++++ src/zeroconf/_handlers/record_manager.py | 4 ++-- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 src/zeroconf/_handlers/record_manager.pxd diff --git a/build_ext.py b/build_ext.py index f2c592884..1b27457da 100644 --- a/build_ext.py +++ b/build_ext.py @@ -29,6 +29,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", + "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_services/registry.py", ], compiler_directives={"language_level": "3"}, # Python 3 diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd new file mode 100644 index 000000000..7616bead2 --- /dev/null +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -0,0 +1,23 @@ + +import cython + +from .._cache cimport DNSCache +from .._dns cimport DNSRecord +from .._protocol.incoming cimport DNSIncoming + + +cdef cython.float _DNS_PTR_MIN_TTL +cdef object _ADDRESS_RECORD_TYPES +cdef object RecordUpdate + +cdef class RecordManager: + + cdef object zc + cdef DNSCache cache + cdef cython.list listeners + + @cython.locals( + cache=DNSCache, + record=DNSRecord + ) + cpdef async_updates_from_response(self, DNSIncoming msg) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 9f0f4787d..94a37b78e 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -20,7 +20,6 @@ USA """ -import itertools from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast from .._cache import _UniqueRecordsType @@ -146,7 +145,8 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # processsed. new = False if other_adds or address_adds: - new = cache.async_add_records(itertools.chain(address_adds, other_adds)) + new = cache.async_add_records(address_adds) + new |= cache.async_add_records(other_adds) # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. From f8ad5a2df2914ab310ac3fd34343e7e09e66ebf6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 29 Aug 2023 02:18:59 +0000 Subject: [PATCH 0917/1433] 0.87.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87aebdc31..d8bbc85cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.87.0 (2023-08-29) + +### Feature + +* Improve performance by adding cython pxd for RecordManager ([#1241](https://github.com/python-zeroconf/python-zeroconf/issues/1241)) ([`a7dad3d`](https://github.com/python-zeroconf/python-zeroconf/commit/a7dad3d9743586f352e21eea1e129c6875f9a713)) + ## v0.86.0 (2023-08-28) ### Feature diff --git a/pyproject.toml b/pyproject.toml index f3c7e7b71..34ad7e69f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.86.0" +version = "0.87.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index cbd3dce53..3d63213b4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.86.0' +__version__ = '0.87.0' __license__ = 'LGPL' From 5a76fc5ff74f2941ffbf7570e45390f35e0b7e01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Aug 2023 21:47:13 -0500 Subject: [PATCH 0918/1433] feat: speed up RecordManager with additional cython defs (#1242) --- src/zeroconf/_cache.pxd | 8 ++++++++ src/zeroconf/_handlers/record_manager.pxd | 16 ++++++++++++---- src/zeroconf/_handlers/record_manager.py | 12 ++++++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 07eeb8079..6bc9ea5de 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -23,6 +23,12 @@ cdef class DNSCache: cdef public cython.dict cache cdef public cython.dict service_cache + cpdef async_add_records(self, object entries) + + cpdef async_remove_records(self, object entries) + + cpdef async_get_unique(self, DNSRecord entry) + @cython.locals( records=cython.dict, record=DNSRecord, @@ -33,6 +39,8 @@ cdef class DNSCache: cdef _async_remove(self, DNSRecord record) + cpdef async_mark_unique_records_older_than_1s_to_expire(self, object unique_types, object answers, object now) + @cython.locals( record=DNSRecord, ) diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 7616bead2..7a55e64fa 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -9,15 +9,23 @@ from .._protocol.incoming cimport DNSIncoming cdef cython.float _DNS_PTR_MIN_TTL cdef object _ADDRESS_RECORD_TYPES cdef object RecordUpdate +cdef object TYPE_CHECKING +cdef object _TYPE_PTR cdef class RecordManager: - cdef object zc - cdef DNSCache cache - cdef cython.list listeners + cdef public object zc + cdef public DNSCache cache + cdef public cython.list listeners + + cpdef async_updates(self, object now, object records) + + cpdef async_updates_complete(self, object notify) @cython.locals( cache=DNSCache, - record=DNSRecord + record=DNSRecord, + maybe_entry=DNSRecord, + now_float=cython.float ) cpdef async_updates_from_response(self, DNSIncoming msg) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 94a37b78e..5e4f7c9b3 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -33,6 +33,8 @@ if TYPE_CHECKING: from .._core import Zeroconf +_float = float + class RecordManager: """Process records into the cache and notify listeners.""" @@ -45,7 +47,7 @@ def __init__(self, zeroconf: 'Zeroconf') -> None: self.cache = zeroconf.cache self.listeners: List[RecordUpdateListener] = [] - def async_updates(self, now: float, records: List[RecordUpdate]) -> None: + def async_updates(self, now: _float, records: List[RecordUpdate]) -> None: """Used to notify listeners of new information that has updated a record. @@ -81,6 +83,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: other_adds: List[DNSRecord] = [] removes: Set[DNSRecord] = set() now = msg.now + now_float = now unique_types: Set[Tuple[str, int, int]] = set() cache = self.cache @@ -108,11 +111,11 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: record = cast(_UniqueRecordsType, record) maybe_entry = cache.async_get_unique(record) - if not record.is_expired(now): + if not record.is_expired(now_float): if maybe_entry is not None: maybe_entry.reset_ttl(record) else: - if record.type in _ADDRESS_RECORD_TYPES: + if record_type in _ADDRESS_RECORD_TYPES: address_adds.append(record) else: other_adds.append(record) @@ -146,7 +149,8 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: new = False if other_adds or address_adds: new = cache.async_add_records(address_adds) - new |= cache.async_add_records(other_adds) + if cache.async_add_records(other_adds): + new = True # Removes are processed last since # ServiceInfo could generate an un-needed query # because the data was not yet populated. From a3e98cb77baeddb5098e669b852a9ad951fd2506 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 29 Aug 2023 02:55:15 +0000 Subject: [PATCH 0919/1433] 0.88.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bbc85cd..2ff20c2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.88.0 (2023-08-29) + +### Feature + +* Speed up RecordManager with additional cython defs ([#1242](https://github.com/python-zeroconf/python-zeroconf/issues/1242)) ([`5a76fc5`](https://github.com/python-zeroconf/python-zeroconf/commit/5a76fc5ff74f2941ffbf7570e45390f35e0b7e01)) + ## v0.87.0 (2023-08-29) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 34ad7e69f..594ee6bc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.87.0" +version = "0.88.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3d63213b4..de54784b6 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.87.0' +__version__ = '0.88.0' __license__ = 'LGPL' From 18b65d1c75622869b0c29258215d3db3ae520d6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 19:09:42 -0500 Subject: [PATCH 0920/1433] feat: reduce overhead to process incoming questions (#1244) --- build_ext.py | 1 + src/zeroconf/_listener.pxd | 6 +++--- src/zeroconf/_utils/time.pxd | 4 ++++ src/zeroconf/_utils/time.py | 4 +++- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 src/zeroconf/_utils/time.pxd diff --git a/build_ext.py b/build_ext.py index 1b27457da..8e4e7b99f 100644 --- a/build_ext.py +++ b/build_ext.py @@ -31,6 +31,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_protocol/outgoing.py", "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_services/registry.py", + "src/zeroconf/_utils/time.py", ], compiler_directives={"language_level": "3"}, # Python 3 ), diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 0f32a44a7..87ed8b5f2 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -1,13 +1,13 @@ import cython +from ._protocol.incoming cimport DNSIncoming +from ._utils.time cimport current_time_millis, millis_to_seconds + -cdef object millis_to_seconds cdef object log cdef object logging_DEBUG -from ._protocol.incoming cimport DNSIncoming - cdef class AsyncListener: diff --git a/src/zeroconf/_utils/time.pxd b/src/zeroconf/_utils/time.pxd new file mode 100644 index 000000000..367f39b66 --- /dev/null +++ b/src/zeroconf/_utils/time.pxd @@ -0,0 +1,4 @@ + +cpdef current_time_millis() + +cpdef millis_to_seconds(object millis) diff --git a/src/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py index 59362c557..c6811585c 100644 --- a/src/zeroconf/_utils/time.py +++ b/src/zeroconf/_utils/time.py @@ -23,6 +23,8 @@ import time +_float = float + def current_time_millis() -> float: """Current time in milliseconds. @@ -33,6 +35,6 @@ def current_time_millis() -> float: return time.monotonic() * 1000 -def millis_to_seconds(millis: float) -> float: +def millis_to_seconds(millis: _float) -> float: """Convert milliseconds to seconds.""" return millis / 1000.0 From a0b6266fa4d1500421df1696f7e7dc723ea54672 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 00:18:06 +0000 Subject: [PATCH 0921/1433] 0.89.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff20c2d5..bb68ad094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.89.0 (2023-09-02) + +### Feature + +* Reduce overhead to process incoming questions ([#1244](https://github.com/python-zeroconf/python-zeroconf/issues/1244)) ([`18b65d1`](https://github.com/python-zeroconf/python-zeroconf/commit/18b65d1c75622869b0c29258215d3db3ae520d6c)) + ## v0.88.0 (2023-08-29) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 594ee6bc6..6b228f133 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.88.0" +version = "0.89.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index de54784b6..d69d68645 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.88.0' +__version__ = '0.89.0' __license__ = 'LGPL' From 36ae505dc9f95b59fdfb632960845a45ba8575b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 20:01:55 -0500 Subject: [PATCH 0922/1433] refactor: reduce duplicate code in engine.py (#1246) --- src/zeroconf/_engine.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 44435750e..a74c091c7 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -80,8 +80,7 @@ def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[thr async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: """Set up the instance.""" - assert self.loop is not None - self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) + self._async_schedule_next_cache_cleanup() await self._async_create_endpoints() assert self.running_event is not None self.running_event.set() @@ -118,8 +117,13 @@ def _async_cache_cleanup(self) -> None: now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] ) self.zc.record_manager.async_updates_complete(False) - assert self.loop is not None - self._cleanup_timer = self.loop.call_later(_CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) + self._async_schedule_next_cache_cleanup() + + def _async_schedule_next_cache_cleanup(self) -> None: + """Schedule the next cache cleanup.""" + loop = self.loop + assert loop is not None + self._cleanup_timer = loop.call_at(loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" From 816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 20:02:01 -0500 Subject: [PATCH 0923/1433] feat: avoid python float conversion in listener hot path (#1245) --- src/zeroconf/_listener.pxd | 5 +++++ src/zeroconf/_listener.py | 23 +++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 87ed8b5f2..75114d565 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -7,7 +7,10 @@ from ._utils.time cimport current_time_millis, millis_to_seconds cdef object log cdef object logging_DEBUG +cdef object TYPE_CHECKING +cdef cython.uint _MAX_MSG_ABSOLUTE +cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL cdef class AsyncListener: @@ -22,3 +25,5 @@ cdef class AsyncListener: @cython.locals(now=cython.float, msg=DNSIncoming) cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) + + cdef _cancel_any_timers_for_addr(self, object addr) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index bc0af296f..8da9381c9 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -38,6 +38,8 @@ _bytes = bytes +_str = str +_int = int logging_DEBUG = logging.DEBUG @@ -110,10 +112,13 @@ def datagram_received( ) return - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () if len(addrs) == 2: + v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore + addr_port = addrs + if TYPE_CHECKING: + addr_port = cast(Tuple[str, int], addr_port) scope = None else: # https://github.com/python/mypy/issues/1178 @@ -121,8 +126,9 @@ def datagram_received( if debug: # pragma: no branch log.debug('IPv6 scope_id %d associated to the receiving interface', scope) v6_flow_scope = (flow, scope) + addr_port = (addr, port) - msg = DNSIncoming(data, (addr, port), scope, now) + msg = DNSIncoming(data, addr_port, scope, now) self.data = data self.last_time = now self.last_message = msg @@ -176,13 +182,14 @@ def handle_query_or_defer( return deferred.append(msg) delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) - assert self.zc.loop is not None + loop = self.zc.loop + assert loop is not None self._cancel_any_timers_for_addr(addr) - self._timers[addr] = self.zc.loop.call_later( - delay, self._respond_query, None, addr, port, transport, v6_flow_scope + self._timers[addr] = loop.call_at( + loop.time() + delay, self._respond_query, None, addr, port, transport, v6_flow_scope ) - def _cancel_any_timers_for_addr(self, addr: str) -> None: + def _cancel_any_timers_for_addr(self, addr: _str) -> None: """Cancel any future truncated packet timers for the address.""" if addr in self._timers: self._timers.pop(addr).cancel() @@ -190,8 +197,8 @@ def _cancel_any_timers_for_addr(self, addr: str) -> None: def _respond_query( self, msg: Optional[DNSIncoming], - addr: str, - port: int, + addr: _str, + port: _int, transport: _WrappedTransport, v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), ) -> None: From f26218da633da0d57a6892ba6ac0a7847bc0b6a6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 01:10:40 +0000 Subject: [PATCH 0924/1433] 0.90.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb68ad094..82f2aa001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.90.0 (2023-09-02) + +### Feature + +* Avoid python float conversion in listener hot path ([#1245](https://github.com/python-zeroconf/python-zeroconf/issues/1245)) ([`816ad4d`](https://github.com/python-zeroconf/python-zeroconf/commit/816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a)) + ## v0.89.0 (2023-09-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 6b228f133..880467861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.89.0" +version = "0.90.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d69d68645..077799817 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.89.0' +__version__ = '0.90.0' __license__ = 'LGPL' From 5e31f0afe4c341fbdbbbe50348a829ea553cbda0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 22:23:11 -0500 Subject: [PATCH 0925/1433] feat: reduce overhead to process incoming updates by avoiding the handle_response shim (#1247) --- src/zeroconf/_core.py | 1 + src/zeroconf/_listener.pxd | 4 ++++ src/zeroconf/_listener.py | 4 +++- src/zeroconf/_protocol/incoming.pxd | 4 ++++ tests/__init__.py | 2 +- tests/services/test_info.py | 30 ++++++++++++++--------------- tests/test_asyncio.py | 1 + tests/test_core.py | 2 +- tests/test_handlers.py | 10 +++++----- 9 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 4960f1e07..aebcee344 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -564,6 +564,7 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: def handle_response(self, msg: DNSIncoming) -> None: """Deal with incoming response packets. All answers are held in the cache, and listeners are notified.""" + self.log_warning_once("handle_response is deprecated, use record_manager.async_updates_from_response") self.record_manager.async_updates_from_response(msg) def handle_assembled_query( diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 75114d565..4e4144c78 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -1,6 +1,7 @@ import cython +from ._handlers.record_manager cimport RecordManager from ._protocol.incoming cimport DNSIncoming from ._utils.time cimport current_time_millis, millis_to_seconds @@ -12,9 +13,12 @@ cdef object TYPE_CHECKING cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL + + cdef class AsyncListener: cdef public object zc + cdef RecordManager _record_manager cdef public cython.bytes data cdef public cython.float last_time cdef public DNSIncoming last_message diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 8da9381c9..913c169f8 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -55,6 +55,7 @@ class AsyncListener: __slots__ = ( 'zc', + '_record_manager', 'data', 'last_time', 'last_message', @@ -66,6 +67,7 @@ class AsyncListener: def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc + self._record_manager = zc.record_manager self.data: Optional[bytes] = None self.last_time: float = 0 self.last_message: Optional[DNSIncoming] = None @@ -156,7 +158,7 @@ def datagram_received( return if not msg.is_query(): - self.zc.handle_response(msg) + self._record_manager.async_updates_from_response(msg) return self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index a7130b663..604b1e30a 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -70,6 +70,10 @@ cdef class DNSIncoming: ) cpdef has_qu_question(self) + cpdef is_query(self) + + cpdef is_response(self) + @cython.locals( off=cython.uint, label_idx=cython.uint, diff --git a/tests/__init__.py b/tests/__init__.py index f203ff07d..98cd901c5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -42,7 +42,7 @@ def _inject_responses(zc: Zeroconf, msgs: List[DNSIncoming]) -> None: async def _wait_for_response(): for msg in msgs: - zc.handle_response(msg) + zc.record_manager.async_updates_from_response(msg) asyncio.run_coroutine_threadsafe(_wait_for_response(), zc.loop).result() diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 1fc3bd014..c0a4e661f 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -903,7 +903,7 @@ async def test_release_wait_when_new_recorded_added(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) assert await asyncio.wait_for(task, timeout=2) assert info.addresses == [b'\x7f\x00\x00\x01'] await aiozc.async_close() @@ -966,7 +966,7 @@ async def test_port_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -982,7 +982,7 @@ async def test_port_changes_are_seen(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1049,7 +1049,7 @@ async def test_port_changes_are_seen_with_directed_request(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1065,7 +1065,7 @@ async def test_port_changes_are_seen_with_directed_request(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200, addr="127.0.0.1", port=5353) @@ -1131,7 +1131,7 @@ async def test_ipv4_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] @@ -1147,7 +1147,7 @@ async def test_ipv4_changes_are_seen(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) @@ -1213,7 +1213,7 @@ async def test_ipv6_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V6Only) == [ @@ -1231,7 +1231,7 @@ async def test_ipv6_changes_are_seen(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) @@ -1295,7 +1295,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] @@ -1354,7 +1354,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1374,7 +1374,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1426,7 +1426,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1456,7 +1456,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): ), 0, ) - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1530,7 +1530,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): await asyncio.sleep(0) for task in tasks: assert not task.done() - aiozc.zeroconf.handle_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) _, pending = await asyncio.wait(tasks, timeout=2) assert not pending assert info.addresses == [b'\x7f\x00\x00\x01'] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 395a16ea0..18e8c8e09 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1192,6 +1192,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de 0, ) + zc.record_manager.async_updates_from_response(DNSIncoming(generated.packets()[0])) zc.handle_response(DNSIncoming(generated.packets()[0])) await browser.async_cancel() diff --git a/tests/test_core.py b/tests/test_core.py index 303e28ef3..4bce6db97 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -105,7 +105,7 @@ def test_launch_and_close_apple_p2p_on_mac(self): rv = r.Zeroconf(apple_p2p=True) rv.close() - def test_handle_response(self): + def test_async_updates_from_response(self): def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: ttl = 120 generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 4cfdd8e98..bdd16c3c4 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1423,7 +1423,7 @@ async def test_response_aggregation_timings(run_isolated): assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - zc.handle_response(incoming) + zc.record_manager.async_updates_from_response(incoming) assert info.dns_pointer() in incoming.answers assert info2.dns_pointer() in incoming.answers send_mock.reset_mock() @@ -1437,7 +1437,7 @@ async def test_response_aggregation_timings(run_isolated): assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - zc.handle_response(incoming) + zc.record_manager.async_updates_from_response(incoming) assert info3.dns_pointer() in incoming.answers send_mock.reset_mock() @@ -1499,7 +1499,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - zc.handle_response(incoming) + zc.record_manager.async_updates_from_response(incoming) assert info2.dns_pointer() in incoming.answers send_mock.reset_mock() @@ -1509,7 +1509,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - zc.handle_response(incoming) + zc.record_manager.async_updates_from_response(incoming) assert info2.dns_pointer() in incoming.answers send_mock.reset_mock() @@ -1532,7 +1532,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - zc.handle_response(incoming) + zc.record_manager.async_updates_from_response(incoming) assert info2.dns_pointer() in incoming.answers From a7feade6baf5e9c9baffeba66d023fd156be86fe Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 03:31:28 +0000 Subject: [PATCH 0926/1433] 0.91.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f2aa001..63153755e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.91.0 (2023-09-02) + +### Feature + +* Reduce overhead to process incoming updates by avoiding the handle_response shim ([#1247](https://github.com/python-zeroconf/python-zeroconf/issues/1247)) ([`5e31f0a`](https://github.com/python-zeroconf/python-zeroconf/commit/5e31f0afe4c341fbdbbbe50348a829ea553cbda0)) + ## v0.90.0 (2023-09-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 880467861..bb5cf8c5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.90.0" +version = "0.91.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 077799817..c41bd89c0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.90.0' +__version__ = '0.91.0' __license__ = 'LGPL' From 4e40fae20bf50b4608e28fad4a360c4ed48ac86b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 23:49:40 -0500 Subject: [PATCH 0927/1433] fix: remove useless calls in ServiceInfo (#1248) --- src/zeroconf/_services/info.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 19e4ce29e..a308fddb5 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -427,7 +427,10 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo return False record_key = record.key - if record_key == self.server_key and type(record) is DNSAddress: + record_type = type(record) + if record_key == self.server_key and record_type is DNSAddress: + if TYPE_CHECKING: + assert isinstance(record, DNSAddress) try: ip_addr = _cached_ip_addresses(record.address) except ValueError as ex: @@ -435,9 +438,6 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo return False if type(ip_addr) is IPv4Address: - if self._ipv4_addresses: - self._set_ipv4_addresses_from_cache(zc, now) - ipv4_addresses = self._ipv4_addresses if ip_addr not in ipv4_addresses: ipv4_addresses.insert(0, ip_addr) @@ -448,9 +448,6 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo return False - if not self._ipv6_addresses: - self._set_ipv6_addresses_from_cache(zc, now) - ipv6_addresses = self._ipv6_addresses if ip_addr not in self._ipv6_addresses: ipv6_addresses.insert(0, ip_addr) @@ -464,13 +461,18 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo if record_key != self.key: return False - if record.type == _TYPE_TXT and type(record) is DNSText: + if record_type is DNSText: + if TYPE_CHECKING: + assert isinstance(record, DNSText) self._set_text(record.text) return True - if record.type == _TYPE_SRV and type(record) is DNSService: + if record_type is DNSService: + if TYPE_CHECKING: + assert isinstance(record, DNSService) old_server_key = self.server_key - self.name = record.name + self._name = record.name + self.key = record.key self.server = record.server self.server_key = record.server_key self.port = record.port @@ -577,7 +579,11 @@ def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int) -> """Get the addresses from the cache.""" if self.server_key is None: return [] - return cast("List[DNSAddress]", zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) + if TYPE_CHECKING: + records = cast("List[DNSAddress]", zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) + else: + records = zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN) + return records def set_server_if_missing(self) -> None: """Set the server if it is missing. From af192d38ea1035e53bc2154aeed493e96a8d08a2 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 04:57:54 +0000 Subject: [PATCH 0928/1433] 0.91.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63153755e..64ceb710b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.91.1 (2023-09-02) + +### Fix + +* Remove useless calls in ServiceInfo ([#1248](https://github.com/python-zeroconf/python-zeroconf/issues/1248)) ([`4e40fae`](https://github.com/python-zeroconf/python-zeroconf/commit/4e40fae20bf50b4608e28fad4a360c4ed48ac86b)) + ## v0.91.0 (2023-09-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index bb5cf8c5d..0589dc9db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.91.0" +version = "0.91.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c41bd89c0..bd6583558 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.91.0' +__version__ = '0.91.1' __license__ = 'LGPL' From 0890f628dbbd577fb77d3e6f2e267052b2b2b515 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 15:27:14 -0500 Subject: [PATCH 0929/1433] feat: cache construction of records used to answer queries from the service registry (#1243) --- src/zeroconf/_core.py | 16 ++--- src/zeroconf/_handlers/query_handler.py | 33 +++++----- src/zeroconf/_services/info.py | 81 ++++++++++++++++++------- tests/services/test_browser.py | 10 +++ tests/test_handlers.py | 1 + 5 files changed, 95 insertions(+), 46 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index aebcee344..0264f72ab 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -400,7 +400,7 @@ def generate_service_query(self, info: ServiceInfo) -> DNSOutgoing: # pylint: d # # _CLASS_UNIQUE is the "QU" bit out.add_question(DNSQuestion(info.type, _TYPE_PTR, _CLASS_IN | _CLASS_UNIQUE)) - out.add_authorative_answer(info.dns_pointer(created=current_time_millis())) + out.add_authorative_answer(info.dns_pointer()) return out def _add_broadcast_answer( # pylint: disable=no-self-use @@ -411,14 +411,14 @@ def _add_broadcast_answer( # pylint: disable=no-self-use broadcast_addresses: bool = True, ) -> None: """Add answers to broadcast a service.""" - now = current_time_millis() - other_ttl = info.other_ttl if override_ttl is None else override_ttl - host_ttl = info.host_ttl if override_ttl is None else override_ttl - out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl, created=now), 0) - out.add_answer_at_time(info.dns_service(override_ttl=host_ttl, created=now), 0) - out.add_answer_at_time(info.dns_text(override_ttl=other_ttl, created=now), 0) + current_time_millis() + other_ttl = None if override_ttl is None else override_ttl + host_ttl = None if override_ttl is None else override_ttl + out.add_answer_at_time(info.dns_pointer(override_ttl=other_ttl), 0) + out.add_answer_at_time(info.dns_service(override_ttl=host_ttl), 0) + out.add_answer_at_time(info.dns_text(override_ttl=other_ttl), 0) if broadcast_addresses: - for record in info.get_address_and_nsec_records(override_ttl=host_ttl, created=now): + for record in info.get_address_and_nsec_records(override_ttl=host_ttl): out.add_answer_at_time(record, 0) def unregister_service(self, info: ServiceInfo) -> None: diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index e4b635081..cbb18eeee 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -163,7 +163,7 @@ def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: self.question_history = question_history def _add_service_type_enumeration_query_answers( - self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float + self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet ) -> None: """Provide an answer to a service type enumeration query. @@ -171,32 +171,31 @@ def _add_service_type_enumeration_query_answers( """ for stype in self.registry.async_get_types(): dns_pointer = DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, now + _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, 0.0 ) if not known_answers.suppresses(dns_pointer): answer_set[dns_pointer] = set() def _add_pointer_answers( - self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float + self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet ) -> None: """Answer PTR/ANY question.""" for service in self.registry.async_get_infos_type(lower_name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. - dns_pointer = service.dns_pointer(created=now) + dns_pointer = service.dns_pointer() if known_answers.suppresses(dns_pointer): continue answer_set[dns_pointer] = { - service.dns_service(created=now), - service.dns_text(created=now), - } | service.get_address_and_nsec_records(created=now) + service.dns_service(), + service.dns_text(), + } | service.get_address_and_nsec_records() def _add_address_answers( self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, - now: float, type_: int, ) -> None: """Answer A/AAAA/ANY question.""" @@ -204,7 +203,7 @@ def _add_address_answers( answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() seen_types: Set[int] = set() - for dns_address in service.dns_addresses(created=now): + for dns_address in service.dns_addresses(): seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) @@ -214,12 +213,12 @@ def _add_address_answers( if answers: if missing_types: assert service.server is not None, "Service server must be set for NSEC record." - additionals.add(service.dns_nsec(list(missing_types), created=now)) + additionals.add(service.dns_nsec(list(missing_types))) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: assert service.server is not None, "Service server must be set for NSEC record." - answer_set[service.dns_nsec(list(missing_types), created=now)] = set() + answer_set[service.dns_nsec(list(missing_types))] = set() def _answer_question( self, @@ -231,16 +230,16 @@ def _answer_question( question_lower_name = question.name.lower() if question.type == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: - self._add_service_type_enumeration_query_answers(answer_set, known_answers, now) + self._add_service_type_enumeration_query_answers(answer_set, known_answers) return answer_set type_ = question.type if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question_lower_name, answer_set, known_answers, now) + self._add_pointer_answers(question_lower_name, answer_set, known_answers) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question_lower_name, answer_set, known_answers, now, type_) + self._add_address_answers(question_lower_name, answer_set, known_answers, type_) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): service = self.registry.async_get_info_name(question_lower_name) @@ -248,11 +247,11 @@ def _answer_question( if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. - dns_service = service.dns_service(created=now) + dns_service = service.dns_service() if not known_answers.suppresses(dns_service): - answer_set[dns_service] = service.get_address_and_nsec_records(created=now) + answer_set[dns_service] = service.get_address_and_nsec_records() if type_ in (_TYPE_TXT, _TYPE_ANY): - dns_text = service.dns_text(created=now) + dns_text = service.dns_text() if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index a308fddb5..7ca8d29bb 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -133,6 +133,11 @@ class ServiceInfo(RecordUpdateListener): "other_ttl", "interface_index", "_new_records_futures", + "_dns_pointer_cache", + "_dns_service_cache", + "_dns_text_cache", + "_dns_address_cache", + "_get_address_and_nsec_records_cache", ) def __init__( @@ -180,6 +185,11 @@ def __init__( self.other_ttl = other_ttl self.interface_index = interface_index self._new_records_futures: Set[asyncio.Future] = set() + self._dns_address_cache: Optional[List[DNSAddress]] = None + self._dns_pointer_cache: Optional[DNSPointer] = None + self._dns_service_cache: Optional[DNSService] = None + self._dns_text_cache: Optional[DNSText] = None + self._get_address_and_nsec_records_cache: Optional[Set[DNSRecord]] = None @property def name(self) -> str: @@ -191,6 +201,9 @@ def name(self, name: str) -> None: """Replace the the name and reset the key.""" self._name = name self.key = name.lower() + self._dns_service_cache = None + self._dns_pointer_cache = None + self._dns_text_cache = None @property def addresses(self) -> List[bytes]: @@ -210,6 +223,8 @@ def addresses(self, value: List[bytes]) -> None: """ self._ipv4_addresses.clear() self._ipv6_addresses.clear() + self._dns_address_cache = None + self._get_address_and_nsec_records_cache = None for address in value: try: @@ -489,42 +504,56 @@ def dns_addresses( self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All, - created: Optional[float] = None, ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" + cacheable = version is IPVersion.All and override_ttl is None + if self._dns_address_cache is not None and cacheable: + return self._dns_address_cache name = self.server or self._name ttl = override_ttl if override_ttl is not None else self.host_ttl class_ = _CLASS_IN_UNIQUE version_value = version.value - return [ + records = [ DNSAddress( name, _TYPE_AAAA if type(ip_addr) is IPv6Address else _TYPE_A, class_, ttl, ip_addr.packed, - created=created, + created=0.0, ) for ip_addr in self._ip_addresses_by_version_value(version_value) ] + if cacheable: + self._dns_address_cache = records + return records - def dns_pointer(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSPointer: + def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" - return DNSPointer( + cacheable = override_ttl is None + if self._dns_pointer_cache is not None and cacheable: + return self._dns_pointer_cache + record = DNSPointer( self.type, _TYPE_PTR, _CLASS_IN, override_ttl if override_ttl is not None else self.other_ttl, self._name, - created, + 0.0, ) + if cacheable: + self._dns_pointer_cache = record + return record - def dns_service(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSService: + def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: """Return DNSService from ServiceInfo.""" + cacheable = override_ttl is None + if self._dns_service_cache is not None and cacheable: + return self._dns_service_cache port = self.port if TYPE_CHECKING: assert isinstance(port, int) - return DNSService( + record = DNSService( self._name, _TYPE_SRV, _CLASS_IN_UNIQUE, @@ -533,23 +562,30 @@ def dns_service(self, override_ttl: Optional[int] = None, created: Optional[floa self.weight, port, self.server or self._name, - created, + 0.0, ) + if cacheable: + self._dns_service_cache = record + return record - def dns_text(self, override_ttl: Optional[int] = None, created: Optional[float] = None) -> DNSText: + def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: """Return DNSText from ServiceInfo.""" - return DNSText( + cacheable = override_ttl is None + if self._dns_text_cache is not None and cacheable: + return self._dns_text_cache + record = DNSText( self._name, _TYPE_TXT, _CLASS_IN_UNIQUE, override_ttl if override_ttl is not None else self.other_ttl, self.text, - created, + 0.0, ) + if cacheable: + self._dns_text_cache = record + return record - def dns_nsec( - self, missing_types: List[int], override_ttl: Optional[int] = None, created: Optional[float] = None - ) -> DNSNsec: + def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -558,21 +594,24 @@ def dns_nsec( override_ttl if override_ttl is not None else self.host_ttl, self._name, missing_types, - created, + 0.0, ) - def get_address_and_nsec_records( - self, override_ttl: Optional[int] = None, created: Optional[float] = None - ) -> Set[DNSRecord]: + def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" + cacheable = override_ttl is None + if self._get_address_and_nsec_records_cache is not None and cacheable: + return self._get_address_and_nsec_records_cache missing_types: Set[int] = _ADDRESS_RECORD_TYPES.copy() records: Set[DNSRecord] = set() - for dns_address in self.dns_addresses(override_ttl, IPVersion.All, created): + for dns_address in self.dns_addresses(override_ttl, IPVersion.All): missing_types.discard(dns_address.type) records.add(dns_address) if missing_types: assert self.server is not None, "Service server must be set for NSEC record." - records.add(self.dns_nsec(list(missing_types), override_ttl, created)) + records.add(self.dns_nsec(list(missing_types), override_ttl)) + if cacheable: + self._get_address_and_nsec_records_cache = records return records def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int) -> List[DNSAddress]: diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index d269f85cf..aa13761db 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -792,6 +792,8 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: assert service_info.port == 80 info.port = 400 + info._dns_service_cache = None # we are mutating the record so clear the cache + _inject_response( zc, mock_incoming_msg([info.dns_service()]), @@ -856,6 +858,8 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), ) time.sleep(0.2) + info._dns_service_cache = None # we are mutating the record so clear the cache + info.port = 400 _inject_response( zc, @@ -914,6 +918,8 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: ) time.sleep(0.2) info.port = 400 + info._dns_service_cache = None # we are mutating the record so clear the cache + _inject_response( zc, mock_incoming_msg([info.dns_service()]), @@ -1131,6 +1137,8 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: ) time.sleep(0.2) info.port = 400 + info._dns_service_cache = None # we are mutating the record so clear the cache + _inject_response( zc, mock_incoming_msg([info.dns_service()]), @@ -1210,6 +1218,8 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: ) time.sleep(0.3) info.port = 400 + info._dns_service_cache = None # we are mutating the record so clear the cache + _inject_response( zc, mock_incoming_msg([info.dns_service()]), diff --git a/tests/test_handlers.py b/tests/test_handlers.py index bdd16c3c4..6266ad91c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -986,6 +986,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): a_record = info.dns_addresses()[0] a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) assert not a_record.is_recent(current_time_millis()) + info._dns_address_cache = None # we are mutating the record so clear the cache zc.cache.async_add_records([a_record]) # With QU should respond to only unicast when the answer has been recently multicast From 318094c0d6f8333fd5ed8bf6bfe8ecef606ef8c6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 20:35:18 +0000 Subject: [PATCH 0930/1433] 0.92.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ceb710b..847e6acfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.92.0 (2023-09-02) + +### Feature + +* Cache construction of records used to answer queries from the service registry ([#1243](https://github.com/python-zeroconf/python-zeroconf/issues/1243)) ([`0890f62`](https://github.com/python-zeroconf/python-zeroconf/commit/0890f628dbbd577fb77d3e6f2e267052b2b2b515)) + ## v0.91.1 (2023-09-02) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0589dc9db..5677aab60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.91.1" +version = "0.92.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index bd6583558..56fd4bed4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.91.1' +__version__ = '0.92.0' __license__ = 'LGPL' From 7cb8da0c6c5c944588009fe36012c1197c422668 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 17:19:26 -0500 Subject: [PATCH 0931/1433] feat: reduce overhead to answer questions (#1250) --- src/zeroconf/_handlers/query_handler.py | 33 ++++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index cbb18eeee..b232ea491 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -21,7 +21,7 @@ """ -from typing import TYPE_CHECKING, List, Set, cast +from typing import TYPE_CHECKING, List, Optional, Set, cast from .._cache import DNSCache, _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet @@ -109,19 +109,20 @@ def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> No else: self._mcast_aggregate.add(answer) - def _generate_answers_with_additionals(self, rrset: Set[DNSRecord]) -> _AnswerWithAdditionalsType: - """Create answers with additionals from an rrset.""" - return {record: self._additionals[record] for record in rrset} - def answers( self, ) -> QuestionAnswers: """Return answer sets that will be queued.""" return QuestionAnswers( - self._generate_answers_with_additionals(self._ucast), - self._generate_answers_with_additionals(self._mcast_now), - self._generate_answers_with_additionals(self._mcast_aggregate), - self._generate_answers_with_additionals(self._mcast_aggregate_last_second), + *( + {record: self._additionals[record] for record in rrset} + for rrset in ( + self._ucast, + self._mcast_now, + self._mcast_aggregate, + self._mcast_aggregate_last_second, + ) + ) ) def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: @@ -224,17 +225,16 @@ def _answer_question( self, question: DNSQuestion, known_answers: DNSRRSet, - now: float, ) -> _AnswerWithAdditionalsType: + """Answer a question.""" answer_set: _AnswerWithAdditionalsType = {} question_lower_name = question.name.lower() + type_ = question.type - if question.type == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: + if type_ == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: self._add_service_type_enumeration_query_answers(answer_set, known_answers) return answer_set - type_ = question.type - if type_ in (_TYPE_PTR, _TYPE_ANY): self._add_pointer_answers(question_lower_name, answer_set, known_answers) @@ -267,12 +267,15 @@ def async_response( # pylint: disable=unused-argument """ known_answers = DNSRRSet([msg.answers for msg in msgs if not msg.is_probe]) query_res = _QueryResponse(self.cache, msgs) + known_answers_set: Optional[Set[DNSRecord]] = None for msg in msgs: for question in msg.questions: if not question.unicast: - self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) - answer_set = self._answer_question(question, known_answers, msg.now) + if not known_answers_set: # pragma: no branch + known_answers_set = set(known_answers.lookup) + self.question_history.add_question_at_time(question, msg.now, known_answers_set) + answer_set = self._answer_question(question, known_answers) if not ucast_source and question.unicast: query_res.add_qu_question_response(answer_set) continue From 2b6056f199042259fe0e01e038cdd93689dcbc13 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Sep 2023 22:50:28 +0000 Subject: [PATCH 0932/1433] 0.93.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 847e6acfb..0ab0348e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.93.0 (2023-09-02) + +### Feature + +* Reduce overhead to answer questions ([#1250](https://github.com/python-zeroconf/python-zeroconf/issues/1250)) ([`7cb8da0`](https://github.com/python-zeroconf/python-zeroconf/commit/7cb8da0c6c5c944588009fe36012c1197c422668)) + ## v0.92.0 (2023-09-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 5677aab60..edb501265 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.92.0" +version = "0.93.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 56fd4bed4..a4b6db209 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.92.0' +__version__ = '0.93.0' __license__ = 'LGPL' From 730921b155dfb9c62251c8c643b1302e807aff3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 19:33:10 -0500 Subject: [PATCH 0933/1433] fix: no change re-release due to unrecoverable failed CI run (#1251) From 235d52877b8d959efb653e46daff684c53fa4e4d Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 3 Sep 2023 00:42:04 +0000 Subject: [PATCH 0934/1433] 0.93.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab0348e7..5593d1e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.93.1 (2023-09-03) + +### Fix + +* No change re-release due to unrecoverable failed CI run ([#1251](https://github.com/python-zeroconf/python-zeroconf/issues/1251)) ([`730921b`](https://github.com/python-zeroconf/python-zeroconf/commit/730921b155dfb9c62251c8c643b1302e807aff3b)) + ## v0.93.0 (2023-09-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index edb501265..0dd868827 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.93.0" +version = "0.93.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a4b6db209..ce4f78201 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.93.0' +__version__ = '0.93.1' __license__ = 'LGPL' From 8d3ec792277aaf7ef790318b5b35ab00839ca3b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 11:35:39 -0500 Subject: [PATCH 0935/1433] feat: optimize cache implementation (#1252) --- src/zeroconf/_cache.pxd | 25 +++++++++++++++++++----- src/zeroconf/_cache.py | 25 ++++++------------------ src/zeroconf/_handlers/record_manager.py | 5 +++-- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 6bc9ea5de..3ffe08002 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -4,6 +4,7 @@ from ._dns cimport ( DNSAddress, DNSEntry, DNSHinfo, + DNSNsec, DNSPointer, DNSRecord, DNSService, @@ -13,7 +14,7 @@ from ._dns cimport ( cdef object _UNIQUE_RECORD_TYPES cdef object _TYPE_PTR -cdef object _ONE_SECOND +cdef cython.uint _ONE_SECOND cdef _remove_key(cython.dict cache, object key, DNSRecord record) @@ -27,23 +28,37 @@ cdef class DNSCache: cpdef async_remove_records(self, object entries) + @cython.locals( + store=cython.dict, + ) cpdef async_get_unique(self, DNSRecord entry) + @cython.locals( + record=DNSRecord, + ) + cpdef async_expire(self, float now) + @cython.locals( records=cython.dict, record=DNSRecord, ) - cdef _async_all_by_details(self, object name, object type_, object class_) + cpdef async_all_by_details(self, str name, object type_, object class_) + cpdef async_entries_with_name(self, str name) + + cpdef async_entries_with_server(self, str name) + + @cython.locals( + store=cython.dict, + ) cdef _async_add(self, DNSRecord record) cdef _async_remove(self, DNSRecord record) - cpdef async_mark_unique_records_older_than_1s_to_expire(self, object unique_types, object answers, object now) - @cython.locals( record=DNSRecord, + created_float=cython.float, ) - cdef _async_mark_unique_records_older_than_1s_to_expire(self, object unique_types, object answers, object now) + cpdef async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, float now) cdef _dns_record_matches(DNSRecord record, object key, object type_, object class_) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index ad339cd50..b1e6df38b 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -20,7 +20,6 @@ USA """ -import itertools from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast from ._dns import ( @@ -115,12 +114,12 @@ def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: for entry in entries: self._async_remove(entry) - def async_expire(self, now: float) -> List[DNSRecord]: + def async_expire(self, now: _float) -> List[DNSRecord]: """Purge expired entries from the cache. This function must be run in from event loop. """ - expired = [record for record in itertools.chain(*self.cache.values()) if record.is_expired(now)] + expired = [record for records in self.cache.values() for record in records if record.is_expired(now)] self.async_remove_records(expired) return expired @@ -136,15 +135,7 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details(self, name: _str, type_: int, class_: int) -> Iterable[DNSRecord]: - """Gets all matching entries by details. - - This function is not thread-safe and must be called from - the event loop. - """ - return self._async_all_by_details(name, type_, class_) - - def _async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: + def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: """Gets all matching entries by details. This function is not thread-safe and must be called from @@ -240,11 +231,6 @@ def names(self) -> List[str]: def async_mark_unique_records_older_than_1s_to_expire( self, unique_types: Set[Tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float - ) -> None: - self._async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) - - def _async_mark_unique_records_older_than_1s_to_expire( - self, unique_types: Set[Tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float ) -> None: # rfc6762#section-10.2 para 2 # Since unique is set, all old records with that name, rrtype, @@ -252,8 +238,9 @@ def _async_mark_unique_records_older_than_1s_to_expire( # invalid, and marked to expire from the cache in one second. answers_rrset = set(answers) for name, type_, class_ in unique_types: - for record in self._async_all_by_details(name, type_, class_): - if (now - record.created > _ONE_SECOND) and record not in answers_rrset: + for record in self.async_all_by_details(name, type_, class_): + created_float = record.created + if (now - created_float > _ONE_SECOND) and record not in answers_rrset: # Expire in 1s record.set_created_ttl(now, 1) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 5e4f7c9b3..dcbe5e916 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -86,8 +86,9 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: now_float = now unique_types: Set[Tuple[str, int, int]] = set() cache = self.cache + answers = msg.answers - for record in msg.answers: + for record in answers: # Protect zeroconf from records that can cause denial of service. # # We enforce a minimum TTL for PTR records to avoid @@ -127,7 +128,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, msg.answers, now) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) if updates: self.async_updates(now, updates) From 72d6886642820e1976aba93c57431e1b7b8789bf Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 3 Sep 2023 16:44:32 +0000 Subject: [PATCH 0936/1433] 0.94.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5593d1e8e..d4232448a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.94.0 (2023-09-03) + +### Feature + +* Optimize cache implementation ([#1252](https://github.com/python-zeroconf/python-zeroconf/issues/1252)) ([`8d3ec79`](https://github.com/python-zeroconf/python-zeroconf/commit/8d3ec792277aaf7ef790318b5b35ab00839ca3b3)) + ## v0.93.1 (2023-09-03) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0dd868827..11ef8c86d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.93.1" +version = "0.94.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ce4f78201..9eee273c3 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.93.1' +__version__ = '0.94.0' __license__ = 'LGPL' From 22e4a296d440b3038c0ff5ed6fc8878304ec4937 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 12:26:34 -0500 Subject: [PATCH 0937/1433] feat: speed up adding and removing RecordUpdateListeners (#1253) --- src/zeroconf/_core.py | 2 +- src/zeroconf/_handlers/record_manager.pxd | 7 ++++++- src/zeroconf/_handlers/record_manager.py | 4 ++-- src/zeroconf/_services/info.py | 4 ---- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 0264f72ab..403754846 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -240,7 +240,7 @@ async def async_wait_for_start(self) -> None: raise NotRunningException @property - def listeners(self) -> List[RecordUpdateListener]: + def listeners(self) -> Set[RecordUpdateListener]: return self.record_manager.listeners async def async_wait(self, timeout: float) -> None: diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 7a55e64fa..e0792d724 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -12,11 +12,12 @@ cdef object RecordUpdate cdef object TYPE_CHECKING cdef object _TYPE_PTR + cdef class RecordManager: cdef public object zc cdef public DNSCache cache - cdef public cython.list listeners + cdef public cython.set listeners cpdef async_updates(self, object now, object records) @@ -29,3 +30,7 @@ cdef class RecordManager: now_float=cython.float ) cpdef async_updates_from_response(self, DNSIncoming msg) + + cpdef async_add_listener(self, object listener, object question) + + cpdef async_remove_listener(self, object listener) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index dcbe5e916..586fba0b1 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -45,7 +45,7 @@ def __init__(self, zeroconf: 'Zeroconf') -> None: """Init the record manager.""" self.zc = zeroconf self.cache = zeroconf.cache - self.listeners: List[RecordUpdateListener] = [] + self.listeners: Set[RecordUpdateListener] = set() def async_updates(self, now: _float, records: List[RecordUpdate]) -> None: """Used to notify listeners of new information that has updated @@ -175,7 +175,7 @@ def async_add_listener( " In the future this will fail" ) - self.listeners.append(listener) + self.listeners.add(listener) if question is None: return diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 7ca8d29bb..1ffd95700 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -758,10 +758,6 @@ def generate_request_query( question.unicast = True return out - def __eq__(self, other: object) -> bool: - """Tests equality of service name""" - return isinstance(other, ServiceInfo) and other._name == self._name - def __repr__(self) -> str: """String representation""" return '{}({})'.format( From 46adba9f432edce96e2850840d9db4ca0dbd0510 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 3 Sep 2023 17:46:38 +0000 Subject: [PATCH 0938/1433] 0.95.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4232448a..13c6640d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.95.0 (2023-09-03) + +### Feature + +* Speed up adding and removing RecordUpdateListeners ([#1253](https://github.com/python-zeroconf/python-zeroconf/issues/1253)) ([`22e4a29`](https://github.com/python-zeroconf/python-zeroconf/commit/22e4a296d440b3038c0ff5ed6fc8878304ec4937)) + ## v0.94.0 (2023-09-03) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 11ef8c86d..3922c9c89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.94.0" +version = "0.95.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 9eee273c3..aa5dae764 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.94.0' +__version__ = '0.95.0' __license__ = 'LGPL' From ce59787a170781ffdaa22425018d288b395ac081 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 13:36:04 -0500 Subject: [PATCH 0939/1433] feat: optimize DNSCache.get_by_details (#1254) * feat: optimize DNSCache.get_by_details This is one of the most called functions since ServiceInfo.load_from_cache calls it * fix: make get_all_by_details thread-safe * fix: remove unneeded key checks --- src/zeroconf/_cache.pxd | 13 +++++++++++-- src/zeroconf/_cache.py | 24 ++++++++++++------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 3ffe08002..cdba81767 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -48,6 +48,17 @@ cdef class DNSCache: cpdef async_entries_with_server(self, str name) + @cython.locals( + cached_entry=DNSRecord, + ) + cpdef get_by_details(self, str name, object type_, object class_) + + @cython.locals( + records=cython.dict, + entry=DNSRecord, + ) + cpdef get_all_by_details(self, str name, object type_, object class_) + @cython.locals( store=cython.dict, ) @@ -60,5 +71,3 @@ cdef class DNSCache: created_float=cython.float, ) cpdef async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, float now) - -cdef _dns_record_matches(DNSRecord record, object key, object type_, object class_) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index b1e6df38b..83206e79d 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -147,7 +147,7 @@ def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DN if records is None: return matches for record in records: - if _dns_record_matches(record, key, type_, class_): + if type_ == record.type and class_ == record.class_: matches.append(record) return matches @@ -181,7 +181,7 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: return cached_entry return None - def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSRecord]: + def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRecord]: """Gets the first matching entry by details. Returns None if no entries match. Calling this function is not recommended as it will only @@ -194,17 +194,21 @@ def get_by_details(self, name: str, type_: int, class_: int) -> Optional[DNSReco Use get_all_by_details instead. """ key = name.lower() - for cached_entry in reversed(list(self.cache.get(key, []))): - if _dns_record_matches(cached_entry, key, type_, class_): + records = self.cache.get(key) + if records is None: + return None + for cached_entry in reversed(list(records)): + if type_ == cached_entry.type and class_ == cached_entry.class_: return cached_entry return None - def get_all_by_details(self, name: str, type_: int, class_: int) -> List[DNSRecord]: + def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRecord]: """Gets all matching entries by details.""" key = name.lower() - return [ - entry for entry in list(self.cache.get(key, [])) if _dns_record_matches(entry, key, type_, class_) - ] + records = self.cache.get(key) + if records is None: + return [] + return [entry for entry in list(records) if type_ == entry.type and class_ == entry.class_] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" @@ -243,7 +247,3 @@ def async_mark_unique_records_older_than_1s_to_expire( if (now - created_float > _ONE_SECOND) and record not in answers_rrset: # Expire in 1s record.set_created_ttl(now, 1) - - -def _dns_record_matches(record: _DNSRecord, key: _str, type_: _int, class_: _int) -> bool: - return key == record.key and type_ == record.type and class_ == record.class_ From 5c884b0b93be1afdc7ac363ee4df60da5111d0ff Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 3 Sep 2023 18:43:43 +0000 Subject: [PATCH 0940/1433] 0.96.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c6640d9..d4fb7fd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.96.0 (2023-09-03) + +### Feature + +* Optimize DNSCache.get_by_details ([#1254](https://github.com/python-zeroconf/python-zeroconf/issues/1254)) ([`ce59787`](https://github.com/python-zeroconf/python-zeroconf/commit/ce59787a170781ffdaa22425018d288b395ac081)) + ## v0.95.0 (2023-09-03) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 3922c9c89..f454d3fc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.95.0" +version = "0.96.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index aa5dae764..fc47b0f18 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.95.0' +__version__ = '0.96.0' __license__ = 'LGPL' From 2d3aed36e24c73013fcf4acc90803fc1737d0917 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 18:03:18 -0500 Subject: [PATCH 0941/1433] feat: speed up answering queries (#1255) --- build_ext.py | 1 + src/zeroconf/_handlers/query_handler.pxd | 64 ++++++++++++++++++++++++ src/zeroconf/_handlers/query_handler.py | 20 +++----- src/zeroconf/_history.pxd | 6 ++- src/zeroconf/_services/registry.pxd | 8 +++ 5 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 src/zeroconf/_handlers/query_handler.pxd diff --git a/build_ext.py b/build_ext.py index 8e4e7b99f..55d76d3cc 100644 --- a/build_ext.py +++ b/build_ext.py @@ -30,6 +30,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", "src/zeroconf/_handlers/record_manager.py", + "src/zeroconf/_handlers/query_handler.py", "src/zeroconf/_services/registry.py", "src/zeroconf/_utils/time.py", ], diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd new file mode 100644 index 000000000..3457128ca --- /dev/null +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -0,0 +1,64 @@ + +import cython + +from .._cache cimport DNSCache +from .._dns cimport DNSPointer, DNSQuestion, DNSRecord, DNSRRSet +from .._history cimport QuestionHistory +from .._protocol.incoming cimport DNSIncoming +from .._services.registry cimport ServiceRegistry + + +cdef object TYPE_CHECKING, QuestionAnswers +cdef cython.uint _ONE_SECOND, _TYPE_PTR, _TYPE_ANY, _TYPE_A, _TYPE_AAAA, _TYPE_SRV, _TYPE_TXT +cdef str _SERVICE_TYPE_ENUMERATION_NAME +cdef cython.set _RESPOND_IMMEDIATE_TYPES + +cdef class _QueryResponse: + + cdef object _is_probe + cdef DNSIncoming _msg + cdef float _now + cdef DNSCache _cache + cdef cython.dict _additionals + cdef cython.set _ucast + cdef cython.set _mcast_now + cdef cython.set _mcast_aggregate + cdef cython.set _mcast_aggregate_last_second + + cpdef add_qu_question_response(self, cython.dict answers) + + cpdef add_ucast_question_response(self, cython.dict answers) + + cpdef add_mcast_question_response(self, cython.dict answers) + + @cython.locals(maybe_entry=DNSRecord) + cpdef _has_mcast_within_one_quarter_ttl(self, DNSRecord record) + + @cython.locals(maybe_entry=DNSRecord) + cpdef _has_mcast_record_in_last_second(self, DNSRecord record) + + cpdef answers(self) + +cdef class QueryHandler: + + cdef ServiceRegistry registry + cdef DNSCache cache + cdef QuestionHistory question_history + + cdef _add_service_type_enumeration_query_answers(self, cython.dict answer_set, DNSRRSet known_answers) + + cdef _add_pointer_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers) + + cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) + + @cython.locals(question_lower_name=str, type_=cython.uint) + cdef _answer_question(self, DNSQuestion question, DNSRRSet known_answers) + + @cython.locals( + msg=DNSIncoming, + question=DNSQuestion, + answer_set=cython.dict, + known_answers=DNSRRSet, + known_answers_set=cython.set, + ) + cpdef async_response(self, cython.list msgs, object unicast_source) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index b232ea491..34fde5470 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -46,6 +46,8 @@ _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} +_int = int + class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" @@ -113,17 +115,11 @@ def answers( self, ) -> QuestionAnswers: """Return answer sets that will be queued.""" - return QuestionAnswers( - *( - {record: self._additionals[record] for record in rrset} - for rrset in ( - self._ucast, - self._mcast_now, - self._mcast_aggregate, - self._mcast_aggregate_last_second, - ) - ) - ) + ucast = {r: self._additionals[r] for r in self._ucast} + mcast_now = {r: self._additionals[r] for r in self._mcast_now} + mcast_aggregate = {r: self._additionals[r] for r in self._mcast_aggregate} + mcast_aggregate_last_second = {r: self._additionals[r] for r in self._mcast_aggregate_last_second} + return QuestionAnswers(ucast, mcast_now, mcast_aggregate, mcast_aggregate_last_second) def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -197,7 +193,7 @@ def _add_address_answers( lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, - type_: int, + type_: _int, ) -> None: """Answer A/AAAA/ANY question.""" for service in self.registry.async_get_infos_server(lower_name): diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index 6e4e374f7..d4e1c8332 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -1,5 +1,7 @@ import cython +from ._dns cimport DNSQuestion + cdef cython.double _DUPLICATE_QUESTION_INTERVAL @@ -7,10 +9,10 @@ cdef class QuestionHistory: cdef cython.dict _history + cpdef add_question_at_time(self, DNSQuestion question, float now, cython.set known_answers) @cython.locals(than=cython.double, previous_question=cython.tuple, previous_known_answers=cython.set) - cpdef suppresses(self, object question, cython.double now, cython.set known_answers) - + cpdef suppresses(self, DNSQuestion question, cython.double now, cython.set known_answers) @cython.locals(than=cython.double, now_known_answers=cython.tuple) cpdef async_expire(self, cython.double now) diff --git a/src/zeroconf/_services/registry.pxd b/src/zeroconf/_services/registry.pxd index 722ef0ecd..a741b93a8 100644 --- a/src/zeroconf/_services/registry.pxd +++ b/src/zeroconf/_services/registry.pxd @@ -16,3 +16,11 @@ cdef class ServiceRegistry: cdef _add(self, object info) cdef _remove(self, cython.list infos) + + cpdef async_get_info_name(self, str name) + + cpdef async_get_types(self) + + cpdef async_get_infos_type(self, str type_) + + cpdef async_get_infos_server(self, str server) From aebabd9f8486e4f7f6e79e8a5b5eed1da6c900c3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 3 Sep 2023 23:11:15 +0000 Subject: [PATCH 0942/1433] 0.97.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4fb7fd10..7f4a75317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.97.0 (2023-09-03) + +### Feature + +* Speed up answering queries ([#1255](https://github.com/python-zeroconf/python-zeroconf/issues/1255)) ([`2d3aed3`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3aed36e24c73013fcf4acc90803fc1737d0917)) + ## v0.96.0 (2023-09-03) ### Feature diff --git a/pyproject.toml b/pyproject.toml index f454d3fc2..62138aecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.96.0" +version = "0.97.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index fc47b0f18..1cc1df879 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.96.0' +__version__ = '0.97.0' __license__ = 'LGPL' From ac081cf00addde1ceea2c076f73905fdb293de3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 10:47:14 -0500 Subject: [PATCH 0943/1433] feat: speed up decoding incoming packets (#1256) --- src/zeroconf/_protocol/incoming.pxd | 15 ++++++++++----- src/zeroconf/_protocol/incoming.py | 26 +++++++++++++++----------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 604b1e30a..ebd09a0ea 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -7,8 +7,6 @@ cdef cython.uint MAX_DNS_LABELS cdef cython.uint DNS_COMPRESSION_POINTER_LEN cdef cython.uint MAX_NAME_LENGTH -cdef object current_time_millis - cdef cython.uint _TYPE_A cdef cython.uint _TYPE_CNAME cdef cython.uint _TYPE_PTR @@ -43,6 +41,7 @@ from .._dns cimport ( DNSService, DNSText, ) +from .._utils.time cimport current_time_millis cdef class DNSIncoming: @@ -62,6 +61,7 @@ cdef class DNSIncoming: cdef public cython.uint num_additionals cdef public object valid cdef public object now + cdef cython.float _now_float cdef public object scope_id cdef public object source @@ -79,7 +79,9 @@ cdef class DNSIncoming: label_idx=cython.uint, length=cython.uint, link=cython.uint, - link_data=cython.uint + link_data=cython.uint, + link_py_int=object, + linked_labels=cython.list ) cdef _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) @@ -95,9 +97,12 @@ cdef class DNSIncoming: cdef _read_questions(self) - cdef bytes _read_character_string(self) + @cython.locals( + length=cython.uint, + ) + cdef str _read_character_string(self) - cdef _read_string(self, unsigned int length) + cdef bytes _read_string(self, unsigned int length) @cython.locals( name_start=cython.uint diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 352a61410..87d25816f 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -89,6 +89,7 @@ class DNSIncoming: 'num_additionals', 'valid', 'now', + '_now_float', 'scope_id', 'source', ) @@ -116,6 +117,7 @@ def __init__( self.valid = False self._did_read_others = False self.now = now or current_time_millis() + self._now_float = self.now self.source = source self.scope_id = scope_id try: @@ -226,11 +228,13 @@ def _read_questions(self) -> None: question = DNSQuestion(name, type_, class_) self.questions.append(question) - def _read_character_string(self) -> bytes: + def _read_character_string(self) -> str: """Reads a character string from the packet""" length = self.data[self.offset] self.offset += 1 - return self._read_string(length) + info = self.data[self.offset : self.offset + length].decode('utf-8', 'replace') + self.offset += length + return info def _read_string(self, length: _int) -> bytes: """Reads a string of a given length from the packet""" @@ -273,7 +277,7 @@ def _read_record( """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(4)) - dns_address.created = self.now + dns_address.created = self._now_float return dns_address if type_ in (_TYPE_CNAME, _TYPE_PTR): return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) @@ -299,13 +303,13 @@ def _read_record( type_, class_, ttl, - self._read_character_string().decode('utf-8', 'replace'), - self._read_character_string().decode('utf-8', 'replace'), + self._read_character_string(), + self._read_character_string(), self.now, ) if type_ == _TYPE_AAAA: dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(16)) - dns_address.created = self.now + dns_address.created = self._now_float dns_address.scope_id = self.scope_id return dns_address if type_ == _TYPE_NSEC: @@ -377,7 +381,7 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: # We have a DNS compression pointer link_data = self.data[off + 1] link = (length & 0x3F) * 256 + link_data - lint_int = int(link) + link_py_int = link if link > self._data_len: raise IncomingDecodeError( f"DNS compression pointer at {off} points to {link} beyond packet from {self.source}" @@ -386,16 +390,16 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: raise IncomingDecodeError( f"DNS compression pointer at {off} points to itself from {self.source}" ) - if lint_int in seen_pointers: + if link_py_int in seen_pointers: raise IncomingDecodeError( f"DNS compression pointer at {off} was seen again from {self.source}" ) - linked_labels = self.name_cache.get(lint_int) + linked_labels = self.name_cache.get(link_py_int) if not linked_labels: linked_labels = [] - seen_pointers.add(lint_int) + seen_pointers.add(link_py_int) self._decode_labels_at_offset(link, linked_labels, seen_pointers) - self.name_cache[lint_int] = linked_labels + self.name_cache[link_py_int] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: raise IncomingDecodeError( From 3a53e79fda52e124bb96f1df4ddbe85ebe429311 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 6 Sep 2023 15:55:54 +0000 Subject: [PATCH 0944/1433] 0.98.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4a75317..8295fd660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.98.0 (2023-09-06) + +### Feature + +* Speed up decoding incoming packets ([#1256](https://github.com/python-zeroconf/python-zeroconf/issues/1256)) ([`ac081cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac081cf00addde1ceea2c076f73905fdb293de3a)) + ## v0.97.0 (2023-09-03) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 62138aecf..79cf3cfab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.97.0" +version = "0.98.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1cc1df879..9f49ac794 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.97.0' +__version__ = '0.98.0' __license__ = 'LGPL' From 83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 14:54:54 -0500 Subject: [PATCH 0945/1433] feat: reduce IP Address parsing overhead in ServiceInfo (#1257) --- src/zeroconf/_services/info.py | 39 +++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 1ffd95700..517b41bee 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -91,7 +91,13 @@ def instance_name_from_service_info(info: "ServiceInfo", strict: bool = True) -> return info.name[: -len(service_name) - 1] -_cached_ip_addresses = lru_cache(maxsize=256)(ip_address) +@lru_cache(maxsize=512) +def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4Address, IPv6Address]]: + """Cache IP addresses.""" + try: + return ip_address(address) + except ValueError: + return None class ServiceInfo(RecordUpdateListener): @@ -227,16 +233,19 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - try: - addr = _cached_ip_addresses(address) - except ValueError: + addr = _cached_ip_addresses(address) + if addr is None: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" f" got {address!r}. Hint: convert string addresses with socket.inet_pton" ) if addr.version == 4: + if TYPE_CHECKING: + assert isinstance(addr, IPv4Address) self._ipv4_addresses.append(addr) else: + if TYPE_CHECKING: + assert isinstance(addr, IPv6Address) self._ipv6_addresses.append(addr) @property @@ -394,11 +403,8 @@ def _get_ip_addresses_from_cache_lifo( for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue - try: - ip_addr = _cached_ip_addresses(record.address) - except ValueError: - continue - else: + ip_addr = _cached_ip_addresses(record.address) + if ip_addr is not None: address_list.append(ip_addr) address_list.reverse() # Reverse to get LIFO order return address_list @@ -446,13 +452,14 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo if record_key == self.server_key and record_type is DNSAddress: if TYPE_CHECKING: assert isinstance(record, DNSAddress) - try: - ip_addr = _cached_ip_addresses(record.address) - except ValueError as ex: - log.warning("Encountered invalid address while processing %s: %s", record, ex) + ip_addr = _cached_ip_addresses(record.address) + if ip_addr is None: + log.warning("Encountered invalid address while processing %s: %s", record, record.address) return False - if type(ip_addr) is IPv4Address: + if ip_addr.version == 4: + if TYPE_CHECKING: + assert isinstance(ip_addr, IPv4Address) ipv4_addresses = self._ipv4_addresses if ip_addr not in ipv4_addresses: ipv4_addresses.insert(0, ip_addr) @@ -463,6 +470,8 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo return False + if TYPE_CHECKING: + assert isinstance(ip_addr, IPv6Address) ipv6_addresses = self._ipv6_addresses if ip_addr not in self._ipv6_addresses: ipv6_addresses.insert(0, ip_addr) @@ -516,7 +525,7 @@ def dns_addresses( records = [ DNSAddress( name, - _TYPE_AAAA if type(ip_addr) is IPv6Address else _TYPE_A, + _TYPE_AAAA if ip_addr.version == 6 else _TYPE_A, class_, ttl, ip_addr.packed, From bb743094e469452c39972bd518985475454b7c43 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 6 Sep 2023 20:03:59 +0000 Subject: [PATCH 0946/1433] 0.99.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8295fd660..568a209f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.99.0 (2023-09-06) + +### Feature + +* Reduce IP Address parsing overhead in ServiceInfo ([#1257](https://github.com/python-zeroconf/python-zeroconf/issues/1257)) ([`83d0b7f`](https://github.com/python-zeroconf/python-zeroconf/commit/83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb)) + ## v0.98.0 (2023-09-06) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 79cf3cfab..73120ef03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.98.0" +version = "0.99.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 9f49ac794..d69e5c9e4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.98.0' +__version__ = '0.99.0' __license__ = 'LGPL' From 1ed6bd2ec4db0612b71384f923ffff1efd3ce878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 13:05:58 -0500 Subject: [PATCH 0947/1433] feat: small speed up to writing outgoing dns records (#1258) --- src/zeroconf/_protocol/outgoing.pxd | 38 ++++++++++++++++++++++------- src/zeroconf/_protocol/outgoing.py | 12 +++++---- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index e7da04b3b..0338cfd88 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -1,7 +1,8 @@ import cython -from .._dns cimport DNSEntry, DNSQuestion, DNSRecord +from .._cache cimport DNSCache +from .._dns cimport DNSEntry, DNSPointer, DNSQuestion, DNSRecord from .incoming cimport DNSIncoming @@ -14,6 +15,7 @@ cdef cython.uint _FLAGS_TC cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _MAX_MSG_TYPICAL +cdef object TYPE_CHECKING cdef class DNSOutgoing: @@ -70,13 +72,31 @@ cdef class DNSOutgoing: cpdef write_short(self, object value) @cython.locals( - questions_offset=cython.uint, - answer_offset=cython.uint, - authority_offset=cython.uint, - additional_offset=cython.uint, - questions_written=cython.uint, - answers_written=cython.uint, - authorities_written=cython.uint, - additionals_written=cython.uint, + questions_offset=object, + answer_offset=object, + authority_offset=object, + additional_offset=object, + questions_written=object, + answers_written=object, + authorities_written=object, + additionals_written=object, ) cdef _packets(self) + + cpdef add_question_or_all_cache(self, DNSCache cache, object now, str name, object type_, object class_) + + cpdef add_question_or_one_cache(self, DNSCache cache, object now, str name, object type_, object class_) + + cpdef add_question(self, DNSQuestion question) + + cpdef add_answer(self, DNSIncoming inp, DNSRecord record) + + cpdef add_answer_at_time(self, DNSRecord record, object now) + + cpdef add_authorative_answer(self, DNSPointer record) + + cpdef add_additional_answer(self, DNSRecord record) + + cpdef is_query(self) + + cpdef is_response(self) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index e13750f6f..069b2936b 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -22,7 +22,7 @@ import enum import logging -from typing import Dict, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union from .._cache import DNSCache from .._dns import DNSPointer, DNSQuestion, DNSRecord @@ -176,7 +176,7 @@ def add_additional_answer(self, record: DNSRecord) -> None: self.additionals.append(record) def add_question_or_one_cache( - self, cache: DNSCache, now: float, name: str, type_: int, class_: int + self, cache: DNSCache, now: float_, name: str_, type_: int_, class_: int_ ) -> None: """Add a question if it is not already cached.""" cached_entry = cache.get_by_details(name, type_, class_) @@ -186,7 +186,7 @@ def add_question_or_one_cache( self.add_answer_at_time(cached_entry, now) def add_question_or_all_cache( - self, cache: DNSCache, now: float, name: str, type_: int, class_: int + self, cache: DNSCache, now: float_, name: str_, type_: int_, class_: int_ ) -> None: """Add a question if it is not already cached. This is currently only used for IPv6 addresses. @@ -223,7 +223,8 @@ def _write_int(self, value: Union[float, int]) -> None: def write_string(self, value: bytes) -> None: """Writes a string to the packet""" - assert isinstance(value, bytes) + if TYPE_CHECKING: + assert isinstance(value, bytes) self.data.append(value) self.size += len(value) @@ -237,7 +238,8 @@ def _write_utf(self, s: str) -> None: self.write_string(utfstr) def write_character_string(self, value: bytes) -> None: - assert isinstance(value, bytes) + if TYPE_CHECKING: + assert isinstance(value, bytes) length = len(value) if length > 256: raise NamePartTooLongException From cd41743f7c36903cdfb772137889f6f919d8bb6b Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 7 Sep 2023 18:15:09 +0000 Subject: [PATCH 0948/1433] 0.100.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568a209f5..8885dd832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.100.0 (2023-09-07) + +### Feature + +* Small speed up to writing outgoing dns records ([#1258](https://github.com/python-zeroconf/python-zeroconf/issues/1258)) ([`1ed6bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/1ed6bd2ec4db0612b71384f923ffff1efd3ce878)) + ## v0.99.0 (2023-09-06) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 73120ef03..52d340a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.99.0" +version = "0.100.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d69e5c9e4..a79984730 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.99.0' +__version__ = '0.100.0' __license__ = 'LGPL' From 248655f0276223b089373c70ec13a0385dfaa4d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 14:01:11 -0500 Subject: [PATCH 0949/1433] feat: speed up writing outgoing dns records (#1259) --- src/zeroconf/_dns.pxd | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 5622a5ed3..126fe451d 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -1,6 +1,8 @@ import cython +from ._protocol.outgoing cimport DNSOutgoing + cdef object _LEN_BYTE cdef object _LEN_SHORT @@ -66,6 +68,8 @@ cdef class DNSAddress(DNSRecord): cdef _eq(self, DNSAddress other) + cpdef write(self, DNSOutgoing out) + cdef class DNSHinfo(DNSRecord): @@ -75,6 +79,7 @@ cdef class DNSHinfo(DNSRecord): cdef _eq(self, DNSHinfo other) + cpdef write(self, DNSOutgoing out) cdef class DNSPointer(DNSRecord): @@ -84,6 +89,7 @@ cdef class DNSPointer(DNSRecord): cdef _eq(self, DNSPointer other) + cpdef write(self, DNSOutgoing out) cdef class DNSText(DNSRecord): @@ -92,6 +98,7 @@ cdef class DNSText(DNSRecord): cdef _eq(self, DNSText other) + cpdef write(self, DNSOutgoing out) cdef class DNSService(DNSRecord): @@ -104,6 +111,7 @@ cdef class DNSService(DNSRecord): cdef _eq(self, DNSService other) + cpdef write(self, DNSOutgoing out) cdef class DNSNsec(DNSRecord): @@ -113,6 +121,7 @@ cdef class DNSNsec(DNSRecord): cdef _eq(self, DNSNsec other) + cpdef write(self, DNSOutgoing out) cdef class DNSRRSet: From 72bd07ddede25511377ec9d519eef8cae384a7d4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 7 Sep 2023 19:10:31 +0000 Subject: [PATCH 0950/1433] 0.101.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8885dd832..f073fc3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.101.0 (2023-09-07) + +### Feature + +* Speed up writing outgoing dns records ([#1259](https://github.com/python-zeroconf/python-zeroconf/issues/1259)) ([`248655f`](https://github.com/python-zeroconf/python-zeroconf/commit/248655f0276223b089373c70ec13a0385dfaa4d6)) + ## v0.100.0 (2023-09-07) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 52d340a0d..e3e5a9743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.100.0" +version = "0.101.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a79984730..de603e54d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.100.0' +__version__ = '0.101.0' __license__ = 'LGPL' From bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 14:55:52 -0500 Subject: [PATCH 0951/1433] feat: significantly speed up writing outgoing dns records (#1260) --- src/zeroconf/_protocol/outgoing.pxd | 20 +++++++++++++---- src/zeroconf/_protocol/outgoing.py | 34 +++++++++++++++++++---------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 0338cfd88..4caaf4537 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -17,6 +17,10 @@ cdef cython.uint _MAX_MSG_TYPICAL cdef object TYPE_CHECKING +cdef object PACK_BYTE +cdef object PACK_SHORT +cdef object PACK_LONG + cdef class DNSOutgoing: cdef public unsigned int flags @@ -24,7 +28,7 @@ cdef class DNSOutgoing: cdef public object id cdef public bint multicast cdef public cython.list packets_data - cdef public object names + cdef public cython.dict names cdef public cython.list data cdef public unsigned int size cdef public object allow_long @@ -53,7 +57,7 @@ cdef class DNSOutgoing: ) cdef _write_record(self, DNSRecord record, object now) - cdef _write_record_class(self, object record) + cdef _write_record_class(self, DNSEntry record) cdef _check_data_limit_or_rollback(self, object start_data_length, object start_size) @@ -61,16 +65,24 @@ cdef class DNSOutgoing: cdef _write_answers_from_offset(self, object answer_offset) - cdef _write_records_from_offset(self, object records, object offset) + cdef _write_records_from_offset(self, cython.list records, object offset) cdef _has_more_to_add(self, object questions_offset, object answer_offset, object authority_offset, object additional_offset) cdef _write_ttl(self, DNSRecord record, object now) - cpdef write_name(self, object name) + @cython.locals( + labels=cython.list, + label=cython.str, + ) + cpdef write_name(self, cython.str name) cpdef write_short(self, object value) + cpdef write_string(self, cython.bytes value) + + cpdef _write_utf(self, cython.str value) + @cython.locals( questions_offset=object, answer_offset=object, diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 069b2936b..4d17cce00 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -22,6 +22,7 @@ import enum import logging +from struct import Struct from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union from .._cache import DNSCache @@ -43,10 +44,16 @@ str_ = str float_ = float int_ = int +bytes_ = bytes DNSQuestion_ = DNSQuestion DNSRecord_ = DNSRecord +PACK_BYTE = Struct('>B').pack +PACK_SHORT = Struct('>H').pack +PACK_LONG = Struct('>L').pack + + class State(enum.Enum): init = 0 finished = 1 @@ -200,35 +207,35 @@ def add_question_or_all_cache( def _write_byte(self, value: int_) -> None: """Writes a single byte to the packet""" - self.data.append(value.to_bytes(1, 'big')) + self.data.append(PACK_BYTE(value)) self.size += 1 def _insert_short_at_start(self, value: int_) -> None: """Inserts an unsigned short at the start of the packet""" - self.data.insert(0, value.to_bytes(2, 'big')) + self.data.insert(0, PACK_SHORT(value)) def _replace_short(self, index: int_, value: int_) -> None: """Replaces an unsigned short in a certain position in the packet""" - self.data[index] = value.to_bytes(2, 'big') + self.data[index] = PACK_SHORT(value) def write_short(self, value: int_) -> None: """Writes an unsigned short to the packet""" - self.data.append(value.to_bytes(2, 'big')) + self.data.append(PACK_SHORT(value)) self.size += 2 def _write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" - self.data.append(int(value).to_bytes(4, 'big')) + self.data.append(PACK_LONG(int(value))) self.size += 4 - def write_string(self, value: bytes) -> None: + def write_string(self, value: bytes_) -> None: """Writes a string to the packet""" if TYPE_CHECKING: assert isinstance(value, bytes) self.data.append(value) self.size += len(value) - def _write_utf(self, s: str) -> None: + def _write_utf(self, s: str_) -> None: """Writes a UTF-8 string of a given length to the packet""" utfstr = s.encode('utf-8') length = len(utfstr) @@ -446,7 +453,8 @@ def _packets(self) -> List[bytes]: questions_offset, answer_offset, authority_offset, additional_offset ): # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - log.debug("Setting TC flag") + if debug_enable: # pragma: no branch + log.debug("Setting TC flag") self._insert_short_at_start(self.flags | _FLAGS_TC) else: self._insert_short_at_start(self.flags) @@ -459,9 +467,13 @@ def _packets(self) -> List[bytes]: self.packets_data.append(b''.join(self.data)) self._reset_for_next_packet() - if (questions_written + answers_written + authorities_written + additionals_written) == 0 and ( - len(self.questions) + len(self.answers) + len(self.authorities) + len(self.additionals) - ) > 0: + if ( + not questions_written + and not answers_written + and not authorities_written + and not additionals_written + and (self.questions or self.answers or self.authorities or self.additionals) + ): log.warning("packets() made no progress adding records; returning") break self.state = State.finished From dac5d4be501870fd0814756067ff3f98a04e612b Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 7 Sep 2023 20:04:21 +0000 Subject: [PATCH 0952/1433] 0.102.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f073fc3cd..9409d611c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.102.0 (2023-09-07) + +### Feature + +* Significantly speed up writing outgoing dns records ([#1260](https://github.com/python-zeroconf/python-zeroconf/issues/1260)) ([`bf2f366`](https://github.com/python-zeroconf/python-zeroconf/commit/bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077)) + ## v0.101.0 (2023-09-07) ### Feature diff --git a/pyproject.toml b/pyproject.toml index e3e5a9743..5f512829a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.101.0" +version = "0.102.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index de603e54d..3487db3e6 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.101.0' +__version__ = '0.102.0' __license__ = 'LGPL' From 33a2714cadff96edf016b869cc63b0661d16ef2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:36:32 -0500 Subject: [PATCH 0953/1433] feat: avoid calling get_running_loop when resolving ServiceInfo (#1261) --- src/zeroconf/_services/info.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 517b41bee..5fb05107f 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -263,11 +263,11 @@ def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: assert self._properties is not None return self._properties - async def async_wait(self, timeout: float) -> None: + async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: """Calling task waits for a given number of milliseconds or until notified.""" - loop = get_running_loop() - assert loop is not None - await wait_for_future_set_or_timeout(loop, self._new_records_futures, timeout) + await wait_for_future_set_or_timeout( + loop or asyncio.get_running_loop(), self._new_records_futures, timeout + ) def addresses_by_version(self, version: IPVersion) -> List[bytes]: """List addresses matching IP version. @@ -722,6 +722,9 @@ async def async_request( if self.load_from_cache(zc, now): return True + if TYPE_CHECKING: + assert zc.loop is not None + first_request = True delay = _LISTENER_TIME next_ = now @@ -743,7 +746,7 @@ async def async_request( delay *= 2 next_ += random.randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) - await self.async_wait(min(next_, last) - now) + await self.async_wait(min(next_, last) - now, zc.loop) now = current_time_millis() finally: zc.async_remove_listener(self) From 24f276e1dfab55b203a5de6e3184079cbd2fda3e Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 9 Sep 2023 22:47:14 +0000 Subject: [PATCH 0954/1433] 0.103.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9409d611c..9e64bb649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.103.0 (2023-09-09) + +### Feature + +* Avoid calling get_running_loop when resolving ServiceInfo ([#1261](https://github.com/python-zeroconf/python-zeroconf/issues/1261)) ([`33a2714`](https://github.com/python-zeroconf/python-zeroconf/commit/33a2714cadff96edf016b869cc63b0661d16ef2c)) + ## v0.102.0 (2023-09-07) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 5f512829a..d04574a30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.102.0" +version = "0.103.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3487db3e6..1a165ff59 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.102.0' +__version__ = '0.103.0' __license__ = 'LGPL' From 50a8f066b6ab90bc9e3300f81cf9332550b720df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 12:06:48 -0500 Subject: [PATCH 0955/1433] feat: speed up generating answers (#1262) --- build_ext.py | 1 + src/zeroconf/_handlers/answers.pxd | 16 ++++++++++++++++ src/zeroconf/_handlers/answers.py | 10 +++++++--- 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 src/zeroconf/_handlers/answers.pxd diff --git a/build_ext.py b/build_ext.py index 55d76d3cc..4af7b4fcd 100644 --- a/build_ext.py +++ b/build_ext.py @@ -29,6 +29,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", + "src/zeroconf/_handlers/answers.py", "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_handlers/query_handler.py", "src/zeroconf/_services/registry.py", diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd new file mode 100644 index 000000000..df34014a6 --- /dev/null +++ b/src/zeroconf/_handlers/answers.pxd @@ -0,0 +1,16 @@ + +import cython + +from .._protocol.outgoing cimport DNSOutgoing + + +cdef object _FLAGS_QR_RESPONSE_AA +cdef object NAME_GETTER + +cpdef construct_outgoing_multicast_answers(cython.dict answers) + +cpdef construct_outgoing_unicast_answers( + cython.dict answers, object ucast_source, cython.list questions, object id_ +) + +cdef _add_answers_additionals(DNSOutgoing out, cython.dict answers) diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index a80d2367a..44aa11cff 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -29,11 +29,15 @@ _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] +int_ = int + MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) NAME_GETTER = attrgetter('name') +_FLAGS_QR_RESPONSE_AA = _FLAGS_QR_RESPONSE | _FLAGS_AA + class QuestionAnswers(NamedTuple): ucast: _AnswerWithAdditionalsType @@ -52,16 +56,16 @@ class AnswerGroup(NamedTuple): def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=True) + out = DNSOutgoing(_FLAGS_QR_RESPONSE_AA, True) _add_answers_additionals(out, answers) return out def construct_outgoing_unicast_answers( - answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int + answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int_ ) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" - out = DNSOutgoing(_FLAGS_QR_RESPONSE | _FLAGS_AA, multicast=False, id_=id_) + out = DNSOutgoing(_FLAGS_QR_RESPONSE_AA, False, id_) # Adding the questions back when the source is legacy unicast behavior if ucast_source: for question in questions: From a537a31db9a5125544ef2310f4cafdcf83a6406b Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Sep 2023 17:14:51 +0000 Subject: [PATCH 0956/1433] 0.104.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e64bb649..660f3c5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.104.0 (2023-09-10) + +### Feature + +* Speed up generating answers ([#1262](https://github.com/python-zeroconf/python-zeroconf/issues/1262)) ([`50a8f06`](https://github.com/python-zeroconf/python-zeroconf/commit/50a8f066b6ab90bc9e3300f81cf9332550b720df)) + ## v0.103.0 (2023-09-09) ### Feature diff --git a/pyproject.toml b/pyproject.toml index d04574a30..9b90834fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.103.0" +version = "0.104.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1a165ff59..ce3b7a5de 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -84,7 +84,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.103.0' +__version__ = '0.104.0' __license__ = 'LGPL' From 6bf5d95a75ef7998f4b846b700bb160bc1c28300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 17:05:14 -0500 Subject: [PATCH 0957/1433] chore: prepare ServiceInfo base class RecordUpdateListener for cython (#1263) --- build_ext.py | 1 + src/zeroconf/__init__.py | 3 ++- src/zeroconf/_engine.py | 2 +- src/zeroconf/_handlers/record_manager.py | 3 ++- src/zeroconf/_record_update.py | 30 ++++++++++++++++++++++++ src/zeroconf/_services/browser.py | 3 ++- src/zeroconf/_services/info.py | 3 ++- src/zeroconf/_updates.pxd | 9 +++++++ src/zeroconf/_updates.py | 9 ++++--- 9 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/zeroconf/_record_update.py create mode 100644 src/zeroconf/_updates.pxd diff --git a/build_ext.py b/build_ext.py index 4af7b4fcd..8c39f4955 100644 --- a/build_ext.py +++ b/build_ext.py @@ -33,6 +33,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_handlers/query_handler.py", "src/zeroconf/_services/registry.py", + "src/zeroconf/_updates.py", "src/zeroconf/_utils/time.py", ], compiler_directives={"language_level": "3"}, # Python 3 diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ce3b7a5de..b17307948 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -50,6 +50,7 @@ from ._logger import QuietLogger, log # noqa # import needed for backwards compat from ._protocol.incoming import DNSIncoming # noqa # import needed for backwards compat from ._protocol.outgoing import DNSOutgoing # noqa # import needed for backwards compat +from ._record_update import RecordUpdate from ._services import ( # noqa # import needed for backwards compat ServiceListener, ServiceStateChange, @@ -65,7 +66,7 @@ ServiceRegistry, ) from ._services.types import ZeroconfServiceTypes -from ._updates import RecordUpdate, RecordUpdateListener +from ._updates import RecordUpdateListener from ._utils.name import service_type_name # noqa # import needed for backwards compat from ._utils.net import ( # noqa # import needed for backwards compat InterfaceChoice, diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index a74c091c7..9e4550030 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -26,7 +26,7 @@ import threading from typing import TYPE_CHECKING, List, Optional, cast -from ._updates import RecordUpdate +from ._record_update import RecordUpdate from ._utils.asyncio import get_running_loop, run_coro_with_timeout from ._utils.time import current_time_millis from .const import _CACHE_CLEANUP_INTERVAL diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 586fba0b1..396bad45c 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -26,7 +26,8 @@ from .._dns import DNSQuestion, DNSRecord from .._logger import log from .._protocol.incoming import DNSIncoming -from .._updates import RecordUpdate, RecordUpdateListener +from .._record_update import RecordUpdate +from .._updates import RecordUpdateListener from .._utils.time import current_time_millis from ..const import _ADDRESS_RECORD_TYPES, _DNS_PTR_MIN_TTL, _TYPE_PTR diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py new file mode 100644 index 000000000..fbcacd5fc --- /dev/null +++ b/src/zeroconf/_record_update.py @@ -0,0 +1,30 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from typing import NamedTuple, Optional + +from ._dns import DNSRecord + + +class RecordUpdate(NamedTuple): + new: DNSRecord + old: Optional[DNSRecord] diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 17307c991..de1769bcd 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -44,13 +44,14 @@ from .._dns import DNSPointer, DNSQuestion, DNSQuestionType from .._logger import log from .._protocol.outgoing import DNSOutgoing +from .._record_update import RecordUpdate from .._services import ( ServiceListener, ServiceStateChange, Signal, SignalRegistrationInterface, ) -from .._updates import RecordUpdate, RecordUpdateListener +from .._updates import RecordUpdateListener from .._utils.name import cached_possible_types, service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 5fb05107f..14398b6a2 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -38,7 +38,8 @@ from .._exceptions import BadTypeInNameException from .._logger import log from .._protocol.outgoing import DNSOutgoing -from .._updates import RecordUpdate, RecordUpdateListener +from .._record_update import RecordUpdate +from .._updates import RecordUpdateListener from .._utils.asyncio import ( _resolve_all_futures_to_none, get_running_loop, diff --git a/src/zeroconf/_updates.pxd b/src/zeroconf/_updates.pxd new file mode 100644 index 000000000..6309537c3 --- /dev/null +++ b/src/zeroconf/_updates.pxd @@ -0,0 +1,9 @@ + +import cython + + +cdef class RecordUpdateListener: + + cpdef async_update_records(self, object zc, object now, cython.list records) + + cpdef async_update_records_complete(self) diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index b760daf90..a117cc2b2 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -20,17 +20,16 @@ USA """ -from typing import TYPE_CHECKING, List, NamedTuple, Optional +from typing import TYPE_CHECKING, List from ._dns import DNSRecord +from ._record_update import RecordUpdate if TYPE_CHECKING: from ._core import Zeroconf -class RecordUpdate(NamedTuple): - new: DNSRecord - old: Optional[DNSRecord] +float_ = float class RecordUpdateListener: @@ -50,7 +49,7 @@ def update_record( # pylint: disable=no-self-use """ raise RuntimeError("update_record is deprecated and will be removed in a future version.") - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: """Update multiple records in one shot. All records that are received in a single packet are passed From 7ca690ac3fa75e7474d3412944bbd5056cb313dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 18:03:42 -0500 Subject: [PATCH 0958/1433] feat: speed up ServiceInfo with a cython pxd (#1264) --- build_ext.py | 1 + src/zeroconf/_services/info.pxd | 87 +++++++++++++++++++++++++++++++++ src/zeroconf/_services/info.py | 67 +++++++++++++++---------- 3 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 src/zeroconf/_services/info.pxd diff --git a/build_ext.py b/build_ext.py index 8c39f4955..b9deeecb5 100644 --- a/build_ext.py +++ b/build_ext.py @@ -32,6 +32,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_handlers/answers.py", "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_handlers/query_handler.py", + "src/zeroconf/_services/info.py", "src/zeroconf/_services/registry.py", "src/zeroconf/_updates.py", "src/zeroconf/_utils/time.py", diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd new file mode 100644 index 000000000..b06ea88de --- /dev/null +++ b/src/zeroconf/_services/info.pxd @@ -0,0 +1,87 @@ + +import cython + +from .._cache cimport DNSCache +from .._dns cimport DNSPointer, DNSRecord, DNSService, DNSText +from .._protocol.outgoing cimport DNSOutgoing +from .._updates cimport RecordUpdateListener +from .._utils.time cimport current_time_millis + + +cdef object _resolve_all_futures_to_none + +cdef object _TYPE_SRV +cdef object _TYPE_TXT +cdef object _TYPE_A +cdef object _TYPE_AAAA +cdef object _TYPE_PTR +cdef object _TYPE_NSEC +cdef object _CLASS_IN +cdef object _FLAGS_QR_QUERY + +cdef object service_type_name + +cdef object DNS_QUESTION_TYPE_QU +cdef object DNS_QUESTION_TYPE_QM + +cdef object _IPVersion_All_value +cdef object _IPVersion_V4Only_value + +cdef object TYPE_CHECKING + +cdef class ServiceInfo(RecordUpdateListener): + + cdef public cython.bytes text + cdef public str type + cdef str _name + cdef public str key + cdef public cython.list _ipv4_addresses + cdef public cython.list _ipv6_addresses + cdef public object port + cdef public object weight + cdef public object priority + cdef public str server + cdef public str server_key + cdef public cython.dict _properties + cdef public object host_ttl + cdef public object other_ttl + cdef public object interface_index + cdef public cython.set _new_records_futures + cdef public DNSPointer _dns_pointer_cache + cdef public DNSService _dns_service_cache + cdef public DNSText _dns_text_cache + cdef public cython.list _dns_address_cache + cdef public cython.set _get_address_and_nsec_records_cache + + @cython.locals( + cache=DNSCache + ) + cpdef async_update_records(self, object zc, object now, cython.list records) + + @cython.locals( + cache=DNSCache + ) + cpdef _load_from_cache(self, object zc, object now) + + cdef _unpack_text_into_properties(self) + + cdef _set_properties(self, cython.dict properties) + + cdef _set_text(self, cython.bytes text) + + cdef _get_ip_addresses_from_cache_lifo(self, object zc, object now, object type) + + cdef _process_record_threadsafe(self, object zc, DNSRecord record, object now) + + @cython.locals( + cache=DNSCache + ) + cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) + + cdef _set_ipv4_addresses_from_cache(self, object zc, object now) + + cdef _set_ipv6_addresses_from_cache(self, object zc, object now) + + cdef cython.list _ip_addresses_by_version_value(self, object version_value) + + cdef addresses_by_version(self, object version) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 14398b6a2..425ad7508 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -78,6 +78,12 @@ # the A/AAAA/SRV records for a host. _AVOID_SYNC_DELAY_RANDOM_INTERVAL = (20, 120) +float_ = float +int_ = int + +DNS_QUESTION_TYPE_QU = DNSQuestionType.QU +DNS_QUESTION_TYPE_QM = DNSQuestionType.QM + if TYPE_CHECKING: from .._core import Zeroconf @@ -281,10 +287,9 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: """ version_value = version.value if version_value == _IPVersion_All_value: - return [ - *(addr.packed for addr in self._ipv4_addresses), - *(addr.packed for addr in self._ipv6_addresses), - ] + ip_v4_packed = [addr.packed for addr in self._ipv4_addresses] + ip_v6_packed = [addr.packed for addr in self._ipv6_addresses] + return [*ip_v4_packed, *ip_v6_packed] if version_value == _IPVersion_V4Only_value: return [addr.packed for addr in self._ipv4_addresses] return [addr.packed for addr in self._ipv6_addresses] @@ -303,7 +308,7 @@ def ip_addresses_by_version( return self._ip_addresses_by_version_value(version.value) def _ip_addresses_by_version_value( - self, version_value: int + self, version_value: int_ ) -> Union[List[IPv4Address], List[IPv6Address], List[_BaseAddress]]: """Backend for addresses_by_version that uses the raw value.""" if version_value == _IPVersion_All_value: @@ -397,7 +402,7 @@ def get_name(self) -> str: return self._name[: len(self._name) - len(self.type) - 1] def _get_ip_addresses_from_cache_lifo( - self, zc: 'Zeroconf', now: float, type: int + self, zc: 'Zeroconf', now: float_, type: int_ ) -> List[Union[IPv4Address, IPv6Address]]: """Set IPv6 addresses from the cache.""" address_list: List[Union[IPv4Address, IPv6Address]] = [] @@ -410,7 +415,7 @@ def _get_ip_addresses_from_cache_lifo( address_list.reverse() # Reverse to get LIFO order return address_list - def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: + def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: self._ipv6_addresses = cast( @@ -419,7 +424,7 @@ def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: else: self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) - def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: + def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float_) -> None: """Set IPv4 addresses from the cache.""" if TYPE_CHECKING: self._ipv4_addresses = cast( @@ -428,7 +433,7 @@ def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float) -> None: else: self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: """Updates service information from a DNS record. This method will be run in the event loop. @@ -440,7 +445,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU if updated and new_records_futures: _resolve_all_futures_to_none(new_records_futures) - def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: float) -> bool: + def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: float_) -> bool: """Thread safe record updating. Returns True if a new record was added. @@ -624,14 +629,15 @@ def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Se self._get_address_and_nsec_records_cache = records return records - def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int) -> List[DNSAddress]: + def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int_) -> List[DNSAddress]: """Get the addresses from the cache.""" if self.server_key is None: return [] + cache = zc.cache if TYPE_CHECKING: - records = cast("List[DNSAddress]", zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) + records = cast("List[DNSAddress]", cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) else: - records = zc.cache.get_all_by_details(self.server_key, _type, _CLASS_IN) + records = cache.get_all_by_details(self.server_key, _type, _CLASS_IN) return records def set_server_if_missing(self) -> None: @@ -643,28 +649,33 @@ def set_server_if_missing(self) -> None: self.server = self._name self.server_key = self.key - def load_from_cache(self, zc: 'Zeroconf', now: Optional[float] = None) -> bool: + def load_from_cache(self, zc: 'Zeroconf', now: Optional[float_] = None) -> bool: + """Populate the service info from the cache. + + This method is designed to be threadsafe. + """ + return self._load_from_cache(zc, now or current_time_millis()) + + def _load_from_cache(self, zc: 'Zeroconf', now: float_) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. """ - if not now: - now = current_time_millis() + cache = zc.cache original_server_key = self.server_key - cached_srv_record = zc.cache.get_by_details(self._name, _TYPE_SRV, _CLASS_IN) + cached_srv_record = cache.get_by_details(self._name, _TYPE_SRV, _CLASS_IN) if cached_srv_record: self._process_record_threadsafe(zc, cached_srv_record, now) - cached_txt_record = zc.cache.get_by_details(self._name, _TYPE_TXT, _CLASS_IN) + cached_txt_record = cache.get_by_details(self._name, _TYPE_TXT, _CLASS_IN) if cached_txt_record: self._process_record_threadsafe(zc, cached_txt_record, now) if original_server_key == self.server_key: # If there is a srv which changes the server_key, # A and AAAA will already be loaded from the cache # and we do not want to do it twice - for record in [ - *self._get_address_records_from_cache_by_type(zc, _TYPE_A), - *self._get_address_records_from_cache_by_type(zc, _TYPE_AAAA), - ]: + for record in self._get_address_records_from_cache_by_type(zc, _TYPE_A): + self._process_record_threadsafe(zc, record, now) + for record in self._get_address_records_from_cache_by_type(zc, _TYPE_AAAA): self._process_record_threadsafe(zc, record, now) return self._is_complete @@ -720,7 +731,7 @@ async def async_request( now = current_time_millis() - if self.load_from_cache(zc, now): + if self._load_from_cache(zc, now): return True if TYPE_CHECKING: @@ -737,11 +748,13 @@ async def async_request( return False if next_ <= now: out = self.generate_request_query( - zc, now, question_type or DNSQuestionType.QU if first_request else DNSQuestionType.QM + zc, + now, + question_type or DNS_QUESTION_TYPE_QU if first_request else DNS_QUESTION_TYPE_QM, ) first_request = False if not out.questions: - return self.load_from_cache(zc, now) + return self._load_from_cache(zc, now) zc.async_send(out, addr, port) next_ = now + delay delay *= 2 @@ -755,7 +768,7 @@ async def async_request( return True def generate_request_query( - self, zc: 'Zeroconf', now: float, question_type: Optional[DNSQuestionType] = None + self, zc: 'Zeroconf', now: float_, question_type: Optional[DNSQuestionType] = None ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) @@ -766,7 +779,7 @@ def generate_request_query( out.add_question_or_one_cache(cache, now, name, _TYPE_TXT, _CLASS_IN) out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_A, _CLASS_IN) out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_AAAA, _CLASS_IN) - if question_type == DNSQuestionType.QU: + if question_type == DNS_QUESTION_TYPE_QU: for question in out.questions: question.unicast = True return out From eef99c7e632a112dd034d7e5257cdc0aa514f472 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Sep 2023 23:11:54 +0000 Subject: [PATCH 0959/1433] 0.105.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660f3c5c7..56115f856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.105.0 (2023-09-10) + +### Feature + +* Speed up ServiceInfo with a cython pxd ([#1264](https://github.com/python-zeroconf/python-zeroconf/issues/1264)) ([`7ca690a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ca690ac3fa75e7474d3412944bbd5056cb313dd)) + ## v0.104.0 (2023-09-10) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 9b90834fc..fc208f48e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.104.0" +version = "0.105.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b17307948..381fb0fef 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.104.0' +__version__ = '0.105.0' __license__ = 'LGPL' From 37bfaf2f630358e8c68652f3b3120931a6f94910 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 09:55:15 -0500 Subject: [PATCH 0960/1433] feat: speed up answering questions (#1265) --- src/zeroconf/_handlers/query_handler.pxd | 9 ++++++- src/zeroconf/_handlers/query_handler.py | 21 ++++++++-------- src/zeroconf/_services/info.pxd | 21 +++++++++++++++- src/zeroconf/_services/info.py | 32 ++++++++++++++++++++++-- src/zeroconf/_services/registry.pxd | 14 ++++++----- tests/services/test_info.py | 15 +++++++++++ 6 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 3457128ca..afbaab95b 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -5,6 +5,7 @@ from .._cache cimport DNSCache from .._dns cimport DNSPointer, DNSQuestion, DNSRecord, DNSRRSet from .._history cimport QuestionHistory from .._protocol.incoming cimport DNSIncoming +from .._services.info cimport ServiceInfo from .._services.registry cimport ServiceRegistry @@ -12,6 +13,9 @@ cdef object TYPE_CHECKING, QuestionAnswers cdef cython.uint _ONE_SECOND, _TYPE_PTR, _TYPE_ANY, _TYPE_A, _TYPE_AAAA, _TYPE_SRV, _TYPE_TXT cdef str _SERVICE_TYPE_ENUMERATION_NAME cdef cython.set _RESPOND_IMMEDIATE_TYPES +cdef cython.set _ADDRESS_RECORD_TYPES +cdef object IPVersion +cdef object _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL cdef class _QueryResponse: @@ -45,13 +49,16 @@ cdef class QueryHandler: cdef DNSCache cache cdef QuestionHistory question_history + @cython.locals(service=ServiceInfo) cdef _add_service_type_enumeration_query_answers(self, cython.dict answer_set, DNSRRSet known_answers) + @cython.locals(service=ServiceInfo) cdef _add_pointer_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers) + @cython.locals(service=ServiceInfo) cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) - @cython.locals(question_lower_name=str, type_=cython.uint) + @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) cdef _answer_question(self, DNSQuestion question, DNSRRSet known_answers) @cython.locals( diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 34fde5470..66deab43b 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -28,6 +28,7 @@ from .._history import QuestionHistory from .._protocol.incoming import DNSIncoming from .._services.registry import ServiceRegistry +from .._utils.net import IPVersion from ..const import ( _ADDRESS_RECORD_TYPES, _CLASS_IN, @@ -180,13 +181,13 @@ def _add_pointer_answers( for service in self.registry.async_get_infos_type(lower_name): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. - dns_pointer = service.dns_pointer() + dns_pointer = service._dns_pointer(None) if known_answers.suppresses(dns_pointer): continue answer_set[dns_pointer] = { - service.dns_service(), - service.dns_text(), - } | service.get_address_and_nsec_records() + service._dns_service(None), + service._dns_text(None), + } | service._get_address_and_nsec_records(None) def _add_address_answers( self, @@ -200,7 +201,7 @@ def _add_address_answers( answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() seen_types: Set[int] = set() - for dns_address in service.dns_addresses(): + for dns_address in service._dns_addresses(None, IPVersion.All): seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) @@ -210,12 +211,12 @@ def _add_address_answers( if answers: if missing_types: assert service.server is not None, "Service server must be set for NSEC record." - additionals.add(service.dns_nsec(list(missing_types))) + additionals.add(service._dns_nsec(list(missing_types), None)) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: assert service.server is not None, "Service server must be set for NSEC record." - answer_set[service.dns_nsec(list(missing_types))] = set() + answer_set[service._dns_nsec(list(missing_types), None)] = set() def _answer_question( self, @@ -243,11 +244,11 @@ def _answer_question( if type_ in (_TYPE_SRV, _TYPE_ANY): # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. - dns_service = service.dns_service() + dns_service = service._dns_service(None) if not known_answers.suppresses(dns_service): - answer_set[dns_service] = service.get_address_and_nsec_records() + answer_set[dns_service] = service._get_address_and_nsec_records(None) if type_ in (_TYPE_TXT, _TYPE_ANY): - dns_text = service.dns_text() + dns_text = service._dns_text(None) if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index b06ea88de..860cc9bab 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -2,7 +2,7 @@ import cython from .._cache cimport DNSCache -from .._dns cimport DNSPointer, DNSRecord, DNSService, DNSText +from .._dns cimport DNSNsec, DNSPointer, DNSRecord, DNSService, DNSText from .._protocol.outgoing cimport DNSOutgoing from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis @@ -27,6 +27,8 @@ cdef object DNS_QUESTION_TYPE_QM cdef object _IPVersion_All_value cdef object _IPVersion_V4Only_value +cdef cython.set _ADDRESS_RECORD_TYPES + cdef object TYPE_CHECKING cdef class ServiceInfo(RecordUpdateListener): @@ -85,3 +87,20 @@ cdef class ServiceInfo(RecordUpdateListener): cdef cython.list _ip_addresses_by_version_value(self, object version_value) cdef addresses_by_version(self, object version) + + @cython.locals(cacheable=cython.bint) + cdef cython.list _dns_addresses(self, object override_ttls, object version) + + @cython.locals(cacheable=cython.bint) + cdef DNSPointer _dns_pointer(self, object override_ttl) + + @cython.locals(cacheable=cython.bint) + cdef DNSService _dns_service(self, object override_ttl) + + @cython.locals(cacheable=cython.bint) + cdef DNSText _dns_text(self, object override_ttl) + + cdef DNSNsec _dns_nsec(self, cython.list missing_types, object override_ttl) + + @cython.locals(cacheable=cython.bint) + cdef cython.set _get_address_and_nsec_records(self, object override_ttl) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 425ad7508..352c9b8ef 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -519,6 +519,14 @@ def dns_addresses( self, override_ttl: Optional[int] = None, version: IPVersion = IPVersion.All, + ) -> List[DNSAddress]: + """Return matching DNSAddress from ServiceInfo.""" + return self._dns_addresses(override_ttl, version) + + def _dns_addresses( + self, + override_ttl: Optional[int], + version: IPVersion, ) -> List[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" cacheable = version is IPVersion.All and override_ttl is None @@ -544,6 +552,10 @@ def dns_addresses( return records def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: + """Return DNSPointer from ServiceInfo.""" + return self._dns_pointer(override_ttl) + + def _dns_pointer(self, override_ttl: Optional[int]) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" cacheable = override_ttl is None if self._dns_pointer_cache is not None and cacheable: @@ -561,6 +573,10 @@ def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: return record def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: + """Return DNSService from ServiceInfo.""" + return self._dns_service(override_ttl) + + def _dns_service(self, override_ttl: Optional[int]) -> DNSService: """Return DNSService from ServiceInfo.""" cacheable = override_ttl is None if self._dns_service_cache is not None and cacheable: @@ -584,6 +600,10 @@ def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: return record def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: + """Return DNSText from ServiceInfo.""" + return self._dns_text(override_ttl) + + def _dns_text(self, override_ttl: Optional[int]) -> DNSText: """Return DNSText from ServiceInfo.""" cacheable = override_ttl is None if self._dns_text_cache is not None and cacheable: @@ -601,6 +621,10 @@ def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: return record def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) -> DNSNsec: + """Return DNSNsec from ServiceInfo.""" + return self._dns_nsec(missing_types, override_ttl) + + def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -613,18 +637,22 @@ def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) ) def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Set[DNSRecord]: + """Build a set of address records and NSEC records for non-present record types.""" + return self._get_address_and_nsec_records(override_ttl) + + def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" cacheable = override_ttl is None if self._get_address_and_nsec_records_cache is not None and cacheable: return self._get_address_and_nsec_records_cache missing_types: Set[int] = _ADDRESS_RECORD_TYPES.copy() records: Set[DNSRecord] = set() - for dns_address in self.dns_addresses(override_ttl, IPVersion.All): + for dns_address in self._dns_addresses(override_ttl, IPVersion.All): missing_types.discard(dns_address.type) records.add(dns_address) if missing_types: assert self.server is not None, "Service server must be set for NSEC record." - records.add(self.dns_nsec(list(missing_types), override_ttl)) + records.add(self._dns_nsec(list(missing_types), override_ttl)) if cacheable: self._get_address_and_nsec_records_cache = records return records diff --git a/src/zeroconf/_services/registry.pxd b/src/zeroconf/_services/registry.pxd index a741b93a8..1d0562c3b 100644 --- a/src/zeroconf/_services/registry.pxd +++ b/src/zeroconf/_services/registry.pxd @@ -1,6 +1,8 @@ import cython +from .info cimport ServiceInfo + cdef class ServiceRegistry: @@ -11,16 +13,16 @@ cdef class ServiceRegistry: @cython.locals( record_list=cython.list, ) - cdef _async_get_by_index(self, cython.dict records, str key) + cdef cython.list _async_get_by_index(self, cython.dict records, str key) - cdef _add(self, object info) + cdef _add(self, ServiceInfo info) cdef _remove(self, cython.list infos) - cpdef async_get_info_name(self, str name) + cpdef ServiceInfo async_get_info_name(self, str name) - cpdef async_get_types(self) + cpdef cython.list async_get_types(self) - cpdef async_get_infos_type(self, str type_) + cpdef cython.list async_get_infos_type(self, str type_) - cpdef async_get_infos_server(self, str server) + cpdef cython.list async_get_infos_server(self, str server) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index c0a4e661f..7d437d232 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1535,3 +1535,18 @@ async def test_release_wait_when_new_recorded_added_concurrency(): assert not pending assert info.addresses == [b'\x7f\x00\x00\x01'] await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_service_info_nsec_records(): + """Test we can generate nsec records from ServiceInfo.""" + type_ = "_http._tcp.local." + registration_name = "multiareccon.%s" % type_ + desc = {'path': '/~paulsm/'} + host = "multahostcon.local." + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) + nsec_record = info.dns_nsec([const._TYPE_A, const._TYPE_AAAA], 50) + assert nsec_record.name == registration_name + assert nsec_record.type == const._TYPE_NSEC + assert nsec_record.ttl == 50 + assert nsec_record.rdtypes == [const._TYPE_A, const._TYPE_AAAA] From f38cf555f70f3d4c27f442b0db6eb8452603dbfb Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 11 Sep 2023 15:04:40 +0000 Subject: [PATCH 0961/1433] 0.106.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56115f856..11f75a196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.106.0 (2023-09-11) + +### Feature + +* Speed up answering questions ([#1265](https://github.com/python-zeroconf/python-zeroconf/issues/1265)) ([`37bfaf2`](https://github.com/python-zeroconf/python-zeroconf/commit/37bfaf2f630358e8c68652f3b3120931a6f94910)) + ## v0.105.0 (2023-09-10) ### Feature diff --git a/pyproject.toml b/pyproject.toml index fc208f48e..84051a847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.105.0" +version = "0.106.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 381fb0fef..db9c166b5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.105.0' +__version__ = '0.106.0' __license__ = 'LGPL' From 24a0a00b3e457979e279a2eeadc8fad2ab09e125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 10:19:56 -0500 Subject: [PATCH 0962/1433] feat: speed up responding to queries (#1266) --- src/zeroconf/_dns.pxd | 2 ++ src/zeroconf/_dns.py | 6 +++++- src/zeroconf/_handlers/query_handler.pxd | 4 ++-- src/zeroconf/_handlers/query_handler.py | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 126fe451d..afcb1985c 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -136,3 +136,5 @@ cdef class DNSRRSet: record_sets=cython.list, ) cdef cython.dict _get_lookup(self) + + cpdef cython.set lookup_set(self) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 73b0c7510..4c015eb31 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -22,7 +22,7 @@ import enum import socket -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -533,6 +533,10 @@ def lookup(self) -> Dict[DNSRecord, float]: """Return the lookup table.""" return self._get_lookup() + def lookup_set(self) -> Set[DNSRecord]: + """Return the lookup table as aset.""" + return set(self._get_lookup()) + def _get_lookup(self) -> Dict[DNSRecord, float]: """Return the lookup table, building it if needed.""" if self._lookup is None: diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index afbaab95b..1f1e4da87 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -59,7 +59,7 @@ cdef class QueryHandler: cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) - cdef _answer_question(self, DNSQuestion question, DNSRRSet known_answers) + cdef cython.dict _answer_question(self, DNSQuestion question, DNSRRSet known_answers) @cython.locals( msg=DNSIncoming, @@ -68,4 +68,4 @@ cdef class QueryHandler: known_answers=DNSRRSet, known_answers_set=cython.set, ) - cpdef async_response(self, cython.list msgs, object unicast_source) + cpdef async_response(self, cython.list msgs, cython.bint unicast_source) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 66deab43b..f42430215 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -268,12 +268,12 @@ def async_response( # pylint: disable=unused-argument for msg in msgs: for question in msg.questions: - if not question.unicast: + if not question.unique: # unique and unicast are the same flag if not known_answers_set: # pragma: no branch - known_answers_set = set(known_answers.lookup) + known_answers_set = known_answers.lookup_set() self.question_history.add_question_at_time(question, msg.now, known_answers_set) answer_set = self._answer_question(question, known_answers) - if not ucast_source and question.unicast: + if not ucast_source and question.unique: # unique and unicast are the same flag query_res.add_qu_question_response(answer_set) continue if ucast_source: From 49d3b0c656db3dfd8ff5b9d6385a22fd64c8a327 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 11 Sep 2023 15:29:11 +0000 Subject: [PATCH 0963/1433] 0.107.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f75a196..7195a7629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.107.0 (2023-09-11) + +### Feature + +* Speed up responding to queries ([#1266](https://github.com/python-zeroconf/python-zeroconf/issues/1266)) ([`24a0a00`](https://github.com/python-zeroconf/python-zeroconf/commit/24a0a00b3e457979e279a2eeadc8fad2ab09e125)) + ## v0.106.0 (2023-09-11) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 84051a847..245272fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.106.0" +version = "0.107.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index db9c166b5..609e85019 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.106.0' +__version__ = '0.107.0' __license__ = 'LGPL' From aed63911f6da0c61165bce79518e6e3f54cb9929 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 13:37:42 -0500 Subject: [PATCH 0964/1433] chore: bump cpython to 3.12rc2 in the CI (#1269) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff79b70b0..d49d30e6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - "3.9" - "3.10" - "3.11" - - "3.12.0-rc.1" + - "3.12.0-rc.2" - "pypy-3.7" os: - ubuntu-latest From 00c439a6400b7850ef9fdd75bc8d82d4e64b1da0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 13:37:57 -0500 Subject: [PATCH 0965/1433] feat: improve performance of constructing outgoing queries (#1267) --- src/zeroconf/_handlers/answers.pxd | 4 ++- src/zeroconf/_handlers/answers.py | 3 ++- src/zeroconf/_protocol/outgoing.pxd | 25 ++++++++++++++---- src/zeroconf/_protocol/outgoing.py | 40 +++++++++++++++++++---------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index df34014a6..91e2375d1 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -1,6 +1,7 @@ import cython +from .._dns cimport DNSRecord from .._protocol.outgoing cimport DNSOutgoing @@ -10,7 +11,8 @@ cdef object NAME_GETTER cpdef construct_outgoing_multicast_answers(cython.dict answers) cpdef construct_outgoing_unicast_answers( - cython.dict answers, object ucast_source, cython.list questions, object id_ + cython.dict answers, bint ucast_source, cython.list questions, object id_ ) +@cython.locals(answer=DNSRecord, additionals=cython.set, additional=DNSRecord) cdef _add_answers_additionals(DNSOutgoing out, cython.dict answers) diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index 44aa11cff..009bedbc5 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -82,7 +82,8 @@ def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsTy # overall size of the outgoing response via name compression for answer in sorted(answers, key=NAME_GETTER): out.add_answer_at_time(answer, 0) - for additional in answers[answer]: + additionals = answers[answer] + for additional in additionals: if additional not in sending: out.add_additional_answer(additional) sending.add(additional) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 4caaf4537..1c4d6af71 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -21,17 +21,25 @@ cdef object PACK_BYTE cdef object PACK_SHORT cdef object PACK_LONG +cdef object STATE_INIT +cdef object STATE_FINISHED + +cdef object LOGGING_IS_ENABLED_FOR +cdef object LOGGING_DEBUG + +cdef cython.tuple BYTE_TABLE + cdef class DNSOutgoing: cdef public unsigned int flags - cdef public object finished + cdef public bint finished cdef public object id cdef public bint multicast cdef public cython.list packets_data cdef public cython.dict names cdef public cython.list data cdef public unsigned int size - cdef public object allow_long + cdef public bint allow_long cdef public object state cdef public cython.list questions cdef public cython.list answers @@ -48,18 +56,21 @@ cdef class DNSOutgoing: cdef _write_int(self, object value) - cdef _write_question(self, DNSQuestion question) + cdef cython.bint _write_question(self, DNSQuestion question) @cython.locals( d=cython.bytes, data_view=cython.list, length=cython.uint ) - cdef _write_record(self, DNSRecord record, object now) + cdef cython.bint _write_record(self, DNSRecord record, object now) cdef _write_record_class(self, DNSEntry record) - cdef _check_data_limit_or_rollback(self, object start_data_length, object start_size) + @cython.locals( + start_size_int=object + ) + cdef cython.bint _check_data_limit_or_rollback(self, cython.uint start_data_length, cython.uint start_size) cdef _write_questions_from_offset(self, object questions_offset) @@ -74,6 +85,9 @@ cdef class DNSOutgoing: @cython.locals( labels=cython.list, label=cython.str, + index=cython.uint, + start_size=cython.uint, + name_length=cython.uint, ) cpdef write_name(self, cython.str name) @@ -103,6 +117,7 @@ cdef class DNSOutgoing: cpdef add_answer(self, DNSIncoming inp, DNSRecord record) + @cython.locals(now_float=cython.float) cpdef add_answer_at_time(self, DNSRecord record, object now) cpdef add_authorative_answer(self, DNSPointer record) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 4d17cce00..f4f68c3de 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -53,12 +53,21 @@ PACK_SHORT = Struct('>H').pack PACK_LONG = Struct('>L').pack +BYTE_TABLE = tuple(PACK_BYTE(i) for i in range(256)) + class State(enum.Enum): init = 0 finished = 1 +STATE_INIT = State.init +STATE_FINISHED = State.finished + +LOGGING_IS_ENABLED_FOR = log.isEnabledFor +LOGGING_DEBUG = logging.DEBUG + + class DNSOutgoing: """Object representation of an outgoing packet""" @@ -93,7 +102,7 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.size: int = _DNS_PACKET_HEADER_LEN self.allow_long: bool = True - self.state = State.init + self.state = STATE_INIT self.questions: List[DNSQuestion] = [] self.answers: List[Tuple[DNSRecord, float]] = [] @@ -137,7 +146,8 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" - if record is not None and (now == 0 or not record.is_expired(now)): + now_float = now + if record is not None and (now_float == 0 or not record.is_expired(now_float)): self.answers.append((record, now)) def add_authorative_answer(self, record: DNSPointer) -> None: @@ -207,7 +217,7 @@ def add_question_or_all_cache( def _write_byte(self, value: int_) -> None: """Writes a single byte to the packet""" - self.data.append(PACK_BYTE(value)) + self.data.append(BYTE_TABLE[value]) self.size += 1 def _insert_short_at_start(self, value: int_) -> None: @@ -267,7 +277,7 @@ def write_name(self, name: str_) -> None: """ # split name into each label - name_length = None + name_length = 0 if name.endswith('.'): name = name[: len(name) - 1] labels = name.split('.') @@ -276,14 +286,14 @@ def write_name(self, name: str_) -> None: start_size = self.size for count in range(len(labels)): label = name if count == 0 else '.'.join(labels[count:]) - index = self.names.get(label) + index = self.names.get(label, 0) if index: # If part of the name already exists in the packet, # create a pointer to it self._write_byte((index >> 8) | 0xC0) self._write_byte(index & 0xFF) return - if name_length is None: + if name_length == 0: name_length = len(name.encode('utf-8')) self.names[label] = start_size + name_length - len(label.encode('utf-8')) self._write_utf(labels[count]) @@ -293,7 +303,8 @@ def write_name(self, name: str_) -> None: def _write_question(self, question: DNSQuestion_) -> bool: """Writes a question to the packet""" - start_data_length, start_size = len(self.data), self.size + start_data_length = len(self.data) + start_size = self.size self.write_name(question.name) self.write_short(question.type) self._write_record_class(question) @@ -314,7 +325,8 @@ def _write_record(self, record: DNSRecord_, now: float_) -> bool: """Writes a record (answer, authoritative answer, additional) to the packet. Returns True on success, or False if we did not because the packet because the record does not fit.""" - start_data_length, start_size = len(self.data), self.size + start_data_length = len(self.data) + start_size = self.size self.write_name(record.name) self.write_short(record.type) self._write_record_class(record) @@ -339,11 +351,13 @@ def _check_data_limit_or_rollback(self, start_data_length: int_, start_size: int if self.size <= len_limit: return True - log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) + if LOGGING_IS_ENABLED_FOR(LOGGING_DEBUG): # pragma: no branch + log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) del self.data[start_data_length:] self.size = start_size - rollback_names = [name for name, idx in self.names.items() if idx >= start_size] + start_size_int = start_size + rollback_names = [name for name, idx in self.names.items() if idx >= start_size_int] for name in rollback_names: del self.names[name] return False @@ -395,7 +409,7 @@ def packets(self) -> List[bytes]: return self._packets() def _packets(self) -> List[bytes]: - if self.state == State.finished: + if self.state == STATE_FINISHED: return self.packets_data questions_offset = 0 @@ -404,7 +418,7 @@ def _packets(self) -> List[bytes]: additional_offset = 0 # we have to at least write out the question first_time = True - debug_enable = log.isEnabledFor(logging.DEBUG) + debug_enable = LOGGING_IS_ENABLED_FOR(LOGGING_DEBUG) while first_time or self._has_more_to_add( questions_offset, answer_offset, authority_offset, additional_offset @@ -476,5 +490,5 @@ def _packets(self) -> List[bytes]: ): log.warning("packets() made no progress adding records; returning") break - self.state = State.finished + self.state = STATE_FINISHED return self.packets_data From c88530bc808dbaf9aff83044938469da7b999278 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 11 Sep 2023 18:46:45 +0000 Subject: [PATCH 0966/1433] 0.108.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7195a7629..8baadc236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.108.0 (2023-09-11) + +### Feature + +* Improve performance of constructing outgoing queries ([#1267](https://github.com/python-zeroconf/python-zeroconf/issues/1267)) ([`00c439a`](https://github.com/python-zeroconf/python-zeroconf/commit/00c439a6400b7850ef9fdd75bc8d82d4e64b1da0)) + ## v0.107.0 (2023-09-11) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 245272fe2..56067c912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.107.0" +version = "0.108.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 609e85019..6af1ddae5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.107.0' +__version__ = '0.108.0' __license__ = 'LGPL' From 48378769c3887b5746ca00de30067a4c0851765c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:36:27 -0500 Subject: [PATCH 0967/1433] feat: speed up ServiceBrowsers with a cython pxd (#1270) --- build_ext.py | 1 + src/zeroconf/_services/browser.pxd | 74 ++++++++++++++++++++++++++++++ src/zeroconf/_services/browser.py | 65 +++++++++++++++----------- src/zeroconf/_services/info.pxd | 6 +-- src/zeroconf/_updates.pxd | 2 +- 5 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 src/zeroconf/_services/browser.pxd diff --git a/build_ext.py b/build_ext.py index b9deeecb5..870c8058e 100644 --- a/build_ext.py +++ b/build_ext.py @@ -32,6 +32,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_handlers/answers.py", "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_handlers/query_handler.py", + "src/zeroconf/_services/browser.py", "src/zeroconf/_services/info.py", "src/zeroconf/_services/registry.py", "src/zeroconf/_updates.py", diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd new file mode 100644 index 000000000..f01645ee9 --- /dev/null +++ b/src/zeroconf/_services/browser.pxd @@ -0,0 +1,74 @@ + +import cython + +from .._cache cimport DNSCache +from .._protocol.outgoing cimport DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from .._updates cimport RecordUpdateListener +from .._utils.time cimport current_time_millis, millis_to_seconds + + +cdef object TYPE_CHECKING +cdef object cached_possible_types +cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT +cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED + +cdef class _DNSPointerOutgoingBucket: + + cdef public object now + cdef public DNSOutgoing out + cdef public cython.uint bytes + + cpdef add(self, cython.uint max_compressed_size, DNSQuestion question, cython.set answers) + + +@cython.locals(answer=DNSPointer) +cdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) + +cdef class QueryScheduler: + + cdef cython.set _types + cdef cython.dict _next_time + cdef object _first_random_delay_interval + cdef cython.dict _delay + + cpdef millis_to_wait(self, object now) + + cpdef reschedule_type(self, object type_, object next_time) + + cpdef process_ready_types(self, object now) + +cdef class _ServiceBrowserBase(RecordUpdateListener): + + cdef public cython.set types + cdef public object zc + cdef object _loop + cdef public object addr + cdef public object port + cdef public object multicast + cdef public object question_type + cdef public cython.dict _pending_handlers + cdef public object _service_state_changed + cdef public QueryScheduler query_scheduler + cdef public bint done + cdef public object _first_request + cdef public object _next_send_timer + cdef public object _query_sender_task + + cpdef _generate_ready_queries(self, object first_request, object now) + + cpdef _enqueue_callback(self, object state_change, object type_, object name) + + @cython.locals(record=DNSRecord, cache=DNSCache, service=DNSRecord) + cpdef async_update_records(self, object zc, cython.float now, cython.list records) + + cpdef _names_matching_types(self, object types) + + cpdef reschedule_type(self, object type_, object now, object next_time) + + cpdef _fire_service_state_changed_event(self, cython.tuple event) + + cpdef _async_send_ready_queries_schedule_next(self) + + cpdef _async_schedule_next(self, object now) + + cpdef _async_send_ready_queries(self, object now) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index de1769bcd..d559109c5 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -78,9 +78,17 @@ ServiceStateChange.Updated: "update_service", } +SERVICE_STATE_CHANGE_ADDED = ServiceStateChange.Added +SERVICE_STATE_CHANGE_REMOVED = ServiceStateChange.Removed +SERVICE_STATE_CHANGE_UPDATED = ServiceStateChange.Updated + if TYPE_CHECKING: from .._core import Zeroconf +float_ = float +int_ = int +bool_ = bool +str_ = str _QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] @@ -96,7 +104,7 @@ def __init__(self, now: float, multicast: bool) -> None: self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=multicast) self.bytes = 0 - def add(self, max_compressed_size: int, question: DNSQuestion, answers: Set[DNSPointer]) -> None: + def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer]) -> None: """Add a new set of questions and known answers to the outgoing.""" self.out.add_question(question) for answer in answers: @@ -105,7 +113,7 @@ def add(self, max_compressed_size: int, question: DNSQuestion, answers: Set[DNSP def _group_ptr_queries_with_known_answers( - now: float, multicast: bool, question_with_known_answers: _QuestionWithKnownAnswers + now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers ) -> List[DNSOutgoing]: """Aggregate queries so that as many known answers as possible fit in the same packet without having known answers spill over into the next packet unless the @@ -205,7 +213,7 @@ class QueryScheduler: """ - __slots__ = ('_schedule_changed_event', '_types', '_next_time', '_first_random_delay_interval', '_delay') + __slots__ = ('_types', '_next_time', '_first_random_delay_interval', '_delay') def __init__( self, @@ -213,18 +221,16 @@ def __init__( delay: int, first_random_delay_interval: Tuple[int, int], ) -> None: - self._schedule_changed_event: Optional[asyncio.Event] = None self._types = types self._next_time: Dict[str, float] = {} self._first_random_delay_interval = first_random_delay_interval self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self._types} - def start(self, now: float) -> None: + def start(self, now: float_) -> None: """Start the scheduler.""" - self._schedule_changed_event = asyncio.Event() self._generate_first_next_time(now) - def _generate_first_next_time(self, now: float) -> None: + def _generate_first_next_time(self, now: float_) -> None: """Generate the initial next query times. https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 @@ -238,20 +244,20 @@ def _generate_first_next_time(self, now: float) -> None: next_time = now + delay self._next_time = {check_type_: next_time for check_type_ in self._types} - def millis_to_wait(self, now: float) -> float: + def millis_to_wait(self, now: float_) -> float: """Returns the number of milliseconds to wait for the next event.""" # Wait for the type has the smallest next time next_time = min(self._next_time.values()) return 0 if next_time <= now else next_time - now - def reschedule_type(self, type_: str, next_time: float) -> bool: + def reschedule_type(self, type_: str_, next_time: float_) -> bool: """Reschedule the query for a type to happen sooner.""" if next_time >= self._next_time[type_]: return False self._next_time[type_] = next_time return True - def process_ready_types(self, now: float) -> List[str]: + def process_ready_types(self, now: float_) -> List[str]: """Generate a list of ready types that is due and schedule the next time.""" if self.millis_to_wait(now): return [] @@ -275,6 +281,7 @@ class _ServiceBrowserBase(RecordUpdateListener): __slots__ = ( 'types', 'zc', + '_loop', 'addr', 'port', 'multicast', @@ -322,6 +329,8 @@ def __init__( # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) self.zc = zc + assert zc.loop is not None + self._loop = zc.loop self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) @@ -370,23 +379,23 @@ def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: def _enqueue_callback( self, state_change: ServiceStateChange, - type_: str, - name: str, + type_: str_, + name: str_, ) -> None: # Code to ensure we only do a single update message # Precedence is; Added, Remove, Update key = (name, type_) if ( - state_change is ServiceStateChange.Added + state_change is SERVICE_STATE_CHANGE_ADDED or ( - state_change is ServiceStateChange.Removed - and self._pending_handlers.get(key) != ServiceStateChange.Added + state_change is SERVICE_STATE_CHANGE_REMOVED + and self._pending_handlers.get(key) != SERVICE_STATE_CHANGE_ADDED ) - or (state_change is ServiceStateChange.Updated and key not in self._pending_handlers) + or (state_change is SERVICE_STATE_CHANGE_UPDATED and key not in self._pending_handlers) ): self._pending_handlers[key] = state_change - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. @@ -404,9 +413,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU record = cast(DNSPointer, record) for type_ in self.types.intersection(cached_possible_types(record.name)): if old_record is None: - self._enqueue_callback(ServiceStateChange.Added, type_, record.alias) + self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, record.alias) elif record.is_expired(now): - self._enqueue_callback(ServiceStateChange.Removed, type_, record.alias) + self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, record.alias) else: expire_time = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) self.reschedule_type(type_, now, expire_time) @@ -417,15 +426,16 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[RecordU continue if record_type in _ADDRESS_RECORD_TYPES: + cache = self.zc.cache # Iterate through the DNSCache and callback any services that use this address for type_, name in self._names_matching_types( - {service.name for service in self.zc.cache.async_entries_with_server(record.name)} + {service.name for service in cache.async_entries_with_server(record.name)} ): - self._enqueue_callback(ServiceStateChange.Updated, type_, name) + self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) continue for type_, name in self._names_matching_types((record.name,)): - self._enqueue_callback(ServiceStateChange.Updated, type_, name) + self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) @abstractmethod def async_update_records_complete(self) -> None: @@ -460,7 +470,7 @@ def _async_cancel(self) -> None: assert self._query_sender_task is not None, "Attempted to cancel a browser that was not started" self._query_sender_task.cancel() - def _generate_ready_queries(self, first_request: bool, now: float) -> List[DNSOutgoing]: + def _generate_ready_queries(self, first_request: bool_, now: float_) -> List[DNSOutgoing]: """Generate the service browser query for any type that is due.""" ready_types = self.query_scheduler.process_ready_types(now) if not ready_types: @@ -485,7 +495,7 @@ def _cancel_send_timer(self) -> None: self._next_send_timer.cancel() self._next_send_timer = None - def reschedule_type(self, type_: str, now: float, next_time: float) -> None: + def reschedule_type(self, type_: str_, now: float_, next_time: float_) -> None: """Reschedule a type to be refreshed in the future.""" if self.query_scheduler.reschedule_type(type_, next_time): # We need to send the queries before rescheduling the next one @@ -496,7 +506,7 @@ def reschedule_type(self, type_: str, now: float, next_time: float) -> None: self._cancel_send_timer() self._async_schedule_next(now) - def _async_send_ready_queries(self, now: float) -> None: + def _async_send_ready_queries(self, now: float_) -> None: """Send any ready queries.""" outs = self._generate_ready_queries(self._first_request, now) if outs: @@ -512,11 +522,10 @@ def _async_send_ready_queries_schedule_next(self) -> None: self._async_send_ready_queries(now) self._async_schedule_next(now) - def _async_schedule_next(self, now: float) -> None: + def _async_schedule_next(self, now: float_) -> None: """Scheule the next time.""" - assert self.zc.loop is not None delay = millis_to_seconds(self.query_scheduler.millis_to_wait(now)) - self._next_send_timer = self.zc.loop.call_later(delay, self._async_send_ready_queries_schedule_next) + self._next_send_timer = self._loop.call_later(delay, self._async_send_ready_queries_schedule_next) class ServiceBrowser(_ServiceBrowserBase, threading.Thread): diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 860cc9bab..33834e413 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -56,9 +56,9 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public cython.set _get_address_and_nsec_records_cache @cython.locals( - cache=DNSCache + cache=DNSCache, ) - cpdef async_update_records(self, object zc, object now, cython.list records) + cpdef async_update_records(self, object zc, cython.float now, cython.list records) @cython.locals( cache=DNSCache @@ -73,7 +73,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef _get_ip_addresses_from_cache_lifo(self, object zc, object now, object type) - cdef _process_record_threadsafe(self, object zc, DNSRecord record, object now) + cdef _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) @cython.locals( cache=DNSCache diff --git a/src/zeroconf/_updates.pxd b/src/zeroconf/_updates.pxd index 6309537c3..23edf6432 100644 --- a/src/zeroconf/_updates.pxd +++ b/src/zeroconf/_updates.pxd @@ -4,6 +4,6 @@ import cython cdef class RecordUpdateListener: - cpdef async_update_records(self, object zc, object now, cython.list records) + cpdef async_update_records(self, object zc, cython.float now, cython.list records) cpdef async_update_records_complete(self) From 1b3225ffc63bd304250695cd8f256c579b941878 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 14 Sep 2023 00:46:22 +0000 Subject: [PATCH 0968/1433] 0.109.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baadc236..0bfdae68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.109.0 (2023-09-14) + +### Feature + +* Speed up ServiceBrowsers with a cython pxd ([#1270](https://github.com/python-zeroconf/python-zeroconf/issues/1270)) ([`4837876`](https://github.com/python-zeroconf/python-zeroconf/commit/48378769c3887b5746ca00de30067a4c0851765c)) + ## v0.108.0 (2023-09-11) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 56067c912..a49d7e37a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.108.0" +version = "0.109.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6af1ddae5..a5a9c10ba 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.108.0' +__version__ = '0.109.0' __license__ = 'LGPL' From 22c433ddaea3049ac49933325ba938fd87a529c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 20:06:21 -0500 Subject: [PATCH 0969/1433] feat: small speed ups to ServiceBrowser (#1271) --- src/zeroconf/_services/browser.pxd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index f01645ee9..540b20ca4 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -72,3 +72,5 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _async_schedule_next(self, object now) cpdef _async_send_ready_queries(self, object now) + + cpdef _cancel_send_timer(self) From 549a104af6375ed5d371cd425e29760ed4097fbd Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 14 Sep 2023 01:19:09 +0000 Subject: [PATCH 0970/1433] 0.110.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfdae68a..a98341bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.110.0 (2023-09-14) + +### Feature + +* Small speed ups to ServiceBrowser ([#1271](https://github.com/python-zeroconf/python-zeroconf/issues/1271)) ([`22c433d`](https://github.com/python-zeroconf/python-zeroconf/commit/22c433ddaea3049ac49933325ba938fd87a529c0)) + ## v0.109.0 (2023-09-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index a49d7e37a..e543ed4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.109.0" +version = "0.110.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a5a9c10ba..541fcd3d2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.109.0' +__version__ = '0.110.0' __license__ = 'LGPL' From d24722bfa4201d48ab482d35b0ef004f070ada80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 22:26:08 -0500 Subject: [PATCH 0971/1433] feat: speed up question and answer internals (#1272) --- src/zeroconf/_dns.pxd | 8 ++--- src/zeroconf/_dns.py | 6 ++-- src/zeroconf/_handlers/answers.pxd | 17 +++++++++++ src/zeroconf/_handlers/answers.py | 37 +++++++++++++++++------- src/zeroconf/_handlers/query_handler.pxd | 17 ++++++----- 5 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index afcb1985c..2e9b778e9 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -22,8 +22,8 @@ cdef object current_time_millis cdef class DNSEntry: - cdef public object key - cdef public object name + cdef public str key + cdef public str name cdef public object type cdef public object class_ cdef public object unique @@ -84,8 +84,8 @@ cdef class DNSHinfo(DNSRecord): cdef class DNSPointer(DNSRecord): cdef public cython.int _hash - cdef public object alias - cdef public object alias_key + cdef public str alias + cdef public str alias_key cdef _eq(self, DNSPointer other) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 4c015eb31..b546d2734 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -67,9 +67,9 @@ class DNSEntry: __slots__ = ('key', 'name', 'type', 'class_', 'unique') - def __init__(self, name: str, type_: int, class_: int) -> None: - self.key = name.lower() + def __init__(self, name: str, type_: _int, class_: _int) -> None: self.name = name + self.key = name.lower() self.type = type_ self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 @@ -328,7 +328,7 @@ def __init__( ) -> None: super().__init__(name, type_, class_, ttl, created) self.alias = alias - self.alias_key = self.alias.lower() + self.alias_key = alias.lower() self._hash = hash((self.key, type_, self.class_, self.alias_key)) @property diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index 91e2375d1..6a0f0e3d5 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -5,6 +5,23 @@ from .._dns cimport DNSRecord from .._protocol.outgoing cimport DNSOutgoing +cdef class QuestionAnswers: + + cdef public object ucast + cdef public object mcast_now + cdef public object mcast_aggregate + cdef public object mcast_aggregate_last_second + + +cdef class AnswerGroup: + + cdef public object send_after + cdef public object send_before + cdef public object answers + + + + cdef object _FLAGS_QR_RESPONSE_AA cdef object NAME_GETTER diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index 009bedbc5..6ba502ac9 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -21,7 +21,7 @@ """ from operator import attrgetter -from typing import Dict, List, NamedTuple, Set +from typing import Dict, List, Set from .._dns import DNSQuestion, DNSRecord from .._protocol.outgoing import DNSOutgoing @@ -38,20 +38,37 @@ _FLAGS_QR_RESPONSE_AA = _FLAGS_QR_RESPONSE | _FLAGS_AA +float_ = float -class QuestionAnswers(NamedTuple): - ucast: _AnswerWithAdditionalsType - mcast_now: _AnswerWithAdditionalsType - mcast_aggregate: _AnswerWithAdditionalsType - mcast_aggregate_last_second: _AnswerWithAdditionalsType +class QuestionAnswers: + """A group of answers to a question.""" -class AnswerGroup(NamedTuple): + __slots__ = ('ucast', 'mcast_now', 'mcast_aggregate', 'mcast_aggregate_last_second') + + def __init__( + self, + ucast: _AnswerWithAdditionalsType, + mcast_now: _AnswerWithAdditionalsType, + mcast_aggregate: _AnswerWithAdditionalsType, + mcast_aggregate_last_second: _AnswerWithAdditionalsType, + ) -> None: + """Initialize a QuestionAnswers.""" + self.ucast = ucast + self.mcast_now = mcast_now + self.mcast_aggregate = mcast_aggregate + self.mcast_aggregate_last_second = mcast_aggregate_last_second + + +class AnswerGroup: """A group of answers scheduled to be sent at the same time.""" - send_after: float # Must be sent after this time - send_before: float # Must be sent before this time - answers: _AnswerWithAdditionalsType + __slots__ = ('send_after', 'send_before', 'answers') + + def __init__(self, send_after: float_, send_before: float_, answers: _AnswerWithAdditionalsType) -> None: + self.send_after = send_after # Must be sent after this time + self.send_before = send_before # Must be sent before this time + self.answers = answers def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 1f1e4da87..31261a698 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -2,14 +2,15 @@ import cython from .._cache cimport DNSCache -from .._dns cimport DNSPointer, DNSQuestion, DNSRecord, DNSRRSet +from .._dns cimport DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet from .._history cimport QuestionHistory from .._protocol.incoming cimport DNSIncoming from .._services.info cimport ServiceInfo from .._services.registry cimport ServiceRegistry +from .answers cimport QuestionAnswers -cdef object TYPE_CHECKING, QuestionAnswers +cdef object TYPE_CHECKING cdef cython.uint _ONE_SECOND, _TYPE_PTR, _TYPE_ANY, _TYPE_A, _TYPE_AAAA, _TYPE_SRV, _TYPE_TXT cdef str _SERVICE_TYPE_ENUMERATION_NAME cdef cython.set _RESPOND_IMMEDIATE_TYPES @@ -19,7 +20,7 @@ cdef object _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL cdef class _QueryResponse: - cdef object _is_probe + cdef bint _is_probe cdef DNSIncoming _msg cdef float _now cdef DNSCache _cache @@ -29,17 +30,19 @@ cdef class _QueryResponse: cdef cython.set _mcast_aggregate cdef cython.set _mcast_aggregate_last_second + @cython.locals(record=DNSRecord) cpdef add_qu_question_response(self, cython.dict answers) cpdef add_ucast_question_response(self, cython.dict answers) + @cython.locals(answer=DNSRecord) cpdef add_mcast_question_response(self, cython.dict answers) @cython.locals(maybe_entry=DNSRecord) - cpdef _has_mcast_within_one_quarter_ttl(self, DNSRecord record) + cdef bint _has_mcast_within_one_quarter_ttl(self, DNSRecord record) @cython.locals(maybe_entry=DNSRecord) - cpdef _has_mcast_record_in_last_second(self, DNSRecord record) + cdef bint _has_mcast_record_in_last_second(self, DNSRecord record) cpdef answers(self) @@ -55,8 +58,8 @@ cdef class QueryHandler: @cython.locals(service=ServiceInfo) cdef _add_pointer_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers) - @cython.locals(service=ServiceInfo) - cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) + @cython.locals(service=ServiceInfo, dns_address=DNSAddress) + cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, object type_) @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) cdef cython.dict _answer_question(self, DNSQuestion question, DNSRRSet known_answers) From 2734db9826c9d9ebf1412f244cd6d40b72545f4f Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 14 Sep 2023 03:34:30 +0000 Subject: [PATCH 0972/1433] 0.111.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a98341bba..50b91d312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.111.0 (2023-09-14) + +### Feature + +* Speed up question and answer internals ([#1272](https://github.com/python-zeroconf/python-zeroconf/issues/1272)) ([`d24722b`](https://github.com/python-zeroconf/python-zeroconf/commit/d24722bfa4201d48ab482d35b0ef004f070ada80)) + ## v0.110.0 (2023-09-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index e543ed4ae..9953b56eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.110.0" +version = "0.111.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 541fcd3d2..98bfe4eef 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.110.0' +__version__ = '0.111.0' __license__ = 'LGPL' From 0c88ecf5ef6b9b256f991e7a630048de640999a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 09:32:08 -0500 Subject: [PATCH 0973/1433] feat: improve AsyncServiceBrowser performance (#1273) --- src/zeroconf/_services/browser.pxd | 2 ++ src/zeroconf/_services/browser.py | 10 +++++++--- src/zeroconf/asyncio.py | 11 ----------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 540b20ca4..1006ee3c3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -74,3 +74,5 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _async_send_ready_queries(self, object now) cpdef _cancel_send_timer(self) + + cpdef async_update_records_complete(self) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index d559109c5..cb611d1a5 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -25,7 +25,6 @@ import random import threading import warnings -from abc import abstractmethod from types import TracebackType # noqa # used in type hints from typing import ( TYPE_CHECKING, @@ -437,14 +436,18 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record for type_, name in self._names_matching_types((record.name,)): self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) - @abstractmethod def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. At this point the cache will have the new records. This method will be run in the event loop. + + This method is expected to be overridden by subclasses. """ + for pending in self._pending_handlers.items(): + self._fire_service_state_changed_event(pending) + self._pending_handlers.clear() def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: """Fire a service state changed event. @@ -454,7 +457,8 @@ def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], Servic When running with AsyncServiceBrowser, this will happen in the event loop. """ - name_type, state_change = event + name_type = event[0] + state_change = event[1] self._service_state_changed.fire( zeroconf=self.zc, service_type=name_type[1], diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 5aaee35fb..cfe3693e8 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -82,17 +82,6 @@ async def async_cancel(self) -> None: """Cancel the browser.""" self._async_cancel() - def async_update_records_complete(self) -> None: - """Called when a record update has completed for all handlers. - - At this point the cache will have the new records. - - This method will be run in the event loop. - """ - for pending in self._pending_handlers.items(): - self._fire_service_state_changed_event(pending) - self._pending_handlers.clear() - async def __aenter__(self) -> 'AsyncServiceBrowser': return self From 248b5062b29e3ef80a549b3180f2b85fc2265f1d Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 14 Sep 2023 14:41:27 +0000 Subject: [PATCH 0974/1433] 0.112.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b91d312..28a4d4dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.112.0 (2023-09-14) + +### Feature + +* Improve AsyncServiceBrowser performance ([#1273](https://github.com/python-zeroconf/python-zeroconf/issues/1273)) ([`0c88ecf`](https://github.com/python-zeroconf/python-zeroconf/commit/0c88ecf5ef6b9b256f991e7a630048de640999a6)) + ## v0.111.0 (2023-09-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 9953b56eb..b8c1918c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.111.0" +version = "0.112.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 98bfe4eef..77f54c363 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.111.0' +__version__ = '0.112.0' __license__ = 'LGPL' From 6257d49952e02107f800f4ad4894716508edfcda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Sep 2023 13:37:47 +0200 Subject: [PATCH 0975/1433] feat: improve performance of loading records from cache in ServiceInfo (#1274) --- src/zeroconf/_dns.pxd | 4 +-- src/zeroconf/_services/info.pxd | 25 +++++++++--------- src/zeroconf/_services/info.py | 46 +++++++++++++++++++++------------ 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 2e9b778e9..fa73d692e 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -106,8 +106,8 @@ cdef class DNSService(DNSRecord): cdef public object priority cdef public object weight cdef public object port - cdef public object server - cdef public object server_key + cdef public str server + cdef public str server_key cdef _eq(self, DNSService other) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 33834e413..de7eb97bd 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -2,13 +2,14 @@ import cython from .._cache cimport DNSCache -from .._dns cimport DNSNsec, DNSPointer, DNSRecord, DNSService, DNSText +from .._dns cimport DNSAddress, DNSNsec, DNSPointer, DNSRecord, DNSService, DNSText from .._protocol.outgoing cimport DNSOutgoing from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis cdef object _resolve_all_futures_to_none +cdef object _cached_ip_addresses_wrapper cdef object _TYPE_SRV cdef object _TYPE_TXT @@ -55,15 +56,11 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public cython.list _dns_address_cache cdef public cython.set _get_address_and_nsec_records_cache - @cython.locals( - cache=DNSCache, - ) + @cython.locals(cache=DNSCache) cpdef async_update_records(self, object zc, cython.float now, cython.list records) - @cython.locals( - cache=DNSCache - ) - cpdef _load_from_cache(self, object zc, object now) + @cython.locals(cache=DNSCache) + cpdef _load_from_cache(self, object zc, cython.float now) cdef _unpack_text_into_properties(self) @@ -71,13 +68,17 @@ cdef class ServiceInfo(RecordUpdateListener): cdef _set_text(self, cython.bytes text) - cdef _get_ip_addresses_from_cache_lifo(self, object zc, object now, object type) - - cdef _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) + @cython.locals(record=DNSAddress) + cdef _get_ip_addresses_from_cache_lifo(self, object zc, cython.float now, object type) @cython.locals( - cache=DNSCache + dns_service_record=DNSService, + dns_text_record=DNSText, + dns_address_record=DNSAddress ) + cdef _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) + + @cython.locals(cache=DNSCache) cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) cdef _set_ipv4_addresses_from_cache(self, object zc, object now) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 352c9b8ef..0600d5d34 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -107,6 +107,9 @@ def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4 return None +_cached_ip_addresses_wrapper = _cached_ip_addresses + + class ServiceInfo(RecordUpdateListener): """Service information. @@ -197,7 +200,7 @@ def __init__( self.host_ttl = host_ttl self.other_ttl = other_ttl self.interface_index = interface_index - self._new_records_futures: Set[asyncio.Future] = set() + self._new_records_futures: Optional[Set[asyncio.Future]] = None self._dns_address_cache: Optional[List[DNSAddress]] = None self._dns_pointer_cache: Optional[DNSPointer] = None self._dns_service_cache: Optional[DNSService] = None @@ -240,7 +243,7 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - addr = _cached_ip_addresses(address) + addr = _cached_ip_addresses_wrapper(address) if addr is None: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" @@ -272,6 +275,8 @@ def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: """Calling task waits for a given number of milliseconds or until notified.""" + if not self._new_records_futures: + self._new_records_futures = set() await wait_for_future_set_or_timeout( loop or asyncio.get_running_loop(), self._new_records_futures, timeout ) @@ -409,7 +414,7 @@ def _get_ip_addresses_from_cache_lifo( for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue - ip_addr = _cached_ip_addresses(record.address) + ip_addr = _cached_ip_addresses_wrapper(record.address) if ip_addr is not None: address_list.append(ip_addr) address_list.reverse() # Reverse to get LIFO order @@ -455,12 +460,17 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo record_key = record.key record_type = type(record) - if record_key == self.server_key and record_type is DNSAddress: + if record_type is DNSAddress and record_key == self.server_key: + dns_address_record = record if TYPE_CHECKING: - assert isinstance(record, DNSAddress) - ip_addr = _cached_ip_addresses(record.address) + assert isinstance(dns_address_record, DNSAddress) + ip_addr = _cached_ip_addresses_wrapper(dns_address_record.address) if ip_addr is None: - log.warning("Encountered invalid address while processing %s: %s", record, record.address) + log.warning( + "Encountered invalid address while processing %s: %s", + dns_address_record, + dns_address_record.address, + ) return False if ip_addr.version == 4: @@ -492,22 +502,24 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo return False if record_type is DNSText: + dns_text_record = record if TYPE_CHECKING: - assert isinstance(record, DNSText) - self._set_text(record.text) + assert isinstance(dns_text_record, DNSText) + self._set_text(dns_text_record.text) return True if record_type is DNSService: + dns_service_record = record if TYPE_CHECKING: - assert isinstance(record, DNSService) + assert isinstance(dns_service_record, DNSService) old_server_key = self.server_key - self._name = record.name - self.key = record.key - self.server = record.server - self.server_key = record.server_key - self.port = record.port - self.weight = record.weight - self.priority = record.priority + self._name = dns_service_record.name + self.key = dns_service_record.key + self.server = dns_service_record.server + self.server_key = dns_service_record.server_key + self.port = dns_service_record.port + self.weight = dns_service_record.weight + self.priority = dns_service_record.priority if old_server_key != self.server_key: self._set_ipv4_addresses_from_cache(zc, now) self._set_ipv6_addresses_from_cache(zc, now) From aa8fd1ace1ae9c018d3aada49e311e0c799b61c1 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 24 Sep 2023 11:46:00 +0000 Subject: [PATCH 0976/1433] 0.113.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a4d4dc0..50a79d9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.113.0 (2023-09-24) + +### Feature + +* Improve performance of loading records from cache in ServiceInfo ([#1274](https://github.com/python-zeroconf/python-zeroconf/issues/1274)) ([`6257d49`](https://github.com/python-zeroconf/python-zeroconf/commit/6257d49952e02107f800f4ad4894716508edfcda)) + ## v0.112.0 (2023-09-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index b8c1918c6..b8f4d7ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.112.0" +version = "0.113.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 77f54c363..f9e6799f8 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.112.0' +__version__ = '0.113.0' __license__ = 'LGPL' From 3c6b18cdf4c94773ad6f4497df98feb337939ee9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 14:33:47 -0500 Subject: [PATCH 0977/1433] feat: speed up responding to queries (#1275) --- src/zeroconf/_dns.pxd | 2 +- src/zeroconf/_dns.py | 25 +++++----- src/zeroconf/_handlers/query_handler.pxd | 14 +++--- src/zeroconf/_handlers/query_handler.py | 43 ++++++++++------- src/zeroconf/_handlers/record_manager.py | 2 +- src/zeroconf/_protocol/incoming.pxd | 4 ++ src/zeroconf/_protocol/incoming.py | 4 +- tests/test_asyncio.py | 2 +- tests/test_dns.py | 4 +- tests/test_handlers.py | 14 +++--- tests/test_protocol.py | 60 ++++++++++++------------ 11 files changed, 93 insertions(+), 81 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index fa73d692e..ccdcc34fa 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -125,7 +125,7 @@ cdef class DNSNsec(DNSRecord): cdef class DNSRRSet: - cdef cython.list _record_sets + cdef cython.list _records cdef cython.dict _lookup @cython.locals(other=DNSRecord) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index b546d2734..0b43f410a 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -174,7 +174,7 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use def suppressed_by(self, msg: 'DNSIncoming') -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" - answers = msg.answers + answers = msg.answers() for record in answers: if self._suppressed_by_answer(record): return True @@ -521,15 +521,15 @@ def __repr__(self) -> str: class DNSRRSet: """A set of dns records with a lookup to get the ttl.""" - __slots__ = ('_record_sets', '_lookup') + __slots__ = ('_records', '_lookup') - def __init__(self, record_sets: List[List[DNSRecord]]) -> None: + def __init__(self, records: List[DNSRecord]) -> None: """Create an RRset from records sets.""" - self._record_sets = record_sets - self._lookup: Optional[Dict[DNSRecord, float]] = None + self._records = records + self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None @property - def lookup(self) -> Dict[DNSRecord, float]: + def lookup(self) -> Dict[DNSRecord, DNSRecord]: """Return the lookup table.""" return self._get_lookup() @@ -537,21 +537,18 @@ def lookup_set(self) -> Set[DNSRecord]: """Return the lookup table as aset.""" return set(self._get_lookup()) - def _get_lookup(self) -> Dict[DNSRecord, float]: + def _get_lookup(self) -> Dict[DNSRecord, DNSRecord]: """Return the lookup table, building it if needed.""" if self._lookup is None: # Build the hash table so we can lookup the record ttl - self._lookup = {} - for record_sets in self._record_sets: - for record in record_sets: - self._lookup[record] = record.ttl + self._lookup = {record: record for record in self._records} return self._lookup def suppresses(self, record: _DNSRecord) -> bool: """Returns true if any answer in the rrset can suffice for the information held in this record.""" lookup = self._get_lookup() - other_ttl = lookup.get(record) - if other_ttl is None: + other = lookup.get(record) + if other is None: return False - return other_ttl > (record.ttl / 2) + return other.ttl > (record.ttl / 2) diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 31261a698..365e3a27b 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -21,7 +21,7 @@ cdef object _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL cdef class _QueryResponse: cdef bint _is_probe - cdef DNSIncoming _msg + cdef cython.list _questions cdef float _now cdef DNSCache _cache cdef cython.dict _additionals @@ -31,12 +31,12 @@ cdef class _QueryResponse: cdef cython.set _mcast_aggregate_last_second @cython.locals(record=DNSRecord) - cpdef add_qu_question_response(self, cython.dict answers) + cdef add_qu_question_response(self, cython.dict answers) - cpdef add_ucast_question_response(self, cython.dict answers) + cdef add_ucast_question_response(self, cython.dict answers) - @cython.locals(answer=DNSRecord) - cpdef add_mcast_question_response(self, cython.dict answers) + @cython.locals(answer=DNSRecord, question=DNSQuestion) + cdef add_mcast_question_response(self, cython.dict answers) @cython.locals(maybe_entry=DNSRecord) cdef bint _has_mcast_within_one_quarter_ttl(self, DNSRecord record) @@ -44,7 +44,7 @@ cdef class _QueryResponse: @cython.locals(maybe_entry=DNSRecord) cdef bint _has_mcast_record_in_last_second(self, DNSRecord record) - cpdef answers(self) + cdef QuestionAnswers answers(self) cdef class QueryHandler: @@ -70,5 +70,7 @@ cdef class QueryHandler: answer_set=cython.dict, known_answers=DNSRRSet, known_answers_set=cython.set, + is_probe=object, + now=object ) cpdef async_response(self, cython.list msgs, cython.bint unicast_source) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index f42430215..776d6a3f3 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -55,7 +55,7 @@ class _QueryResponse: __slots__ = ( "_is_probe", - "_msg", + "_questions", "_now", "_cache", "_additionals", @@ -65,15 +65,11 @@ class _QueryResponse: "_mcast_aggregate_last_second", ) - def __init__(self, cache: DNSCache, msgs: List[DNSIncoming]) -> None: + def __init__(self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float) -> None: """Build a query response.""" - self._is_probe = False - for msg in msgs: - if msg.is_probe: - self._is_probe = True - break - self._msg = msgs[0] - self._now = self._msg.now + self._is_probe = is_probe + self._questions = questions + self._now = now self._cache = cache self._additionals: _AnswerWithAdditionalsType = {} self._ucast: Set[DNSRecord] = set() @@ -107,10 +103,15 @@ def add_mcast_question_response(self, answers: _AnswerWithAdditionalsType) -> No if self._has_mcast_record_in_last_second(answer): self._mcast_aggregate_last_second.add(answer) - elif len(self._msg.questions) == 1 and self._msg.questions[0].type in _RESPOND_IMMEDIATE_TYPES: - self._mcast_now.add(answer) - else: - self._mcast_aggregate.add(answer) + continue + + if len(self._questions) == 1: + question = self._questions[0] + if question.type in _RESPOND_IMMEDIATE_TYPES: + self._mcast_now.add(answer) + continue + + self._mcast_aggregate.add(answer) def answers( self, @@ -262,8 +263,18 @@ def async_response( # pylint: disable=unused-argument This function must be run in the event loop as it is not threadsafe. """ - known_answers = DNSRRSet([msg.answers for msg in msgs if not msg.is_probe]) - query_res = _QueryResponse(self.cache, msgs) + answers: List[DNSRecord] = [] + is_probe = False + msg = msgs[0] + questions = msg.questions + now = msg.now + for msg in msgs: + if not msg.is_probe(): + answers.extend(msg.answers()) + else: + is_probe = True + known_answers = DNSRRSet(answers) + query_res = _QueryResponse(self.cache, questions, is_probe, now) known_answers_set: Optional[Set[DNSRecord]] = None for msg in msgs: @@ -271,7 +282,7 @@ def async_response( # pylint: disable=unused-argument if not question.unique: # unique and unicast are the same flag if not known_answers_set: # pragma: no branch known_answers_set = known_answers.lookup_set() - self.question_history.add_question_at_time(question, msg.now, known_answers_set) + self.question_history.add_question_at_time(question, now, known_answers_set) answer_set = self._answer_question(question, known_answers) if not ucast_source and question.unique: # unique and unicast are the same flag query_res.add_qu_question_response(answer_set) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 396bad45c..63572c1ee 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -87,7 +87,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: now_float = now unique_types: Set[Tuple[str, int, int]] = set() cache = self.cache - answers = msg.answers + answers = msg.answers() for record in answers: # Protect zeroconf from records that can cause denial of service. diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index ebd09a0ea..37fc91e78 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -72,6 +72,10 @@ cdef class DNSIncoming: cpdef is_query(self) + cpdef is_probe(self) + + cpdef answers(self) + cpdef is_response(self) @cython.locals( diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 87d25816f..5838657a3 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -172,7 +172,6 @@ def _log_exception_debug(cls, *logger_data: Any) -> None: log_exc_info = True log.debug(*(logger_data or ['Exception occurred']), exc_info=log_exc_info) - @property def answers(self) -> List[DNSRecord]: """Answers in the packet.""" if not self._did_read_others: @@ -187,7 +186,6 @@ def answers(self) -> List[DNSRecord]: ) return self._answers - @property def is_probe(self) -> bool: """Returns true if this is a probe.""" return self.num_authorities > 0 @@ -203,7 +201,7 @@ def __repr__(self) -> str: 'n_auth=%s' % self.num_authorities, 'n_add=%s' % self.num_additionals, 'questions=%s' % self.questions, - 'answers=%s' % self.answers, + 'answers=%s' % self.answers(), ] ) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 18e8c8e09..d77e7e832 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -997,7 +997,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" pout = DNSIncoming(out.packets()[0]) nonlocal nbr_answers - for answer in pout.answers: + for answer in pout.answers(): nbr_answers += 1 if not answer.ttl > expected_ttl / 2: unexpected_ttl.set() diff --git a/tests/test_dns.py b/tests/test_dns.py index b82f5d812..08f805f03 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -392,7 +392,7 @@ def test_rrset_does_not_consider_ttl(): longaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 100, b'same') shortaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 10, b'same') - rrset = DNSRRSet([[longarec, shortaaaarec]]) + rrset = DNSRRSet([longarec, shortaaaarec]) assert rrset.suppresses(longarec) assert rrset.suppresses(shortarec) @@ -404,7 +404,7 @@ def test_rrset_does_not_consider_ttl(): mediumarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 60, b'same') shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') - rrset2 = DNSRRSet([[mediumarec]]) + rrset2 = DNSRRSet([mediumarec]) assert not rrset2.suppresses(verylongarec) assert rrset2.suppresses(longarec) assert rrset2.suppresses(mediumarec) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 6266ad91c..11b58292a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1425,8 +1425,8 @@ async def test_response_aggregation_timings(run_isolated): outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.record_manager.async_updates_from_response(incoming) - assert info.dns_pointer() in incoming.answers - assert info2.dns_pointer() in incoming.answers + assert info.dns_pointer() in incoming.answers() + assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() protocol.datagram_received(query3.packets()[0], ('127.0.0.1', const._MDNS_PORT)) @@ -1439,7 +1439,7 @@ async def test_response_aggregation_timings(run_isolated): outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.record_manager.async_updates_from_response(incoming) - assert info3.dns_pointer() in incoming.answers + assert info3.dns_pointer() in incoming.answers() send_mock.reset_mock() # Because the response was sent in the last second we need to make @@ -1461,7 +1461,7 @@ async def test_response_aggregation_timings(run_isolated): assert len(calls) == 1 outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) - assert info.dns_pointer() in incoming.answers + assert info.dns_pointer() in incoming.answers() await aiozc.async_close() @@ -1501,7 +1501,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.record_manager.async_updates_from_response(incoming) - assert info2.dns_pointer() in incoming.answers + assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) @@ -1511,7 +1511,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.record_manager.async_updates_from_response(incoming) - assert info2.dns_pointer() in incoming.answers + assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) @@ -1534,7 +1534,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli outgoing = send_mock.call_args[0][0] incoming = r.DNSIncoming(outgoing.packets()[0]) zc.record_manager.async_updates_from_response(incoming) - assert info2.dns_pointer() in incoming.answers + assert info2.dns_pointer() in incoming.answers() @pytest.mark.asyncio diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 79f327559..a8593850f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -63,7 +63,7 @@ def test_parse_own_packet_nsec(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time(answer, 0) parsed = r.DNSIncoming(generated.packets()[0]) - assert answer in parsed.answers + assert answer in parsed.answers() # Types > 255 should be ignored answer_invalid_types = r.DNSNsec( @@ -77,7 +77,7 @@ def test_parse_own_packet_nsec(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time(answer_invalid_types, 0) parsed = r.DNSIncoming(generated.packets()[0]) - assert answer in parsed.answers + assert answer in parsed.answers() def test_parse_own_packet_response(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -96,7 +96,7 @@ def test_parse_own_packet_response(self): ) parsed = r.DNSIncoming(generated.packets()[0]) assert len(generated.answers) == 1 - assert len(generated.answers) == len(parsed.answers) + assert len(generated.answers) == len(parsed.answers()) def test_adding_empty_answer(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -119,7 +119,7 @@ def test_adding_empty_answer(self): ) parsed = r.DNSIncoming(generated.packets()[0]) assert len(generated.answers) == 1 - assert len(generated.answers) == len(parsed.answers) + assert len(generated.answers) == len(parsed.answers()) def test_adding_expired_answer(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -138,7 +138,7 @@ def test_adding_expired_answer(self): ) parsed = r.DNSIncoming(generated.packets()[0]) assert len(generated.answers) == 0 - assert len(generated.answers) == len(parsed.answers) + assert len(generated.answers) == len(parsed.answers()) def test_match_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -221,7 +221,7 @@ def test_dns_hinfo(self): generated = r.DNSOutgoing(0) generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) parsed = r.DNSIncoming(generated.packets()[0]) - answer = cast(r.DNSHinfo, parsed.answers[0]) + answer = cast(r.DNSHinfo, parsed.answers()[0]) assert answer.cpu == 'cpu' assert answer.os == 'os' @@ -276,15 +276,15 @@ def test_many_questions_with_many_known_answers(self): parsed1 = r.DNSIncoming(packets[0]) assert len(parsed1.questions) == 30 - assert len(parsed1.answers) == 88 + assert len(parsed1.answers()) == 88 assert parsed1.truncated parsed2 = r.DNSIncoming(packets[1]) assert len(parsed2.questions) == 0 - assert len(parsed2.answers) == 101 + assert len(parsed2.answers()) == 101 assert parsed2.truncated parsed3 = r.DNSIncoming(packets[2]) assert len(parsed3.questions) == 0 - assert len(parsed3.answers) == 11 + assert len(parsed3.answers()) == 11 assert not parsed3.truncated def test_massive_probe_packet_split(self): @@ -375,7 +375,7 @@ def test_only_one_answer_can_by_large(self): for packet in packets: parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 + assert len(parsed.answers()) == 1 def test_questions_do_not_end_up_every_packet(self): """Test that questions are not sent again when multiple packets are needed. @@ -413,11 +413,11 @@ def test_questions_do_not_end_up_every_packet(self): parsed1 = r.DNSIncoming(packets[0]) assert len(parsed1.questions) == 35 - assert len(parsed1.answers) == 33 + assert len(parsed1.answers()) == 33 parsed2 = r.DNSIncoming(packets[1]) assert len(parsed2.questions) == 0 - assert len(parsed2.answers) == 2 + assert len(parsed2.answers()) == 2 class PacketForm(unittest.TestCase): @@ -482,7 +482,7 @@ def test_incoming_unknown_type(self): generated.add_additional_answer(answer) packet = generated.packets()[0] parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 0 + assert len(parsed.answers()) == 0 assert parsed.is_query() != parsed.is_response() def test_incoming_circular_reference(self): @@ -505,7 +505,7 @@ def test_incoming_ipv6(self): generated.add_additional_answer(answer) packet = generated.packets()[0] parsed = r.DNSIncoming(packet) - record = parsed.answers[0] + record = parsed.answers()[0] assert isinstance(record, r.DNSAddress) assert record.address == packed @@ -662,7 +662,7 @@ def test_dns_compression_rollback_for_corruption(): incoming = r.DNSIncoming(packet) assert incoming.valid is True assert ( - len(incoming.answers) + len(incoming.answers()) == incoming.num_answers + incoming.num_authorities + incoming.num_additionals ) @@ -767,7 +767,7 @@ def test_parse_packet_with_nsec_record(): b"\x00\x00\x80\x00@" ) parsed = DNSIncoming(nsec_packet) - nsec_record = cast(r.DNSNsec, parsed.answers[3]) + nsec_record = cast(r.DNSNsec, parsed.answers()[3]) assert "nsec," in str(nsec_record) assert nsec_record.rdtypes == [16, 33] assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local." @@ -794,8 +794,8 @@ def test_records_same_packet_share_fate(): for packet in out.packets(): dnsin = DNSIncoming(packet) - first_time = dnsin.answers[0].created - for answer in dnsin.answers: + first_time = dnsin.answers()[0].created + for answer in dnsin.answers(): assert answer.created == first_time @@ -828,7 +828,7 @@ def test_dns_compression_all_invalid(caplog): ) parsed = r.DNSIncoming(packet, ("2.4.5.4", 5353)) assert len(parsed.questions) == 0 - assert len(parsed.answers) == 0 + assert len(parsed.answers()) == 0 assert " Unable to parse; skipping record" in caplog.text @@ -845,7 +845,7 @@ def test_invalid_next_name_ignored(): ) parsed = r.DNSIncoming(packet) assert len(parsed.questions) == 1 - assert len(parsed.answers) == 2 + assert len(parsed.answers()) == 2 def test_dns_compression_invalid_skips_record(): @@ -868,7 +868,7 @@ def test_dns_compression_invalid_skips_record(): 'eufy HomeBase2-2464._hap._tcp.local.', [const._TYPE_TXT, const._TYPE_SRV], ) - assert answer in parsed.answers + assert answer in parsed.answers() def test_dns_compression_points_forward(): @@ -893,7 +893,7 @@ def test_dns_compression_points_forward(): 'TV Beneden (2)._androidtvremote._tcp.local.', [const._TYPE_TXT, const._TYPE_SRV], ) - assert answer in parsed.answers + assert answer in parsed.answers() def test_dns_compression_points_to_itself(): @@ -904,7 +904,7 @@ def test_dns_compression_points_to_itself(): b"\x01\x00\x04\xc0\xa8\xd0\x06" ) parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 + assert len(parsed.answers()) == 1 def test_dns_compression_points_beyond_packet(): @@ -915,7 +915,7 @@ def test_dns_compression_points_beyond_packet(): b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' ) parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 + assert len(parsed.answers()) == 1 def test_dns_compression_generic_failure(caplog): @@ -926,7 +926,7 @@ def test_dns_compression_generic_failure(caplog): b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' ) parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353)) - assert len(parsed.answers) == 1 + assert len(parsed.answers()) == 1 assert "Received invalid packet from ('1.2.3.4', 5353)" in caplog.text @@ -946,7 +946,7 @@ def test_label_length_attack(): b'\x01\x00\x04\xc0\xa8\xd0\x06' ) parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 0 + assert len(parsed.answers()) == 0 def test_label_compression_attack(): @@ -976,7 +976,7 @@ def test_label_compression_attack(): b'\x0c\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x06' ) parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 1 + assert len(parsed.answers()) == 1 def test_dns_compression_loop_attack(): @@ -993,7 +993,7 @@ def test_dns_compression_loop_attack(): b'\x04\xc0\xa8\xd0\x05' ) parsed = r.DNSIncoming(packet) - assert len(parsed.answers) == 0 + assert len(parsed.answers()) == 0 def test_txt_after_invalid_nsec_name_still_usable(): @@ -1013,7 +1013,7 @@ def test_txt_after_invalid_nsec_name_still_usable(): b'ce=0' ) parsed = r.DNSIncoming(packet) - txt_record = cast(r.DNSText, parsed.answers[4]) + txt_record = cast(r.DNSText, parsed.answers()[4]) # The NSEC record with the invalid name compression should be skipped assert txt_record.text == ( b'2info=/api/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers' @@ -1022,4 +1022,4 @@ def test_txt_after_invalid_nsec_name_still_usable(): b'00/xml/device_description.xml\x0csslport=1443\x0ehhsslport=1843\tvarian' b't=2\x0emdnssequence=0' ) - assert len(parsed.answers) == 5 + assert len(parsed.answers()) == 5 From 6552f882ce47ea0cf190e80f313688b9c33475f5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 25 Sep 2023 19:42:30 +0000 Subject: [PATCH 0978/1433] 0.114.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a79d9b6..543004abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.114.0 (2023-09-25) + +### Feature + +* Speed up responding to queries ([#1275](https://github.com/python-zeroconf/python-zeroconf/issues/1275)) ([`3c6b18c`](https://github.com/python-zeroconf/python-zeroconf/commit/3c6b18cdf4c94773ad6f4497df98feb337939ee9)) + ## v0.113.0 (2023-09-24) ### Feature diff --git a/pyproject.toml b/pyproject.toml index b8f4d7ded..11e4b4bd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.113.0" +version = "0.114.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f9e6799f8..efd9ef3af 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.113.0' +__version__ = '0.114.0' __license__ = 'LGPL' From a13fd49d77474fd5858de809e48cbab1ccf89173 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Sep 2023 09:08:07 -0500 Subject: [PATCH 0979/1433] feat: speed up outgoing multicast queue (#1277) --- build_ext.py | 1 + src/zeroconf/_handlers/answers.pxd | 6 ++--- .../_handlers/multicast_outgoing_queue.pxd | 25 ++++++++++++++++++ .../_handlers/multicast_outgoing_queue.py | 26 +++++++++++++------ 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/zeroconf/_handlers/multicast_outgoing_queue.pxd diff --git a/build_ext.py b/build_ext.py index 870c8058e..c431d7484 100644 --- a/build_ext.py +++ b/build_ext.py @@ -31,6 +31,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_protocol/outgoing.py", "src/zeroconf/_handlers/answers.py", "src/zeroconf/_handlers/record_manager.py", + "src/zeroconf/_handlers/multicast_outgoing_queue.py", "src/zeroconf/_handlers/query_handler.py", "src/zeroconf/_services/browser.py", "src/zeroconf/_services/info.py", diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index 6a0f0e3d5..7efc45c70 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -15,9 +15,9 @@ cdef class QuestionAnswers: cdef class AnswerGroup: - cdef public object send_after - cdef public object send_before - cdef public object answers + cdef public float send_after + cdef public float send_before + cdef public cython.dict answers diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd new file mode 100644 index 000000000..ff01ce54f --- /dev/null +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd @@ -0,0 +1,25 @@ + +import cython + +from .._utils.time cimport current_time_millis, millis_to_seconds +from .answers cimport AnswerGroup, construct_outgoing_multicast_answers + + +cdef object TYPE_CHECKING +cdef tuple MULTICAST_DELAY_RANDOM_INTERVAL +cdef object RAND_INT + +cdef class MulticastOutgoingQueue: + + cdef object zc + cdef object queue + cdef cython.uint additional_delay + cdef cython.uint aggregation_delay + + @cython.locals(last_group=AnswerGroup, random_int=cython.uint, random_delay=float, send_after=float, send_before=float) + cpdef async_add(self, float now, cython.dict answers) + + @cython.locals(pending=AnswerGroup) + cdef _remove_answers_from_queue(self, cython.dict answers) + + cpdef async_ready(self) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index 0e469d288..d45940fb3 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -32,9 +32,13 @@ construct_outgoing_multicast_answers, ) +RAND_INT = random.randint + if TYPE_CHECKING: from .._core import Zeroconf +_float = float + class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" @@ -50,10 +54,13 @@ def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_ self.additional_delay = additional_delay self.aggregation_delay = max_aggregation_delay - def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: + def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: """Add a group of answers with additionals to the outgoing queue.""" - assert self.zc.loop is not None - random_delay = random.randint(*MULTICAST_DELAY_RANDOM_INTERVAL) + self.additional_delay + loop = self.zc.loop + if TYPE_CHECKING: + assert loop is not None + random_int = RAND_INT(*MULTICAST_DELAY_RANDOM_INTERVAL) + random_delay = random_int + self.additional_delay send_after = now + random_delay send_before = now + self.aggregation_delay + self.additional_delay if len(self.queue): @@ -66,7 +73,7 @@ def async_add(self, now: float, answers: _AnswerWithAdditionalsType) -> None: last_group.answers.update(answers) return else: - self.zc.loop.call_later(millis_to_seconds(random_delay), self.async_ready) + loop.call_at(loop.time() + millis_to_seconds(random_delay), self.async_ready) self.queue.append(AnswerGroup(send_after, send_before, answers)) def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: @@ -77,13 +84,16 @@ def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> Non def async_ready(self) -> None: """Process anything in the queue that is ready.""" - assert self.zc.loop is not None + zc = self.zc + loop = zc.loop + if TYPE_CHECKING: + assert loop is not None now = current_time_millis() if len(self.queue) > 1 and self.queue[0].send_before > now: # There is more than one answer in the queue, # delay until we have to send it (first answer group reaches send_before) - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_before - now), self.async_ready) + loop.call_at(loop.time() + millis_to_seconds(self.queue[0].send_before - now), self.async_ready) return answers: _AnswerWithAdditionalsType = {} @@ -94,9 +104,9 @@ def async_ready(self) -> None: if len(self.queue): # If there are still groups in the queue that are not ready to send # be sure we schedule them to go out later - self.zc.loop.call_later(millis_to_seconds(self.queue[0].send_after - now), self.async_ready) + loop.call_at(loop.time() + millis_to_seconds(self.queue[0].send_after - now), self.async_ready) if answers: # If we have the same answer scheduled to go out, remove them self._remove_answers_from_queue(answers) - self.zc.async_send(construct_outgoing_multicast_answers(answers)) + zc.async_send(construct_outgoing_multicast_answers(answers)) From ed84067bf8ea97a7a11ebef9077f2001edd6e7e8 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 26 Sep 2023 14:16:57 +0000 Subject: [PATCH 0980/1433] 0.115.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 543004abc..9fa9d6706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.115.0 (2023-09-26) + +### Feature + +* Speed up outgoing multicast queue ([#1277](https://github.com/python-zeroconf/python-zeroconf/issues/1277)) ([`a13fd49`](https://github.com/python-zeroconf/python-zeroconf/commit/a13fd49d77474fd5858de809e48cbab1ccf89173)) + ## v0.114.0 (2023-09-25) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 11e4b4bd5..b9fcc3c3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.114.0" +version = "0.115.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index efd9ef3af..79320bb96 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.114.0' +__version__ = '0.115.0' __license__ = 'LGPL' From 52ee02b16860e344c402124f4b2e2869536ec839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Oct 2023 09:33:21 +0100 Subject: [PATCH 0981/1433] fix: add missing python definition for addresses_by_version (#1278) --- src/zeroconf/_services/info.pxd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index de7eb97bd..223883165 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -87,7 +87,9 @@ cdef class ServiceInfo(RecordUpdateListener): cdef cython.list _ip_addresses_by_version_value(self, object version_value) - cdef addresses_by_version(self, object version) + cpdef addresses_by_version(self, object version) + + cpdef ip_addresses_by_version(self, object version) @cython.locals(cacheable=cython.bint) cdef cython.list _dns_addresses(self, object override_ttls, object version) From b0fa5ca5620f28fa92b8a900256811d4b86eec4d Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 1 Oct 2023 08:41:38 +0000 Subject: [PATCH 0982/1433] 0.115.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa9d6706..c46fd4c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.115.1 (2023-10-01) + +### Fix + +* Add missing python definition for addresses_by_version ([#1278](https://github.com/python-zeroconf/python-zeroconf/issues/1278)) ([`52ee02b`](https://github.com/python-zeroconf/python-zeroconf/commit/52ee02b16860e344c402124f4b2e2869536ec839)) + ## v0.115.0 (2023-09-26) ### Feature diff --git a/pyproject.toml b/pyproject.toml index b9fcc3c3f..989661bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.115.0" +version = "0.115.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 79320bb96..7962e878f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.115.0' +__version__ = '0.115.1' __license__ = 'LGPL' From 2060eb2cc43489c34bea08924c3f40b875d5a498 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Oct 2023 10:59:13 -0500 Subject: [PATCH 0983/1433] fix: ensure ServiceInfo cache is cleared when adding to the registry (#1279) * There were production use cases that mutated the service info and re-registered it that need to be accounted for --- src/zeroconf/_services/info.pxd | 2 ++ src/zeroconf/_services/info.py | 8 ++++++++ src/zeroconf/_services/registry.py | 1 + tests/test_asyncio.py | 7 +++++++ 4 files changed, 18 insertions(+) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 223883165..dcfc3a8fe 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -107,3 +107,5 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(cacheable=cython.bint) cdef cython.set _get_address_and_nsec_records(self, object override_ttl) + + cpdef async_clear_cache(self) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 0600d5d34..ee033c823 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -273,6 +273,14 @@ def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: assert self._properties is not None return self._properties + def async_clear_cache(self) -> None: + """Clear the cache for this service info.""" + self._dns_address_cache = None + self._dns_pointer_cache = None + self._dns_service_cache = None + self._dns_text_cache = None + self._get_address_and_nsec_records_cache = None + async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: """Calling task waits for a given number of milliseconds or until notified.""" if not self._new_records_futures: diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 12051275e..e9dc4a62b 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -91,6 +91,7 @@ def _add(self, info: ServiceInfo) -> None: if info.key in self._services: raise ServiceNameAlreadyRegistered + info.async_clear_cache() self._services[info.key] = info self.types.setdefault(info.type.lower(), []).append(info.key) self.servers.setdefault(info.server_key, []).append(info.key) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d77e7e832..25dd4681d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -171,6 +171,12 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ) task = await aiozc.async_update_service(new_info) await task + assert new_info.dns_service().server_key == "ash-2.local." + new_info.server = "ash-3.local." + task = await aiozc.async_update_service(new_info) + await task + assert new_info.dns_service().server_key == "ash-3.local." + task = await aiozc.async_unregister_service(new_info) await task await aiozc.async_close() @@ -178,6 +184,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: assert calls == [ ('add', type_, registration_name), ('update', type_, registration_name), + ('update', type_, registration_name), ('remove', type_, registration_name), ] From fc154878027db9f7a910b565b2e826d9270f1df7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Oct 2023 11:54:37 -0500 Subject: [PATCH 0984/1433] chore: re-trigger release (#1280) From 8f2fe22be891bba926cc19856a20d797f9e8263f Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Oct 2023 17:04:05 +0000 Subject: [PATCH 0985/1433] 0.115.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46fd4c46..0f4a7e336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.115.2 (2023-10-05) + +### Fix + +* Ensure ServiceInfo cache is cleared when adding to the registry ([#1279](https://github.com/python-zeroconf/python-zeroconf/issues/1279)) ([`2060eb2`](https://github.com/python-zeroconf/python-zeroconf/commit/2060eb2cc43489c34bea08924c3f40b875d5a498)) + ## v0.115.1 (2023-10-01) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 989661bc9..79259eaff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.115.1" +version = "0.115.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7962e878f..cfe0d840a 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.115.1' +__version__ = '0.115.2' __license__ = 'LGPL' From 0677ce9b4a0524ae922a04b4b107215d5759d838 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Oct 2023 09:32:10 -1000 Subject: [PATCH 0986/1433] chore: bump py3.12 version in the ci (#1276) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d49d30e6b..e7ac69f6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - "3.9" - "3.10" - "3.11" - - "3.12.0-rc.2" + - "3.12" - "pypy-3.7" os: - ubuntu-latest From 8f300996e5bd4316b2237f0502791dd0d6a855fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 15:17:30 -1000 Subject: [PATCH 0987/1433] feat: reduce type checking overhead at run time (#1281) --- src/zeroconf/_handlers/multicast_outgoing_queue.pxd | 2 +- src/zeroconf/_handlers/query_handler.pxd | 2 +- src/zeroconf/_handlers/record_manager.pxd | 2 +- src/zeroconf/_listener.pxd | 2 +- src/zeroconf/_protocol/outgoing.pxd | 2 +- src/zeroconf/_services/browser.pxd | 2 +- src/zeroconf/_services/info.pxd | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd index ff01ce54f..244c07f80 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd @@ -5,7 +5,7 @@ from .._utils.time cimport current_time_millis, millis_to_seconds from .answers cimport AnswerGroup, construct_outgoing_multicast_answers -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef tuple MULTICAST_DELAY_RANDOM_INTERVAL cdef object RAND_INT diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 365e3a27b..a1a4f8a69 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -10,7 +10,7 @@ from .._services.registry cimport ServiceRegistry from .answers cimport QuestionAnswers -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef cython.uint _ONE_SECOND, _TYPE_PTR, _TYPE_ANY, _TYPE_A, _TYPE_AAAA, _TYPE_SRV, _TYPE_TXT cdef str _SERVICE_TYPE_ENUMERATION_NAME cdef cython.set _RESPOND_IMMEDIATE_TYPES diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index e0792d724..89ad5484d 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -9,7 +9,7 @@ from .._protocol.incoming cimport DNSIncoming cdef cython.float _DNS_PTR_MIN_TTL cdef object _ADDRESS_RECORD_TYPES cdef object RecordUpdate -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef object _TYPE_PTR diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 4e4144c78..a49fe96ac 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -8,7 +8,7 @@ from ._utils.time cimport current_time_millis, millis_to_seconds cdef object log cdef object logging_DEBUG -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 1c4d6af71..2374f8b39 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -15,7 +15,7 @@ cdef cython.uint _FLAGS_TC cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _MAX_MSG_TYPICAL -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef object PACK_BYTE cdef object PACK_SHORT diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 1006ee3c3..a095d6ebf 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -7,7 +7,7 @@ from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis, millis_to_seconds -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef object cached_possible_types cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index dcfc3a8fe..2e516a9ef 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -30,7 +30,7 @@ cdef object _IPVersion_V4Only_value cdef cython.set _ADDRESS_RECORD_TYPES -cdef object TYPE_CHECKING +cdef bint TYPE_CHECKING cdef class ServiceInfo(RecordUpdateListener): From 68be89c2eb679c2fa8531942d063379d6219c19f Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 13 Oct 2023 01:26:47 +0000 Subject: [PATCH 0988/1433] 0.116.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4a7e336..65e4edbc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.116.0 (2023-10-13) + +### Feature + +* Reduce type checking overhead at run time ([#1281](https://github.com/python-zeroconf/python-zeroconf/issues/1281)) ([`8f30099`](https://github.com/python-zeroconf/python-zeroconf/commit/8f300996e5bd4316b2237f0502791dd0d6a855fe)) + ## v0.115.2 (2023-10-05) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 79259eaff..6920498fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.115.2" +version = "0.116.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index cfe0d840a..670b88ca5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.115.2' +__version__ = '0.116.0' __license__ = 'LGPL' From 4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Oct 2023 09:29:50 -1000 Subject: [PATCH 0989/1433] feat: small cleanups to incoming data handlers (#1282) --- src/zeroconf/_dns.pxd | 2 +- src/zeroconf/_handlers/query_handler.pxd | 4 ++-- src/zeroconf/_handlers/query_handler.py | 6 ++++-- src/zeroconf/_handlers/record_manager.pxd | 13 ++++++++++--- src/zeroconf/_handlers/record_manager.py | 9 ++++----- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index ccdcc34fa..6785d1a3a 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -24,7 +24,7 @@ cdef class DNSEntry: cdef public str key cdef public str name - cdef public object type + cdef public cython.uint type cdef public object class_ cdef public object unique diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index a1a4f8a69..ff970d766 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -15,7 +15,7 @@ cdef cython.uint _ONE_SECOND, _TYPE_PTR, _TYPE_ANY, _TYPE_A, _TYPE_AAAA, _TYPE_S cdef str _SERVICE_TYPE_ENUMERATION_NAME cdef cython.set _RESPOND_IMMEDIATE_TYPES cdef cython.set _ADDRESS_RECORD_TYPES -cdef object IPVersion +cdef object IPVersion, _IPVersion_ALL cdef object _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL cdef class _QueryResponse: @@ -59,7 +59,7 @@ cdef class QueryHandler: cdef _add_pointer_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers) @cython.locals(service=ServiceInfo, dns_address=DNSAddress) - cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, object type_) + cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) cdef cython.dict _answer_question(self, DNSQuestion question, DNSRRSet known_answers) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 776d6a3f3..cab116624 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -47,6 +47,8 @@ _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} +_IPVersion_ALL = IPVersion.All + _int = int @@ -202,7 +204,7 @@ def _add_address_answers( answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() seen_types: Set[int] = set() - for dns_address in service._dns_addresses(None, IPVersion.All): + for dns_address in service._dns_addresses(None, _IPVersion_ALL): seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) @@ -269,7 +271,7 @@ def async_response( # pylint: disable=unused-argument questions = msg.questions now = msg.now for msg in msgs: - if not msg.is_probe(): + if msg.is_probe() is False: answers.extend(msg.answers()) else: is_probe = True diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 89ad5484d..8775108b5 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -2,11 +2,14 @@ import cython from .._cache cimport DNSCache -from .._dns cimport DNSRecord +from .._dns cimport DNSQuestion, DNSRecord from .._protocol.incoming cimport DNSIncoming +from .._updates cimport RecordUpdateListener +from .._utils.time cimport current_time_millis cdef cython.float _DNS_PTR_MIN_TTL +cdef cython.uint _TYPE_PTR cdef object _ADDRESS_RECORD_TYPES cdef object RecordUpdate cdef bint TYPE_CHECKING @@ -26,11 +29,15 @@ cdef class RecordManager: @cython.locals( cache=DNSCache, record=DNSRecord, + answers=cython.list, maybe_entry=DNSRecord, now_float=cython.float ) cpdef async_updates_from_response(self, DNSIncoming msg) - cpdef async_add_listener(self, object listener, object question) + cpdef async_add_listener(self, RecordUpdateListener listener, object question) - cpdef async_remove_listener(self, object listener) + cpdef async_remove_listener(self, RecordUpdateListener listener) + + @cython.locals(question=DNSQuestion, record=DNSRecord) + cdef _async_update_matching_records(self, RecordUpdateListener listener, cython.list questions) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 63572c1ee..6fb11f55b 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -106,14 +106,14 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: ) record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) - if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 + if record.unique is True: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record_type, record.class_)) if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = cache.async_get_unique(record) - if not record.is_expired(now_float): + if record.is_expired(now_float) is False: if maybe_entry is not None: maybe_entry.reset_ttl(record) else: @@ -129,7 +129,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now_float) if updates: self.async_updates(now, updates) @@ -151,7 +151,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: new = False if other_adds or address_adds: new = cache.async_add_records(address_adds) - if cache.async_add_records(other_adds): + if cache.async_add_records(other_adds) is True: new = True # Removes are processed last since # ServiceInfo could generate an un-needed query @@ -182,7 +182,6 @@ def async_add_listener( return questions = [question] if isinstance(question, DNSQuestion) else question - assert self.zc.loop is not None self._async_update_matching_records(listener, questions) def _async_update_matching_records( From 29d694a4ac27552805134b7081c53ddf6b16f1e6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 14 Oct 2023 19:38:25 +0000 Subject: [PATCH 0990/1433] 0.117.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e4edbc5..8a4e31d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.117.0 (2023-10-14) + +### Feature + +* Small cleanups to incoming data handlers ([#1282](https://github.com/python-zeroconf/python-zeroconf/issues/1282)) ([`4f4bd9f`](https://github.com/python-zeroconf/python-zeroconf/commit/4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9)) + ## v0.116.0 (2023-10-13) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 6920498fb..5cbd4967f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.116.0" +version = "0.117.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 670b88ca5..c1aed8451 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.116.0' +__version__ = '0.117.0' __license__ = 'LGPL' From 0fc031b1e7bf1766d5a1d39d70d300b86e36715e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Oct 2023 09:41:44 -1000 Subject: [PATCH 0991/1433] feat: small improvements to ServiceBrowser performance (#1283) --- src/zeroconf/_services/browser.pxd | 3 ++- src/zeroconf/_services/browser.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index a095d6ebf..8b77c80e3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -10,6 +10,7 @@ from .._utils.time cimport current_time_millis, millis_to_seconds cdef bint TYPE_CHECKING cdef object cached_possible_types cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT +cdef cython.uint _TYPE_PTR cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED cdef class _DNSPointerOutgoingBucket: @@ -58,7 +59,7 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _enqueue_callback(self, object state_change, object type_, object name) - @cython.locals(record=DNSRecord, cache=DNSCache, service=DNSRecord) + @cython.locals(record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) cpdef async_update_records(self, object zc, cython.float now, cython.list records) cpdef _names_matching_types(self, object types) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index cb611d1a5..c302de546 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -404,24 +404,26 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record This method will be run in the event loop. """ for record_update in records: - record, old_record = record_update + record = record_update[0] + old_record = record_update[1] record_type = record.type if record_type is _TYPE_PTR: if TYPE_CHECKING: record = cast(DNSPointer, record) - for type_ in self.types.intersection(cached_possible_types(record.name)): + pointer = record + for type_ in self.types.intersection(cached_possible_types(pointer.name)): if old_record is None: - self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, record.alias) - elif record.is_expired(now): - self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, record.alias) + self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias) + elif pointer.is_expired(now): + self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias) else: - expire_time = record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + expire_time = pointer.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) self.reschedule_type(type_, now, expire_time) continue # If its expired or already exists in the cache it cannot be updated. - if old_record or record.is_expired(now): + if old_record or record.is_expired(now) is True: continue if record_type in _ADDRESS_RECORD_TYPES: From e3ce4559590f858755058cb2512d0e0f17d457be Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 14 Oct 2023 19:50:44 +0000 Subject: [PATCH 0992/1433] 0.118.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4e31d71..b48414cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.118.0 (2023-10-14) + +### Feature + +* Small improvements to ServiceBrowser performance ([#1283](https://github.com/python-zeroconf/python-zeroconf/issues/1283)) ([`0fc031b`](https://github.com/python-zeroconf/python-zeroconf/commit/0fc031b1e7bf1766d5a1d39d70d300b86e36715e)) + ## v0.117.0 (2023-10-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 5cbd4967f..3e36eb1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.117.0" +version = "0.118.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c1aed8451..7615a44b3 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.117.0' +__version__ = '0.118.0' __license__ = 'LGPL' From b6afa4b2775a1fdb090145eccdc5711c98e7147a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 21:01:21 -1000 Subject: [PATCH 0993/1433] fix: reduce size of wheels by excluding generated .c files (#1284) --- MANIFEST.in | 1 + build_ext.py | 1 + 2 files changed, 2 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 9491f804d..f8eef3376 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include README.rst include COPYING +global-exclude *.c diff --git a/build_ext.py b/build_ext.py index c431d7484..2134f61d4 100644 --- a/build_ext.py +++ b/build_ext.py @@ -44,6 +44,7 @@ def build(setup_kwargs: Any) -> None: cmdclass=dict(build_ext=BuildExt), ) ) + setup_kwargs["exclude_package_data"] = {pkg: ["*.c"] for pkg in setup_kwargs["packages"]} except Exception: if os.environ.get("REQUIRE_CYTHON"): raise From 1514712a97a6411eff43ecfb423d0b23ec11fc34 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 18 Oct 2023 07:10:47 +0000 Subject: [PATCH 0994/1433] 0.118.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48414cde..5e881fb9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.118.1 (2023-10-18) + +### Fix + +* Reduce size of wheels by excluding generated .c files ([#1284](https://github.com/python-zeroconf/python-zeroconf/issues/1284)) ([`b6afa4b`](https://github.com/python-zeroconf/python-zeroconf/commit/b6afa4b2775a1fdb090145eccdc5711c98e7147a)) + ## v0.118.0 (2023-10-14) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 3e36eb1f5..488363516 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.118.0" +version = "0.118.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7615a44b3..ebd4a637d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.118.0' +__version__ = '0.118.1' __license__ = 'LGPL' From e8c9083bb118764a85b12fac9055152a2f62a212 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 21:45:10 -1000 Subject: [PATCH 0995/1433] feat: update cibuildwheel to build wheels on latest cython final release (#1285) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7ac69f6e..cfdb5adb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,7 @@ jobs: fetch-depth: 0 - name: Build wheels - uses: pypa/cibuildwheel@v2.15.0 + uses: pypa/cibuildwheel@v2.16.2 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* From 461e6c1504186e3e4c6399b4d903351e0f17ff33 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 18 Oct 2023 07:56:59 +0000 Subject: [PATCH 0996/1433] 0.119.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e881fb9f..d91772e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.119.0 (2023-10-18) + +### Feature + +* Update cibuildwheel to build wheels on latest cython final release ([#1285](https://github.com/python-zeroconf/python-zeroconf/issues/1285)) ([`e8c9083`](https://github.com/python-zeroconf/python-zeroconf/commit/e8c9083bb118764a85b12fac9055152a2f62a212)) + ## v0.118.1 (2023-10-18) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 488363516..85367cc7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.118.1" +version = "0.119.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ebd4a637d..92aa9aeaf 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.118.1' +__version__ = '0.119.0' __license__ = 'LGPL' From bdcf286ce5bbb19922701a1ba436759507747e04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 16:53:26 -0500 Subject: [PATCH 0997/1433] chore: fix test patching (#1292) --- src/zeroconf/_services/browser.pxd | 2 +- tests/conftest.py | 6 ++---- tests/services/test_browser.py | 8 ++++++-- tests/test_handlers.py | 4 ++++ 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 8b77c80e3..3cce4977a 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -23,7 +23,7 @@ cdef class _DNSPointerOutgoingBucket: @cython.locals(answer=DNSPointer) -cdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) +cpdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) cdef class QueryScheduler: diff --git a/tests/conftest.py b/tests/conftest.py index 5cdff18e0..c0e926a34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest -from zeroconf import _core, _listener, const +from zeroconf import _core, const @pytest.fixture(autouse=True) @@ -34,7 +34,5 @@ def disable_duplicate_packet_suppression(): Some tests run too slowly because of the duplicate packet suppression. """ - with patch.object(_listener, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0), patch.object( - const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0 - ): + with patch.object(const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0): yield diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index aa13761db..f6f3c3459 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -26,7 +26,6 @@ current_time_millis, millis_to_seconds, ) -from zeroconf._handlers import record_manager from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo @@ -1159,7 +1158,6 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: zc.close() -@patch.object(record_manager, '_DNS_PTR_MIN_TTL', 1) @patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01) def test_service_browser_expire_callbacks(): """Test that the ServiceBrowser matching does not match partial names.""" @@ -1216,6 +1214,12 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), ) + # Force the ttl to be 1 second + now = current_time_millis() + for cache_record in zc.cache.cache.values(): + for record in cache_record: + record.set_created_ttl(now, 1) + time.sleep(0.3) info.port = 400 info._dns_service_cache = None # we are mutating the record so clear the cache diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 11b58292a..a1c6ff5d8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1495,6 +1495,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression await asyncio.sleep(0.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1505,6 +1506,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression await asyncio.sleep(1.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1515,7 +1517,9 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression # The delay should increase with two packets and # 900ms is beyond the maximum aggregation delay # when there is no network protection delay From 1e1877adef729a73a639fd9b66c85816081763bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 17:16:19 -0500 Subject: [PATCH 0998/1433] chore: fix cythonize of browser (#1293) --- src/zeroconf/_services/browser.pxd | 2 +- src/zeroconf/_services/browser.py | 29 +++++++++++++++++++++-------- tests/services/test_browser.py | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 3cce4977a..8b77c80e3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -23,7 +23,7 @@ cdef class _DNSPointerOutgoingBucket: @cython.locals(answer=DNSPointer) -cpdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) +cdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) cdef class QueryScheduler: diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index c302de546..8503151a8 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -25,6 +25,7 @@ import random import threading import warnings +from functools import partial from types import TracebackType # noqa # used in type hints from typing import ( TYPE_CHECKING, @@ -111,7 +112,7 @@ def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNS self.bytes += max_compressed_size -def _group_ptr_queries_with_known_answers( +def group_ptr_queries_with_known_answers( now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers ) -> List[DNSOutgoing]: """Aggregate queries so that as many known answers as possible fit in the same packet @@ -122,6 +123,13 @@ def _group_ptr_queries_with_known_answers( so we try to keep all the known answers in the same packet as the questions. """ + return _group_ptr_queries_with_known_answers(now, multicast, question_with_known_answers) + + +def _group_ptr_queries_with_known_answers( + now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers +) -> List[DNSOutgoing]: + """Inner wrapper for group_ptr_queries_with_known_answers.""" # This is the maximum size the query + known answers can be with name compression. # The actual size of the query + known answers may be a bit smaller since other # parts may be shared when the final DNSOutgoing packets are constructed. The @@ -187,6 +195,17 @@ def generate_service_query( return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) +def _on_change_dispatcher( + listener: ServiceListener, + zeroconf: 'Zeroconf', + service_type: str, + name: str, + state_change: ServiceStateChange, +) -> None: + """Dispatch a service state change to a listener.""" + getattr(listener, _ON_CHANGE_DISPATCH[state_change])(zeroconf, service_type, name) + + def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: """Generate a service_state_changed handlers from a listener.""" assert listener is not None @@ -196,13 +215,7 @@ def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[ "don't care about the updates), it'll become mandatory." % (listener,), FutureWarning, ) - - def on_change( - zeroconf: 'Zeroconf', service_type: str, name: str, state_change: ServiceStateChange - ) -> None: - getattr(listener, _ON_CHANGE_DISPATCH[state_change])(zeroconf, service_type, name) - - return on_change + return partial(_on_change_dispatcher, listener) class QueryScheduler: diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index f6f3c3459..15a035989 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -965,7 +965,7 @@ def test_group_ptr_queries_with_known_answers(): ) for counter in range(i) } - outs = _services_browser._group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) + outs = _services_browser.group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) for out in outs: packets = out.packets() # If we generate multiple packets there must From eba2e31d30cc594530b196a42612ab72e5771944 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 17:16:41 -0500 Subject: [PATCH 0999/1433] chore: ensure ci use_cython fails if cythonize fails (#1294) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfdb5adb1..91ae876c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,8 @@ jobs: run: poetry install --only=main,dev - name: Install Dependencies with cython if: ${{ matrix.extension != 'skip_cython' }} + env: + REQUIRE_CYTHON: 1 run: poetry install --only=main,dev - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests From 0060f798872d0dc079634f79e6d247247940decf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 17:52:53 -0500 Subject: [PATCH 1000/1433] chore: fix more failing ServiceBrowser tests (#1295) --- src/zeroconf/_services/browser.py | 4 ++++ tests/test_asyncio.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 8503151a8..ed4825465 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -269,6 +269,10 @@ def reschedule_type(self, type_: str_, next_time: float_) -> bool: self._next_time[type_] = next_time return True + def _force_reschedule_type(self, type_: str_, next_time: float_) -> None: + """Force a reschedule of a type.""" + self._next_time[type_] = next_time + def process_ready_types(self, now: float_) -> List[str]: """Generate a list of ready types that is due and schedule the next time.""" if self.millis_to_wait(now): diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 25dd4681d..53bce4b4c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1055,7 +1055,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): now = _new_current_time_millis() # Force the next query to be sent since we are testing # to see if the query contains answers and not the scheduler - browser.query_scheduler._next_time[type_] = now + (1000 * expected_ttl) + browser.query_scheduler._force_reschedule_type(type_, now + (1000 * expected_ttl)) browser.reschedule_type(type_, now, now) sleep_count += 1 await asyncio.wait_for(got_query.wait(), 1) @@ -1350,7 +1350,7 @@ def _new_current_time_millis(): await asyncio.wait_for(service_added.wait(), 1) time_offset = 1000 * expected_ttl # set the time to the end of the ttl now = _new_current_time_millis() - browser.query_scheduler._next_time[type_] = now + (1000 * expected_ttl) + browser.query_scheduler._force_reschedule_type(type_, now + (1000 * expected_ttl)) # Make sure the query schedule is to a time in the future # so we will reschedule with patch.object( From 0b9d36f5587fd9e43992c97a08b0210effd434d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:15:54 -0500 Subject: [PATCH 1001/1433] chore: fix handler tests (#1296) --- .../_handlers/multicast_outgoing_queue.pxd | 10 ++-- .../_handlers/multicast_outgoing_queue.py | 24 ++++++--- tests/test_handlers.py | 50 +++++++++++-------- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd index 244c07f80..59a4fb2a9 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd @@ -12,11 +12,13 @@ cdef object RAND_INT cdef class MulticastOutgoingQueue: cdef object zc - cdef object queue - cdef cython.uint additional_delay - cdef cython.uint aggregation_delay + cdef public object queue + cdef public object _multicast_delay_random_min + cdef public object _multicast_delay_random_max + cdef object _additional_delay + cdef object _aggregation_delay - @cython.locals(last_group=AnswerGroup, random_int=cython.uint, random_delay=float, send_after=float, send_before=float) + @cython.locals(last_group=AnswerGroup, random_int=cython.uint) cpdef async_add(self, float now, cython.dict answers) @cython.locals(pending=AnswerGroup) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index d45940fb3..1d398d736 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -38,31 +38,41 @@ from .._core import Zeroconf _float = float +_int = int class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" - __slots__ = ("zc", "queue", "additional_delay", "aggregation_delay") + __slots__ = ( + "zc", + "queue", + "_multicast_delay_random_min", + "_multicast_delay_random_max", + "_additional_delay", + "_aggregation_delay", + ) - def __init__(self, zeroconf: 'Zeroconf', additional_delay: int, max_aggregation_delay: int) -> None: + def __init__(self, zeroconf: 'Zeroconf', additional_delay: _int, max_aggregation_delay: _int) -> None: self.zc = zeroconf self.queue: deque[AnswerGroup] = deque() # Additional delay is used to implement # Protect the network against excessive packet flooding # https://datatracker.ietf.org/doc/html/rfc6762#section-14 - self.additional_delay = additional_delay - self.aggregation_delay = max_aggregation_delay + self._multicast_delay_random_min = MULTICAST_DELAY_RANDOM_INTERVAL[0] + self._multicast_delay_random_max = MULTICAST_DELAY_RANDOM_INTERVAL[1] + self._additional_delay = additional_delay + self._aggregation_delay = max_aggregation_delay def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: """Add a group of answers with additionals to the outgoing queue.""" loop = self.zc.loop if TYPE_CHECKING: assert loop is not None - random_int = RAND_INT(*MULTICAST_DELAY_RANDOM_INTERVAL) - random_delay = random_int + self.additional_delay + random_int = RAND_INT(self._multicast_delay_random_min, self._multicast_delay_random_max) + random_delay = random_int + self._additional_delay send_after = now + random_delay - send_before = now + self.aggregation_delay + self.additional_delay + send_before = now + self._aggregation_delay + self._additional_delay if len(self.queue): # If we calculate a random delay for the send after time # that is less than the last group scheduled to go out, diff --git a/tests/test_handlers.py b/tests/test_handlers.py index a1c6ff5d8..13fe3a516 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -11,12 +11,12 @@ import unittest import unittest.mock from typing import List, cast +from unittest.mock import patch import pytest import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, const, current_time_millis -from zeroconf._handlers import multicast_outgoing_queue from zeroconf._handlers.multicast_outgoing_queue import ( MulticastOutgoingQueue, construct_outgoing_multicast_answers, @@ -1413,7 +1413,7 @@ async def test_response_aggregation_timings(run_isolated): zc = aiozc.zeroconf protocol = zc.engine.protocols[0] - with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock: + with patch.object(aiozc.zeroconf, "async_send") as send_mock: protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) @@ -1492,7 +1492,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli zc = aiozc.zeroconf protocol = zc.engine.protocols[0] - with unittest.mock.patch.object(aiozc.zeroconf, "async_send") as send_mock: + with patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression @@ -1581,16 +1581,19 @@ async def test_response_aggregation_random_delay(): outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 500) now = current_time_millis() - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (500, 600)): - outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 500 + outgoing_queue._multicast_delay_random_max = 600 + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) # The second group should always be coalesced into first group since it will always come before - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (300, 400)): - outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 300 + outgoing_queue._multicast_delay_random_max = 400 + outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) # The third group should always be coalesced into first group since it will always come before - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (100, 200)): - outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 100 + outgoing_queue._multicast_delay_random_max = 200 + outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 assert info.dns_pointer() in outgoing_queue.queue[0].answers @@ -1599,8 +1602,9 @@ async def test_response_aggregation_random_delay(): assert info4.dns_pointer() in outgoing_queue.queue[0].answers # The forth group should not be coalesced because its scheduled after the last group in the queue - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (700, 800)): - outgoing_queue.async_add(now, {info5.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 700 + outgoing_queue._multicast_delay_random_max = 800 + outgoing_queue.async_add(now, {info5.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 2 assert info.dns_pointer() not in outgoing_queue.queue[1].answers @@ -1630,21 +1634,22 @@ async def test_future_answers_are_removed_on_send(): outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) now = current_time_millis() - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (1, 1)): - outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 1 + outgoing_queue._multicast_delay_random_max = 1 + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 - with unittest.mock.patch.object(multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (2, 2)): - outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 2 + outgoing_queue._multicast_delay_random_max = 2 + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 2 - with unittest.mock.patch.object( - multicast_outgoing_queue, "MULTICAST_DELAY_RANDOM_INTERVAL", (1000, 1000) - ): - outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) - outgoing_queue.async_add(now, {info.dns_pointer(): set()}) + outgoing_queue._multicast_delay_random_min = 1000 + outgoing_queue._multicast_delay_random_max = 1000 + outgoing_queue.async_add(now, {info2.dns_pointer(): set()}) + outgoing_queue.async_add(now, {info.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 3 @@ -1676,6 +1681,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor zc.add_listener(MyListener(), None) # type: ignore[arg-type] await asyncio.sleep(0) # flush out any call soons - assert "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text + assert ( + "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text + or "TypeError: Argument \'listener\' has incorrect type" in caplog.text + ) await aiozc.async_close() From d467a65257b13a45dab8b6a37aab2fe1976ba103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:40:25 -0500 Subject: [PATCH 1002/1433] chore: fix duplicate packet test with cython (#1298) --- src/zeroconf/_listener.pxd | 7 ++++-- src/zeroconf/_listener.py | 26 +++++++++++++++------ tests/test_listener.py | 47 +++++++++++++++++++++++++------------- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index a49fe96ac..3b1d62313 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -7,7 +7,7 @@ from ._utils.time cimport current_time_millis, millis_to_seconds cdef object log -cdef object logging_DEBUG +cdef object DEBUG_ENABLED cdef bint TYPE_CHECKING cdef cython.uint _MAX_MSG_ABSOLUTE @@ -27,7 +27,10 @@ cdef class AsyncListener: cdef public cython.dict _deferred cdef public cython.dict _timers - @cython.locals(now=cython.float, msg=DNSIncoming) + @cython.locals(now=cython.float, debug=cython.bint) cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) + @cython.locals(msg=DNSIncoming) + cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, cython.float now, bytes data, cython.tuple addrs) + cdef _cancel_any_timers_for_addr(self, object addr) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 913c169f8..c27d1b610 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -23,6 +23,7 @@ import asyncio import logging import random +from functools import partial from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union, cast from ._logger import QuietLogger, log @@ -40,8 +41,9 @@ _bytes = bytes _str = str _int = int +_float = float -logging_DEBUG = logging.DEBUG +DEBUG_ENABLED = partial(log.isEnabledFor, logging.DEBUG) class AsyncListener: @@ -80,9 +82,8 @@ def __init__(self, zc: 'Zeroconf') -> None: def datagram_received( self, data: _bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] ) -> None: - assert self.transport is not None data_len = len(data) - debug = log.isEnabledFor(logging_DEBUG) + debug = DEBUG_ENABLED() if data_len > _MAX_MSG_ABSOLUTE: # Guard against oversized packets to ensure bad implementations cannot overwhelm @@ -95,13 +96,22 @@ def datagram_received( _MAX_MSG_ABSOLUTE, ) return - now = current_time_millis() + self._process_datagram_at_time(debug, data_len, now, data, addrs) + + def _process_datagram_at_time( + self, + debug: bool, + data_len: _int, + now: _float, + data: _bytes, + addrs: Union[Tuple[str, int], Tuple[str, int, int, int]], + ) -> None: if ( self.data == data and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time and self.last_message is not None - and not self.last_message.has_qu_question() + and self.last_message.has_qu_question() is False ): # Guard against duplicate packets if debug: @@ -134,7 +144,7 @@ def datagram_received( self.data = data self.last_time = now self.last_message = msg - if msg.valid: + if msg.valid is True: if debug: log.debug( 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', @@ -157,10 +167,12 @@ def datagram_received( ) return - if not msg.is_query(): + if msg.is_query() is False: self._record_manager.async_updates_from_response(msg) return + if TYPE_CHECKING: + assert self.transport is not None self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) def handle_query_or_defer( diff --git a/tests/test_listener.py b/tests/test_listener.py index 914b4a130..dff01d78b 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -160,62 +160,77 @@ def handle_query_or_defer( addrs = ("1.2.3.4", 43) - with patch.object(_listener, "current_time_millis") as _current_time_millis, patch.object( - listener, "handle_query_or_defer" - ) as _handle_query_or_defer: + with patch.object(listener, "handle_query_or_defer") as _handle_query_or_defer: start_time = current_time_millis() - _current_time_millis.return_value = start_time - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), start_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), start_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() - # Now walk time forward 1000 seconds - _current_time_millis.return_value = start_time + 1000 + # Now walk time forward 1100 milliseconds + new_time = start_time + 1100 # Now call with the same packet again and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question2, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question2), new_time, packet_with_qm_question2, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet and handle_query_or_defer should fire - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet with qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qu_question), new_time, packet_with_qu_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call again with the same packet that has a qu question and handle_query_or_defer should fire - listener.datagram_received(packet_with_qu_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qu_question), new_time, packet_with_qu_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() log.setLevel(logging.WARNING) # Call with the QM packet again - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the same packet again and handle_query_or_defer should not fire - listener.datagram_received(packet_with_qm_question, addrs) + listener._process_datagram_at_time( + False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + ) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() # Now call with garbage - listener.datagram_received(b'garbage', addrs) + listener._process_datagram_at_time(False, len(b'garbage'), new_time, b'garbage', addrs) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() From 630b9aa4b8d50cf9527d66f5cced90887d98a7e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:40:40 -0500 Subject: [PATCH 1003/1433] chore: fix test_dns_record_abc with cython (#1297) --- tests/test_dns.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 08f805f03..4f7e05432 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -10,6 +10,8 @@ import unittest import unittest.mock +import pytest + import zeroconf as r from zeroconf import DNSHinfo, DNSText, ServiceInfo, const, current_time_millis from zeroconf._dns import DNSRRSet @@ -80,7 +82,8 @@ def test_dns_service_repr(self): def test_dns_record_abc(self): record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) self.assertRaises(r.AbstractMethodException, record.__eq__, record) - self.assertRaises(r.AbstractMethodException, record.write, None) + with pytest.raises((r.AbstractMethodException, TypeError)): + record.write(None) # type: ignore[arg-type] def test_dns_record_reset_ttl(self): record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) From edb4d0d0f57de09483291fae99e5c37eadd9bd88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 18:54:46 -0500 Subject: [PATCH 1004/1433] chore: fix ServiceBrowser backoff test under cython (#1299) --- tests/services/test_browser.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 15a035989..268a9b209 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -487,7 +487,7 @@ def test_backoff(): start_time = time.monotonic() * 1000 initial_query_interval = _services_browser._BROWSER_TIME / 1000 - def current_time_millis(): + def _current_time_millis(): """Current system time in milliseconds""" return start_time + time_offset * 1000 @@ -496,19 +496,34 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): got_query.set() old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) + class ServiceBrowserWithPatchedTime(_services_browser.ServiceBrowser): + def _async_start(self) -> None: + """Generate the next time and setup listeners. + + Must be called by uses of this base class after they + have finished setting their properties. + """ + super()._async_start() + self.query_scheduler.start(_current_time_millis()) + + def _async_send_ready_queries_schedule_next(self): + if self.done or self.zc.done: + return + now = _current_time_millis() + self._async_send_ready_queries(now) + self._async_schedule_next(now) + # patch the zeroconf send # patch the zeroconf current_time_millis # patch the backoff limit to prevent test running forever with patch.object(zeroconf_browser, "async_send", send), patch.object( - _services_browser, "current_time_millis", current_time_millis - ), patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10), patch.object( - _services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0) - ): + _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 + ), patch.object(_services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0)): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = ServiceBrowserWithPatchedTime(zeroconf_browser, type_, [on_service_state_change]) try: # Test that queries are sent at increasing intervals From 8a17f2053a89db4beca9e8c1de4640faf27726b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 19:09:17 -0500 Subject: [PATCH 1005/1433] feat: speed up ServiceBrowsers with a pxd for the signal interface (#1289) --- build_ext.py | 1 + src/zeroconf/_services/__init__.pxd | 11 +++++++++++ src/zeroconf/_services/browser.pxd | 1 + src/zeroconf/_services/browser.py | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 src/zeroconf/_services/__init__.pxd diff --git a/build_ext.py b/build_ext.py index 2134f61d4..ba80e52c3 100644 --- a/build_ext.py +++ b/build_ext.py @@ -33,6 +33,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_handlers/record_manager.py", "src/zeroconf/_handlers/multicast_outgoing_queue.py", "src/zeroconf/_handlers/query_handler.py", + "src/zeroconf/_services/__init__.py", "src/zeroconf/_services/browser.py", "src/zeroconf/_services/info.py", "src/zeroconf/_services/registry.py", diff --git a/src/zeroconf/_services/__init__.pxd b/src/zeroconf/_services/__init__.pxd new file mode 100644 index 000000000..46a75f3c5 --- /dev/null +++ b/src/zeroconf/_services/__init__.pxd @@ -0,0 +1,11 @@ + +import cython + + +cdef class Signal: + + cdef list _handlers + +cdef class SignalRegistrationInterface: + + cdef list _handlers diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 8b77c80e3..a844d3330 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -5,6 +5,7 @@ from .._cache cimport DNSCache from .._protocol.outgoing cimport DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis, millis_to_seconds +from . cimport Signal, SignalRegistrationInterface cdef bint TYPE_CHECKING diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index ed4825465..b0b1a0790 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -405,7 +405,7 @@ def _enqueue_callback( state_change is SERVICE_STATE_CHANGE_ADDED or ( state_change is SERVICE_STATE_CHANGE_REMOVED - and self._pending_handlers.get(key) != SERVICE_STATE_CHANGE_ADDED + and self._pending_handlers.get(key) is not SERVICE_STATE_CHANGE_ADDED ) or (state_change is SERVICE_STATE_CHANGE_UPDATED and key not in self._pending_handlers) ): From c37ead4d7000607e81706a97b4cdffd80cf8cf99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 19:28:19 -0500 Subject: [PATCH 1006/1433] feat: speed up decoding labels from incoming data (#1291) --- src/zeroconf/_protocol/incoming.pxd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 37fc91e78..d71e23785 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -87,7 +87,7 @@ cdef class DNSIncoming: link_py_int=object, linked_labels=cython.list ) - cdef _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) + cdef cython.uint _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) cdef _read_header(self) From c2f99d902ad3d3ce09bf59cafb9f2e3c0400f63e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 19:32:57 -0500 Subject: [PATCH 1007/1433] chore: fix race in dns tests (#1300) --- tests/test_dns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dns.py b/tests/test_dns.py index 4f7e05432..0eac568dd 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -149,13 +149,13 @@ def test_dns_record_is_stale(self): now = current_time_millis() assert record.is_stale(now) is False assert record.is_stale(now + (8 / 4.1 * 1000)) is False - assert record.is_stale(now + (8 / 2 * 1000)) is True + assert record.is_stale(now + (8 / 1.9 * 1000)) is True assert record.is_stale(now + (8 * 1000)) is True def test_dns_record_is_recent(self): now = current_time_millis() record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) - assert record.is_recent(now + (8 / 4.1 * 1000)) is True + assert record.is_recent(now + (8 / 4.2 * 1000)) is True assert record.is_recent(now + (8 / 3 * 1000)) is False assert record.is_recent(now + (8 / 2 * 1000)) is False assert record.is_recent(now + (8 * 1000)) is False From f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Nov 2023 19:33:02 -0500 Subject: [PATCH 1008/1433] feat: speed up incoming packet processing with a memory view (#1290) --- src/zeroconf/_protocol/incoming.pxd | 1 + src/zeroconf/_protocol/incoming.py | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index d71e23785..c39ab9a64 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -50,6 +50,7 @@ cdef class DNSIncoming: cdef public unsigned int flags cdef cython.uint offset cdef public bytes data + cdef const unsigned char [:] view cdef unsigned int _data_len cdef public cython.dict name_cache cdef public cython.list questions diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 5838657a3..6a7451e71 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -78,6 +78,7 @@ class DNSIncoming: 'flags', 'offset', 'data', + 'view', '_data_len', 'name_cache', 'questions', @@ -105,6 +106,7 @@ def __init__( self.flags = 0 self.offset = 0 self.data = data + self.view = data self._data_len = len(data) self.name_cache: Dict[int, List[str]] = {} self.questions: List[DNSQuestion] = [] @@ -228,7 +230,7 @@ def _read_questions(self) -> None: def _read_character_string(self) -> str: """Reads a character string from the packet""" - length = self.data[self.offset] + length = self.view[self.offset] self.offset += 1 info = self.data[self.offset : self.offset + length].decode('utf-8', 'replace') self.offset += length @@ -334,8 +336,8 @@ def _read_bitmap(self, end: _int) -> List[int]: offset = self.offset offset_plus_one = offset + 1 offset_plus_two = offset + 2 - window = self.data[offset] - bitmap_length = self.data[offset_plus_one] + window = self.view[offset] + bitmap_length = self.view[offset_plus_one] bitmap_end = offset_plus_two + bitmap_length for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]): for bit in range(0, 8): @@ -361,7 +363,7 @@ def _read_name(self) -> str: def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. while off < self._data_len: - length = self.data[off] + length = self.view[off] if length == 0: return off + DNS_COMPRESSION_HEADER_LEN @@ -377,7 +379,7 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: ) # We have a DNS compression pointer - link_data = self.data[off + 1] + link_data = self.view[off + 1] link = (length & 0x3F) * 256 + link_data link_py_int = link if link > self._data_len: From a910a2b17f92285e349800bd80ba564997f8b88b Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 5 Nov 2023 00:45:52 +0000 Subject: [PATCH 1009/1433] 0.120.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91772e04..09ba7b932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v0.120.0 (2023-11-05) + +### Feature + +* Speed up incoming packet processing with a memory view ([#1290](https://github.com/python-zeroconf/python-zeroconf/issues/1290)) ([`f1f0a25`](https://github.com/python-zeroconf/python-zeroconf/commit/f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7)) +* Speed up decoding labels from incoming data ([#1291](https://github.com/python-zeroconf/python-zeroconf/issues/1291)) ([`c37ead4`](https://github.com/python-zeroconf/python-zeroconf/commit/c37ead4d7000607e81706a97b4cdffd80cf8cf99)) +* Speed up ServiceBrowsers with a pxd for the signal interface ([#1289](https://github.com/python-zeroconf/python-zeroconf/issues/1289)) ([`8a17f20`](https://github.com/python-zeroconf/python-zeroconf/commit/8a17f2053a89db4beca9e8c1de4640faf27726b4)) + ## v0.119.0 (2023-10-18) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 85367cc7b..95cfbd979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.119.0" +version = "0.120.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 92aa9aeaf..05141b33e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.119.0' +__version__ = '0.120.0' __license__ = 'LGPL' From d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 08:30:46 -0600 Subject: [PATCH 1010/1433] feat: speed up record updates (#1301) --- build_ext.py | 1 + src/zeroconf/_cache.pxd | 6 +++--- src/zeroconf/_handlers/query_handler.py | 14 +++++++------- src/zeroconf/_record_update.pxd | 10 ++++++++++ src/zeroconf/_record_update.py | 21 +++++++++++++++++---- src/zeroconf/_services/browser.pxd | 7 +++++-- src/zeroconf/_services/browser.py | 15 ++++++++------- src/zeroconf/_services/info.pxd | 8 ++++++-- src/zeroconf/_services/info.py | 12 ++++++------ src/zeroconf/_updates.py | 2 +- tests/test_updates.py | 14 ++++++++++++++ 11 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 src/zeroconf/_record_update.pxd diff --git a/build_ext.py b/build_ext.py index ba80e52c3..d2f32685e 100644 --- a/build_ext.py +++ b/build_ext.py @@ -26,6 +26,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_dns.py", "src/zeroconf/_cache.py", "src/zeroconf/_history.py", + "src/zeroconf/_record_update.py", "src/zeroconf/_listener.py", "src/zeroconf/_protocol/incoming.py", "src/zeroconf/_protocol/outgoing.py", diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index cdba81767..1f94c21e2 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -44,9 +44,9 @@ cdef class DNSCache: ) cpdef async_all_by_details(self, str name, object type_, object class_) - cpdef async_entries_with_name(self, str name) + cpdef cython.dict async_entries_with_name(self, str name) - cpdef async_entries_with_server(self, str name) + cpdef cython.dict async_entries_with_server(self, str name) @cython.locals( cached_entry=DNSRecord, @@ -57,7 +57,7 @@ cdef class DNSCache: records=cython.dict, entry=DNSRecord, ) - cpdef get_all_by_details(self, str name, object type_, object class_) + cpdef cython.list get_all_by_details(self, str name, object type_, object class_) @cython.locals( store=cython.dict, diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index cab116624..4e74aa5c0 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -139,7 +139,7 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry and maybe_entry.is_recent(self._now)) + return bool(maybe_entry is not None and maybe_entry.is_recent(self._now) is True) def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: """Check if an answer was seen in the last second. @@ -149,7 +149,7 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry and self._now - maybe_entry.created < _ONE_SECOND) + return bool(maybe_entry is not None and self._now - maybe_entry.created < _ONE_SECOND) class QueryHandler: @@ -174,7 +174,7 @@ def _add_service_type_enumeration_query_answers( dns_pointer = DNSPointer( _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, 0.0 ) - if not known_answers.suppresses(dns_pointer): + if known_answers.suppresses(dns_pointer) is False: answer_set[dns_pointer] = set() def _add_pointer_answers( @@ -185,7 +185,7 @@ def _add_pointer_answers( # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service._dns_pointer(None) - if known_answers.suppresses(dns_pointer): + if known_answers.suppresses(dns_pointer) is True: continue answer_set[dns_pointer] = { service._dns_service(None), @@ -208,7 +208,7 @@ def _add_address_answers( seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) - elif not known_answers.suppresses(dns_address): + elif known_answers.suppresses(dns_address) is False: answers.append(dns_address) missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: @@ -248,11 +248,11 @@ def _answer_question( # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. dns_service = service._dns_service(None) - if not known_answers.suppresses(dns_service): + if known_answers.suppresses(dns_service) is False: answer_set[dns_service] = service._get_address_and_nsec_records(None) if type_ in (_TYPE_TXT, _TYPE_ANY): dns_text = service._dns_text(None) - if not known_answers.suppresses(dns_text): + if known_answers.suppresses(dns_text) is False: answer_set[dns_text] = set() return answer_set diff --git a/src/zeroconf/_record_update.pxd b/src/zeroconf/_record_update.pxd new file mode 100644 index 000000000..d1b18cbe0 --- /dev/null +++ b/src/zeroconf/_record_update.pxd @@ -0,0 +1,10 @@ + +import cython + +from ._dns cimport DNSRecord + + +cdef class RecordUpdate: + + cdef public DNSRecord new + cdef public DNSRecord old diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index fbcacd5fc..5a3625340 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -20,11 +20,24 @@ USA """ -from typing import NamedTuple, Optional +from typing import Optional from ._dns import DNSRecord -class RecordUpdate(NamedTuple): - new: DNSRecord - old: Optional[DNSRecord] +class RecordUpdate: + + __slots__ = ("new", "old") + + def __init__(self, new: DNSRecord, old: Optional[DNSRecord] = None): + """RecordUpdate represents a change in a DNS record.""" + self.new = new + self.old = old + + def __getitem__(self, index: int) -> Optional[DNSRecord]: + """Get the new or old record.""" + if index == 0: + return self.new + elif index == 1: + return self.old + raise IndexError(index) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index a844d3330..c9b98a421 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -3,6 +3,7 @@ import cython from .._cache cimport DNSCache from .._protocol.outgoing cimport DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord +from .._record_update cimport RecordUpdate from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis, millis_to_seconds from . cimport Signal, SignalRegistrationInterface @@ -13,6 +14,7 @@ cdef object cached_possible_types cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT cdef cython.uint _TYPE_PTR cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED +cdef cython.set _ADDRESS_RECORD_TYPES cdef class _DNSPointerOutgoingBucket: @@ -43,6 +45,7 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cdef public cython.set types cdef public object zc + cdef DNSCache _cache cdef object _loop cdef public object addr cdef public object port @@ -60,10 +63,10 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _enqueue_callback(self, object state_change, object type_, object name) - @cython.locals(record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) + @cython.locals(record_update=RecordUpdate, record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) cpdef async_update_records(self, object zc, cython.float now, cython.list records) - cpdef _names_matching_types(self, object types) + cpdef cython.list _names_matching_types(self, object types) cpdef reschedule_type(self, object type_, object now, object next_time) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index b0b1a0790..15af8d91c 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -297,6 +297,7 @@ class _ServiceBrowserBase(RecordUpdateListener): __slots__ = ( 'types', 'zc', + '_cache', '_loop', 'addr', 'port', @@ -345,6 +346,7 @@ def __init__( # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) self.zc = zc + self._cache = zc.cache assert zc.loop is not None self._loop = zc.loop self.addr = addr @@ -421,8 +423,8 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record This method will be run in the event loop. """ for record_update in records: - record = record_update[0] - old_record = record_update[1] + record = record_update.new + old_record = record_update.old record_type = record.type if record_type is _TYPE_PTR: @@ -440,15 +442,14 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record continue # If its expired or already exists in the cache it cannot be updated. - if old_record or record.is_expired(now) is True: + if old_record is not None or record.is_expired(now) is True: continue if record_type in _ADDRESS_RECORD_TYPES: - cache = self.zc.cache + cache = self._cache + names = {service.name for service in cache.async_entries_with_server(record.name)} # Iterate through the DNSCache and callback any services that use this address - for type_, name in self._names_matching_types( - {service.name for service in cache.async_entries_with_server(record.name)} - ): + for type_, name in self._names_matching_types(names): self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) continue diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 2e516a9ef..0461bf001 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -4,6 +4,7 @@ import cython from .._cache cimport DNSCache from .._dns cimport DNSAddress, DNSNsec, DNSPointer, DNSRecord, DNSService, DNSText from .._protocol.outgoing cimport DNSOutgoing +from .._record_update cimport RecordUpdate from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis @@ -56,7 +57,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public cython.list _dns_address_cache cdef public cython.set _get_address_and_nsec_records_cache - @cython.locals(cache=DNSCache) + @cython.locals(record_update=RecordUpdate, update=bint, cache=DNSCache) cpdef async_update_records(self, object zc, cython.float now, cython.list records) @cython.locals(cache=DNSCache) @@ -76,7 +77,7 @@ cdef class ServiceInfo(RecordUpdateListener): dns_text_record=DNSText, dns_address_record=DNSAddress ) - cdef _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) + cdef bint _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) @cython.locals(cache=DNSCache) cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) @@ -109,3 +110,6 @@ cdef class ServiceInfo(RecordUpdateListener): cdef cython.set _get_address_and_nsec_records(self, object override_ttl) cpdef async_clear_cache(self) + + @cython.locals(cache=DNSCache) + cdef _generate_request_query(self, object zc, object now, object question_type) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index ee033c823..fab6b4109 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -420,7 +420,7 @@ def _get_ip_addresses_from_cache_lifo( """Set IPv6 addresses from the cache.""" address_list: List[Union[IPv4Address, IPv6Address]] = [] for record in self._get_address_records_from_cache_by_type(zc, type): - if record.is_expired(now): + if record.is_expired(now) is True: continue ip_addr = _cached_ip_addresses_wrapper(record.address) if ip_addr is not None: @@ -463,7 +463,7 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo Returns True if a new record was added. """ - if record.is_expired(now): + if record.is_expired(now) is True: return False record_key = record.key @@ -779,7 +779,7 @@ async def async_request( now = current_time_millis() - if self._load_from_cache(zc, now): + if self._load_from_cache(zc, now) is True: return True if TYPE_CHECKING: @@ -795,7 +795,7 @@ async def async_request( if last <= now: return False if next_ <= now: - out = self.generate_request_query( + out = self._generate_request_query( zc, now, question_type or DNS_QUESTION_TYPE_QU if first_request else DNS_QUESTION_TYPE_QM, @@ -815,8 +815,8 @@ async def async_request( return True - def generate_request_query( - self, zc: 'Zeroconf', now: float_, question_type: Optional[DNSQuestionType] = None + def _generate_request_query( + self, zc: 'Zeroconf', now: float_, question_type: DNSQuestionType ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index a117cc2b2..42fa82850 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -68,7 +68,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record This method will be run in the event loop. """ for record in records: - self.update_record(zc, now, record[0]) + self.update_record(zc, now, record.new) def async_update_records_complete(self) -> None: """Called when a record update has completed for all handlers. diff --git a/tests/test_updates.py b/tests/test_updates.py index 46f5b50b3..eb071adf8 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -11,6 +11,7 @@ import zeroconf as r from zeroconf import Zeroconf, const +from zeroconf._record_update import RecordUpdate from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo @@ -87,3 +88,16 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zc.remove_listener(listener) zc.close() + + +def test_record_update_compat(): + """Test a RecordUpdate can fetch by index.""" + new = r.DNSPointer('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 'new') + old = r.DNSPointer('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 'old') + update = RecordUpdate(new, old) + assert update[0] == new + assert update[1] == old + with pytest.raises(IndexError): + update[2] + assert update.new == new + assert update.old == old From aff7276b6208bb4bb18ca0708db5600d502ea2bb Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 8 Nov 2023 14:40:43 +0000 Subject: [PATCH 1011/1433] 0.121.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ba7b932..e8f8d154b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.121.0 (2023-11-08) + +### Feature + +* Speed up record updates ([#1301](https://github.com/python-zeroconf/python-zeroconf/issues/1301)) ([`d2af6a0`](https://github.com/python-zeroconf/python-zeroconf/commit/d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4)) + ## v0.120.0 (2023-11-05) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 95cfbd979..a31b50c25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.120.0" +version = "0.121.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 05141b33e..ca9d69803 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.120.0' +__version__ = '0.121.0' __license__ = 'LGPL' From 4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 16:15:47 -0600 Subject: [PATCH 1012/1433] feat: build aarch64 wheels (#1302) --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91ae876c6..5e827bebe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,12 +146,19 @@ jobs: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v1 + with: + platforms: arm64 + - name: Build wheels uses: pypa/cibuildwheel@v2.16.2 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc + CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 From 0b94856839906d336258608526e2bacbd3ea3457 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 8 Nov 2023 22:32:03 +0000 Subject: [PATCH 1013/1433] 0.122.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f8d154b..1cc9ea4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.122.0 (2023-11-08) + +### Feature + +* Build aarch64 wheels ([#1302](https://github.com/python-zeroconf/python-zeroconf/issues/1302)) ([`4fe58e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83)) + ## v0.121.0 (2023-11-08) ### Feature diff --git a/pyproject.toml b/pyproject.toml index a31b50c25..6f06f3594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.121.0" +version = "0.122.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ca9d69803..bf391d39d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.121.0' +__version__ = '0.122.0' __license__ = 'LGPL' From 5500591afbb4198655f0527788490758fce7600a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 16:45:24 -0600 Subject: [PATCH 1014/1433] chore: bump setup-qemu-action to v3 (#1303) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e827bebe..2b5fca4cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,7 @@ jobs: - name: Set up QEMU if: runner.os == 'Linux' - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 with: platforms: arm64 From 6c8f5a5dec2072aa6a8f889c5d8a4623ab392234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 22:12:00 -0600 Subject: [PATCH 1015/1433] fix: skip wheel builds for eol python and older python with aarch64 (#1304) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b5fca4cb..d2bc4959c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: uses: pypa/cibuildwheel@v2.16.2 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 From b1a8a071449709da60c25b62f6ee47714b70f927 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 9 Nov 2023 04:21:20 +0000 Subject: [PATCH 1016/1433] 0.122.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc9ea4ba..a5f3e88ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.122.1 (2023-11-09) + +### Fix + +* Skip wheel builds for eol python and older python with aarch64 ([#1304](https://github.com/python-zeroconf/python-zeroconf/issues/1304)) ([`6c8f5a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6c8f5a5dec2072aa6a8f889c5d8a4623ab392234)) + ## v0.122.0 (2023-11-08) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 6f06f3594..ba8243c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.122.0" +version = "0.122.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index bf391d39d..36adc5763 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.122.0' +__version__ = '0.122.1' __license__ = 'LGPL' From 7e884db4d958459e64257aba860dba2450db0687 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Nov 2023 23:21:08 -0600 Subject: [PATCH 1017/1433] fix: do not build aarch64 wheels for PyPy (#1305) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2bc4959c..8f61d900c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: uses: pypa/cibuildwheel@v2.16.2 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 From 9b284aa4651975cb298364b17d62a67d7d899c5b Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 9 Nov 2023 05:30:20 +0000 Subject: [PATCH 1018/1433] 0.122.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f3e88ae..ffef1cda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.122.2 (2023-11-09) + +### Fix + +* Do not build aarch64 wheels for PyPy ([#1305](https://github.com/python-zeroconf/python-zeroconf/issues/1305)) ([`7e884db`](https://github.com/python-zeroconf/python-zeroconf/commit/7e884db4d958459e64257aba860dba2450db0687)) + ## v0.122.1 (2023-11-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index ba8243c3d..fb42b70aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.122.1" +version = "0.122.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 36adc5763..3cf8bac20 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.122.1' +__version__ = '0.122.2' __license__ = 'LGPL' From 79aafb0acf7ca6b17976be7ede748008deada27b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Nov 2023 07:31:41 -0600 Subject: [PATCH 1019/1433] fix: do not build musllinux aarch64 wheels to reduce release time (#1306) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f61d900c..da9db349f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,7 @@ jobs: uses: pypa/cibuildwheel@v2.16.2 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 From 9ca9a57470b17cf683e40f4e397c7e260730545b Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 9 Nov 2023 13:52:35 +0000 Subject: [PATCH 1020/1433] 0.122.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffef1cda6..29154b5c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.122.3 (2023-11-09) + +### Fix + +* Do not build musllinux aarch64 wheels to reduce release time ([#1306](https://github.com/python-zeroconf/python-zeroconf/issues/1306)) ([`79aafb0`](https://github.com/python-zeroconf/python-zeroconf/commit/79aafb0acf7ca6b17976be7ede748008deada27b)) + ## v0.122.2 (2023-11-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index fb42b70aa..8226a8598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.122.2" +version = "0.122.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3cf8bac20..55370b2b2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.122.2' +__version__ = '0.122.3' __license__ = 'LGPL' From 0701b8ab6009891cbaddaa1d17116d31fd1b2f78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Nov 2023 23:42:39 -0600 Subject: [PATCH 1021/1433] feat: speed up instances only used to lookup answers (#1307) --- src/zeroconf/_listener.pxd | 11 ++++++++ src/zeroconf/_listener.py | 12 ++++++--- src/zeroconf/_services/registry.pxd | 5 ++++ src/zeroconf/_services/registry.py | 10 +++++--- tests/test_core.py | 39 ++++++++++++++++++----------- tests/test_listener.py | 12 ++++++++- 6 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 3b1d62313..ec877c78b 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -3,6 +3,7 @@ import cython from ._handlers.record_manager cimport RecordManager from ._protocol.incoming cimport DNSIncoming +from ._services.registry cimport ServiceRegistry from ._utils.time cimport current_time_millis, millis_to_seconds @@ -18,6 +19,7 @@ cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL cdef class AsyncListener: cdef public object zc + cdef ServiceRegistry _registry cdef RecordManager _record_manager cdef public cython.bytes data cdef public cython.float last_time @@ -34,3 +36,12 @@ cdef class AsyncListener: cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, cython.float now, bytes data, cython.tuple addrs) cdef _cancel_any_timers_for_addr(self, object addr) + + cpdef handle_query_or_defer( + self, + DNSIncoming msg, + object addr, + object port, + object transport, + tuple v6_flow_scope + ) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index c27d1b610..07d059eb0 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -57,6 +57,7 @@ class AsyncListener: __slots__ = ( 'zc', + '_registry', '_record_manager', 'data', 'last_time', @@ -69,6 +70,7 @@ class AsyncListener: def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc + self._registry = zc.registry self._record_manager = zc.record_manager self.data: Optional[bytes] = None self.last_time: float = 0 @@ -171,6 +173,10 @@ def _process_datagram_at_time( self._record_manager.async_updates_from_response(msg) return + if not self._registry.has_entries: + # If the registry is empty, we have no answers to give. + return + if TYPE_CHECKING: assert self.transport is not None self.handle_query_or_defer(msg, addr, port, self.transport, v6_flow_scope) @@ -178,10 +184,10 @@ def _process_datagram_at_time( def handle_query_or_defer( self, msg: DNSIncoming, - addr: str, - port: int, + addr: _str, + port: _int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + v6_flow_scope: Union[Tuple[()], Tuple[int, int]], ) -> None: """Deal with incoming query packets. Provides a response if possible.""" diff --git a/src/zeroconf/_services/registry.pxd b/src/zeroconf/_services/registry.pxd index 1d0562c3b..6f9017db7 100644 --- a/src/zeroconf/_services/registry.pxd +++ b/src/zeroconf/_services/registry.pxd @@ -9,6 +9,7 @@ cdef class ServiceRegistry: cdef cython.dict _services cdef public cython.dict types cdef public cython.dict servers + cdef public bint has_entries @cython.locals( record_list=cython.list, @@ -17,6 +18,10 @@ cdef class ServiceRegistry: cdef _add(self, ServiceInfo info) + @cython.locals( + info=ServiceInfo, + old_service_info=ServiceInfo + ) cdef _remove(self, cython.list infos) cpdef ServiceInfo async_get_info_name(self, str name) diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index e9dc4a62b..261e8e9cd 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -35,7 +35,7 @@ class ServiceRegistry: the event loop as it is not thread safe. """ - __slots__ = ("_services", "types", "servers") + __slots__ = ("_services", "types", "servers", "has_entries") def __init__( self, @@ -44,6 +44,7 @@ def __init__( self._services: Dict[str, ServiceInfo] = {} self.types: Dict[str, List] = {} self.servers: Dict[str, List] = {} + self.has_entries: bool = False def async_add(self, info: ServiceInfo) -> None: """Add a new service to the registry.""" @@ -95,14 +96,17 @@ def _add(self, info: ServiceInfo) -> None: self._services[info.key] = info self.types.setdefault(info.type.lower(), []).append(info.key) self.servers.setdefault(info.server_key, []).append(info.key) + self.has_entries = True def _remove(self, infos: List[ServiceInfo]) -> None: """Remove a services under the lock.""" for info in infos: - if info.key not in self._services: + old_service_info = self._services.get(info.key) + if old_service_info is None: continue - old_service_info = self._services[info.key] assert old_service_info.server_key is not None self.types[old_service_info.type.lower()].remove(info.key) self.servers[old_service_info.server_key].remove(info.key) del self._services[info.key] + + self.has_entries = bool(self._services) diff --git a/tests/test_core.py b/tests/test_core.py index 4bce6db97..de4b2ef5b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,12 +12,10 @@ import time import unittest import unittest.mock -from typing import cast -from unittest.mock import patch +from typing import Tuple, Union, cast +from unittest.mock import Mock, patch if sys.version_info[:3][1] < 8: - from unittest.mock import Mock - AsyncMock = Mock else: from unittest.mock import AsyncMock @@ -26,6 +24,8 @@ import zeroconf as r from zeroconf import NotRunningException, Zeroconf, const, current_time_millis +from zeroconf._listener import AsyncListener, _WrappedTransport +from zeroconf._protocol.incoming import DNSIncoming from zeroconf.asyncio import AsyncZeroconf from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 @@ -45,10 +45,19 @@ def teardown_module(): log.setLevel(original_logging_level) -def threadsafe_query(zc, protocol, *args): +def threadsafe_query( + zc: 'Zeroconf', + protocol: 'AsyncListener', + msg: DNSIncoming, + addr: str, + port: int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]], +) -> None: async def make_query(): - protocol.handle_query_or_defer(*args) + protocol.handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + assert zc.loop is not None asyncio.run_coroutine_threadsafe(make_query(), zc.loop).result() @@ -476,28 +485,28 @@ def test_tc_bit_defers(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert source_ip not in protocol._deferred assert source_ip not in protocol._timers @@ -555,20 +564,20 @@ def test_tc_bit_defers_last_response_missing(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred timer1 = protocol._timers[source_ip] next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred timer2 = protocol._timers[source_ip] assert timer1.cancelled() assert timer2 != timer1 # Send the same packet again to similar multi interfaces - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer3 = protocol._timers[source_ip] @@ -577,7 +586,7 @@ def test_tc_bit_defers_last_response_missing(): next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, None) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) assert protocol._deferred[source_ip] == expected_deferred assert source_ip in protocol._timers timer4 = protocol._timers[source_ip] diff --git a/tests/test_listener.py b/tests/test_listener.py index dff01d78b..bd8022736 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -10,7 +10,14 @@ from unittest.mock import MagicMock, patch import zeroconf as r -from zeroconf import Zeroconf, _engine, _listener, const, current_time_millis +from zeroconf import ( + ServiceInfo, + Zeroconf, + _engine, + _listener, + const, + current_time_millis, +) from zeroconf._protocol import outgoing from zeroconf._protocol.incoming import DNSIncoming @@ -125,6 +132,9 @@ def test_guard_against_duplicate_packets(): These packets can quickly overwhelm the system. """ zc = Zeroconf(interfaces=['127.0.0.1']) + zc.registry.async_add( + ServiceInfo("_http._tcp.local.", "Test._http._tcp.local.", server="Test._http._tcp.local.", port=4) + ) zc.question_history = QuestionHistoryWithoutSuppression() class SubListener(_listener.AsyncListener): From d793e1365e351858380d5b7e4bd74399f01f4bbd Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 12 Nov 2023 05:53:27 +0000 Subject: [PATCH 1022/1433] 0.123.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29154b5c5..1b9877061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.123.0 (2023-11-12) + +### Feature + +* Speed up instances only used to lookup answers ([#1307](https://github.com/python-zeroconf/python-zeroconf/issues/1307)) ([`0701b8a`](https://github.com/python-zeroconf/python-zeroconf/commit/0701b8ab6009891cbaddaa1d17116d31fd1b2f78)) + ## v0.122.3 (2023-11-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 8226a8598..a735b5fb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.122.3" +version = "0.123.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 55370b2b2..2e9aad9f0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.122.3' +__version__ = '0.123.0' __license__ = 'LGPL' From 56ef90865189c01d2207abcc5e2efe3a7a022fa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 12:32:57 -0600 Subject: [PATCH 1023/1433] feat: small speed up to process incoming packets (#1309) --- src/zeroconf/_core.py | 2 +- src/zeroconf/_listener.pxd | 10 ++++++++++ src/zeroconf/_listener.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 403754846..7f60a695b 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -573,7 +573,7 @@ def handle_assembled_query( addr: str, port: int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + v6_flow_scope: Union[Tuple[()], Tuple[int, int]], ) -> None: """Respond to a (re)assembled query. diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index ec877c78b..729e0de69 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -37,6 +37,7 @@ cdef class AsyncListener: cdef _cancel_any_timers_for_addr(self, object addr) + @cython.locals(incoming=DNSIncoming, deferred=list) cpdef handle_query_or_defer( self, DNSIncoming msg, @@ -45,3 +46,12 @@ cdef class AsyncListener: object transport, tuple v6_flow_scope ) + + cpdef _respond_query( + self, + object msg, + object addr, + object port, + object transport, + tuple v6_flow_scope + ) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 07d059eb0..700029e17 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -220,7 +220,7 @@ def _respond_query( addr: _str, port: _int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + v6_flow_scope: Union[Tuple[()], Tuple[int, int]], ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" self._cancel_any_timers_for_addr(addr) From ce98cb8a06f20c49cebd5691d464f3caa803f8cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 12:35:45 -0600 Subject: [PATCH 1024/1433] chore(deps): bump cython to >= 3.0.5 (#1310) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a735b5fb0..87b971349 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,7 +151,7 @@ ignore_errors = true [build-system] # 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505 -requires = ['setuptools>=65.4.1', 'wheel', 'Cython', "poetry-core>=1.5.2"] +requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.5', "poetry-core>=1.5.2"] build-backend = "poetry.core.masonry.api" [tool.codespell] From 605dc9ccd843a535802031f051b3d93310186ad1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 12:50:48 -0600 Subject: [PATCH 1025/1433] feat: avoid decoding known answers if we have no answers to give (#1308) --- src/zeroconf/_core.py | 6 +- src/zeroconf/_handlers/answers.py | 8 + .../_handlers/multicast_outgoing_queue.py | 4 +- src/zeroconf/_handlers/query_handler.pxd | 33 ++- src/zeroconf/_handlers/query_handler.py | 203 +++++++++++++----- src/zeroconf/_record_update.py | 1 - tests/test_handlers.py | 89 +++++++- 7 files changed, 270 insertions(+), 74 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 7f60a695b..5827e2d5b 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -577,15 +577,17 @@ def handle_assembled_query( ) -> None: """Respond to a (re)assembled query. - If the protocol recieved packets with the TC bit set, it will + If the protocol received packets with the TC bit set, it will wait a bit for the rest of the packets and only call handle_assembled_query once it has a complete set of packets or the timer expires. If the TC bit is not set, a single packet will be in packets. """ - now = packets[0].now ucast_source = port != _MDNS_PORT question_answers = self.query_handler.async_response(packets, ucast_source) + if not question_answers: + return + now = packets[0].now if question_answers.ucast: questions = packets[0].questions id_ = packets[0].id diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index 6ba502ac9..a2dbd66aa 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -59,6 +59,14 @@ def __init__( self.mcast_aggregate = mcast_aggregate self.mcast_aggregate_last_second = mcast_aggregate_last_second + def __repr__(self) -> str: + """Return a string representation of this QuestionAnswers.""" + return ( + f'QuestionAnswers(ucast={self.ucast}, mcast_now={self.mcast_now}, ' + f'mcast_aggregate={self.mcast_aggregate}, ' + f'mcast_aggregate_last_second={self.mcast_aggregate_last_second})' + ) + class AnswerGroup: """A group of answers scheduled to be sent at the same time.""" diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index 1d398d736..23288d18d 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -77,7 +77,7 @@ def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: # If we calculate a random delay for the send after time # that is less than the last group scheduled to go out, # we instead add the answers to the last group as this - # allows aggregating additonal responses + # allows aggregating additional responses last_group = self.queue[-1] if send_after <= last_group.send_after: last_group.answers.update(answers) @@ -116,7 +116,7 @@ def async_ready(self) -> None: # be sure we schedule them to go out later loop.call_at(loop.time() + millis_to_seconds(self.queue[0].send_after - now), self.async_ready) - if answers: + if answers: # pragma: no branch # If we have the same answer scheduled to go out, remove them self._remove_answers_from_queue(answers) zc.async_send(construct_outgoing_multicast_answers(answers)) diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index ff970d766..8c42144ca 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -18,6 +18,23 @@ cdef cython.set _ADDRESS_RECORD_TYPES cdef object IPVersion, _IPVersion_ALL cdef object _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL +cdef unsigned int _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION +cdef unsigned int _ANSWER_STRATEGY_POINTER +cdef unsigned int _ANSWER_STRATEGY_ADDRESS +cdef unsigned int _ANSWER_STRATEGY_SERVICE +cdef unsigned int _ANSWER_STRATEGY_TEXT + +cdef list _EMPTY_SERVICES_LIST +cdef list _EMPTY_TYPES_LIST + +cdef class _AnswerStrategy: + + cdef public DNSQuestion question + cdef public unsigned int strategy_type + cdef public list types + cdef public list services + + cdef class _QueryResponse: cdef bint _is_probe @@ -53,24 +70,30 @@ cdef class QueryHandler: cdef QuestionHistory question_history @cython.locals(service=ServiceInfo) - cdef _add_service_type_enumeration_query_answers(self, cython.dict answer_set, DNSRRSet known_answers) + cdef _add_service_type_enumeration_query_answers(self, list types, cython.dict answer_set, DNSRRSet known_answers) @cython.locals(service=ServiceInfo) - cdef _add_pointer_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers) + cdef _add_pointer_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers) @cython.locals(service=ServiceInfo, dns_address=DNSAddress) - cdef _add_address_answers(self, str lower_name, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) + cdef _add_address_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) - cdef cython.dict _answer_question(self, DNSQuestion question, DNSRRSet known_answers) + cdef cython.dict _answer_question(self, DNSQuestion question, unsigned int strategy_type, list types, list services, DNSRRSet known_answers) @cython.locals( msg=DNSIncoming, + msgs=list, + strategy=_AnswerStrategy, question=DNSQuestion, answer_set=cython.dict, known_answers=DNSRRSet, known_answers_set=cython.set, + is_unicast=bint, is_probe=object, - now=object + now=float ) cpdef async_response(self, cython.list msgs, cython.bint unicast_source) + + @cython.locals(name=str, question_lower_name=str) + cdef _get_answer_strategies(self, DNSQuestion question) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 4e74aa5c0..0af72f4c6 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -20,13 +20,13 @@ USA """ - from typing import TYPE_CHECKING, List, Optional, Set, cast from .._cache import DNSCache, _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet from .._history import QuestionHistory from .._protocol.incoming import DNSIncoming +from .._services.info import ServiceInfo from .._services.registry import ServiceRegistry from .._utils.net import IPVersion from ..const import ( @@ -47,11 +47,39 @@ _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} +_EMPTY_SERVICES_LIST: List[ServiceInfo] = [] +_EMPTY_TYPES_LIST: List[str] = [] + _IPVersion_ALL = IPVersion.All _int = int +_ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION = 0 +_ANSWER_STRATEGY_POINTER = 1 +_ANSWER_STRATEGY_ADDRESS = 2 +_ANSWER_STRATEGY_SERVICE = 3 +_ANSWER_STRATEGY_TEXT = 4 + + +class _AnswerStrategy: + + __slots__ = ("question", "strategy_type", "types", "services") + + def __init__( + self, + question: DNSQuestion, + strategy_type: _int, + types: List[str], + services: List[ServiceInfo], + ) -> None: + """Create an answer strategy.""" + self.question = question + self.strategy_type = strategy_type + self.types = types + self.services = services + + class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" @@ -164,13 +192,13 @@ def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: self.question_history = question_history def _add_service_type_enumeration_query_answers( - self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, types: List[str], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet ) -> None: """Provide an answer to a service type enumeration query. https://datatracker.ietf.org/doc/html/rfc6763#section-9 """ - for stype in self.registry.async_get_types(): + for stype in types: dns_pointer = DNSPointer( _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, 0.0 ) @@ -178,10 +206,10 @@ def _add_service_type_enumeration_query_answers( answer_set[dns_pointer] = set() def _add_pointer_answers( - self, lower_name: str, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, services: List[ServiceInfo], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet ) -> None: """Answer PTR/ANY question.""" - for service in self.registry.async_get_infos_type(lower_name): + for service in services: # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service._dns_pointer(None) @@ -190,17 +218,18 @@ def _add_pointer_answers( answer_set[dns_pointer] = { service._dns_service(None), service._dns_text(None), - } | service._get_address_and_nsec_records(None) + *service._get_address_and_nsec_records(None), + } def _add_address_answers( self, - lower_name: str, + services: List[ServiceInfo], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, type_: _int, ) -> None: """Answer A/AAAA/ANY question.""" - for service in self.registry.async_get_infos_server(lower_name): + for service in services: answers: List[DNSAddress] = [] additionals: Set[DNSRecord] = set() seen_types: Set[int] = set() @@ -224,75 +253,135 @@ def _add_address_answers( def _answer_question( self, question: DNSQuestion, + strategy_type: _int, + types: List[str], + services: List[ServiceInfo], known_answers: DNSRRSet, ) -> _AnswerWithAdditionalsType: """Answer a question.""" answer_set: _AnswerWithAdditionalsType = {} - question_lower_name = question.name.lower() - type_ = question.type - - if type_ == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: - self._add_service_type_enumeration_query_answers(answer_set, known_answers) - return answer_set - - if type_ in (_TYPE_PTR, _TYPE_ANY): - self._add_pointer_answers(question_lower_name, answer_set, known_answers) - if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): - self._add_address_answers(question_lower_name, answer_set, known_answers, type_) - - if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): - service = self.registry.async_get_info_name(question_lower_name) - if service is not None: - if type_ in (_TYPE_SRV, _TYPE_ANY): - # Add recommended additional answers according to - # https://tools.ietf.org/html/rfc6763#section-12.2. - dns_service = service._dns_service(None) - if known_answers.suppresses(dns_service) is False: - answer_set[dns_service] = service._get_address_and_nsec_records(None) - if type_ in (_TYPE_TXT, _TYPE_ANY): - dns_text = service._dns_text(None) - if known_answers.suppresses(dns_text) is False: - answer_set[dns_text] = set() + if strategy_type == _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION: + self._add_service_type_enumeration_query_answers(types, answer_set, known_answers) + elif strategy_type == _ANSWER_STRATEGY_POINTER: + self._add_pointer_answers(services, answer_set, known_answers) + elif strategy_type == _ANSWER_STRATEGY_ADDRESS: + self._add_address_answers(services, answer_set, known_answers, question.type) + elif strategy_type == _ANSWER_STRATEGY_SERVICE: + # Add recommended additional answers according to + # https://tools.ietf.org/html/rfc6763#section-12.2. + service = services[0] + dns_service = service._dns_service(None) + if known_answers.suppresses(dns_service) is False: + answer_set[dns_service] = service._get_address_and_nsec_records(None) + elif strategy_type == _ANSWER_STRATEGY_TEXT: # pragma: no branch + service = services[0] + dns_text = service._dns_text(None) + if known_answers.suppresses(dns_text) is False: + answer_set[dns_text] = set() return answer_set def async_response( # pylint: disable=unused-argument self, msgs: List[DNSIncoming], ucast_source: bool - ) -> QuestionAnswers: + ) -> Optional[QuestionAnswers]: """Deal with incoming query packets. Provides a response if possible. This function must be run in the event loop as it is not threadsafe. """ - answers: List[DNSRecord] = [] + strategies: List[_AnswerStrategy] = [] + for msg in msgs: + for question in msg.questions: + strategies.extend(self._get_answer_strategies(question)) + + if not strategies: + # We have no way to answer the question because we have + # nothing in the ServiceRegistry that matches or we do not + # understand the question. + return None + is_probe = False - msg = msgs[0] questions = msg.questions - now = msg.now + # Only decode known answers if we are not a probe and we have + # at least one answer strategy + answers: List[DNSRecord] = [] for msg in msgs: - if msg.is_probe() is False: - answers.extend(msg.answers()) - else: + if msg.is_probe() is True: is_probe = True + else: + answers.extend(msg.answers()) + + msg = msgs[0] + query_res = _QueryResponse(self.cache, questions, is_probe, msg.now) known_answers = DNSRRSet(answers) - query_res = _QueryResponse(self.cache, questions, is_probe, now) known_answers_set: Optional[Set[DNSRecord]] = None - - for msg in msgs: - for question in msg.questions: - if not question.unique: # unique and unicast are the same flag - if not known_answers_set: # pragma: no branch - known_answers_set = known_answers.lookup_set() - self.question_history.add_question_at_time(question, now, known_answers_set) - answer_set = self._answer_question(question, known_answers) - if not ucast_source and question.unique: # unique and unicast are the same flag - query_res.add_qu_question_response(answer_set) - continue - if ucast_source: - query_res.add_ucast_question_response(answer_set) - # We always multicast as well even if its a unicast - # source as long as we haven't done it recently (75% of ttl) - query_res.add_mcast_question_response(answer_set) + now = msg.now + for strategy in strategies: + question = strategy.question + is_unicast = question.unique is True # unique and unicast are the same flag + if not is_unicast: + if known_answers_set is None: # pragma: no branch + known_answers_set = known_answers.lookup_set() + self.question_history.add_question_at_time(question, now, known_answers_set) + answer_set = self._answer_question( + question, strategy.strategy_type, strategy.types, strategy.services, known_answers + ) + if not ucast_source and is_unicast: + query_res.add_qu_question_response(answer_set) + continue + if ucast_source: + query_res.add_ucast_question_response(answer_set) + # We always multicast as well even if its a unicast + # source as long as we haven't done it recently (75% of ttl) + query_res.add_mcast_question_response(answer_set) return query_res.answers() + + def _get_answer_strategies( + self, + question: DNSQuestion, + ) -> List[_AnswerStrategy]: + """Collect strategies to answer a question.""" + name = question.name + question_lower_name = name.lower() + type_ = question.type + strategies: List[_AnswerStrategy] = [] + + if type_ == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: + types = self.registry.async_get_types() + if types: + strategies.append( + _AnswerStrategy( + question, _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION, types, _EMPTY_SERVICES_LIST + ) + ) + return strategies + + if type_ in (_TYPE_PTR, _TYPE_ANY): + services = self.registry.async_get_infos_type(question_lower_name) + if services: + strategies.append( + _AnswerStrategy(question, _ANSWER_STRATEGY_POINTER, _EMPTY_TYPES_LIST, services) + ) + + if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): + services = self.registry.async_get_infos_server(question_lower_name) + if services: + strategies.append( + _AnswerStrategy(question, _ANSWER_STRATEGY_ADDRESS, _EMPTY_TYPES_LIST, services) + ) + + if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): + service = self.registry.async_get_info_name(question_lower_name) + if service is not None: + if type_ in (_TYPE_SRV, _TYPE_ANY): + strategies.append( + _AnswerStrategy(question, _ANSWER_STRATEGY_SERVICE, _EMPTY_TYPES_LIST, [service]) + ) + if type_ in (_TYPE_TXT, _TYPE_ANY): + strategies.append( + _AnswerStrategy(question, _ANSWER_STRATEGY_TEXT, _EMPTY_TYPES_LIST, [service]) + ) + + return strategies diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index 5a3625340..8e0e4bdb0 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -26,7 +26,6 @@ class RecordUpdate: - __slots__ = ("new", "old") def __init__(self, new: DNSRecord, old: Optional[DNSRecord] = None): diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 13fe3a516..1a1066fa2 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -107,6 +107,7 @@ def _process_outgoing_packet(out): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # The additonals should all be suppresed since they are all in the answers section @@ -145,6 +146,7 @@ def _process_outgoing_packet(out): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # There will be one NSEC additional to indicate the lack of AAAA record @@ -244,6 +246,7 @@ def test_ptr_optimization(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -260,6 +263,7 @@ def test_ptr_optimization(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate_last_second @@ -305,6 +309,7 @@ def test_any_query_for_ptr(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers mcast_answers = list(question_answers.mcast_aggregate) assert mcast_answers[0].name == type_ assert mcast_answers[0].alias == registration_name # type: ignore[attr-defined] @@ -332,6 +337,7 @@ def test_aaaa_query(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] # unregister @@ -358,6 +364,7 @@ def test_aaaa_query_upper_case(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] # unregister @@ -391,6 +398,7 @@ def test_a_and_aaaa_record_fate_sharing(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert aaaa_record in question_answers.mcast_now assert a_record in additionals @@ -403,6 +411,7 @@ def test_a_and_aaaa_record_fate_sharing(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert a_record in question_answers.mcast_now assert aaaa_record in additionals @@ -437,6 +446,7 @@ def test_unicast_response(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], True ) + assert question_answers for answers in (question_answers.ucast, question_answers.mcast_aggregate): has_srv = has_txt = has_a = has_aaaa = has_nsec = False nbr_additionals = 0 @@ -486,6 +496,7 @@ async def test_probe_answered_immediately(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -499,6 +510,7 @@ async def test_probe_answered_immediately(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert question_answers.ucast assert question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -528,6 +540,7 @@ async def test_probe_answered_immediately_with_uppercase_name(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -541,6 +554,7 @@ async def test_probe_answered_immediately_with_uppercase_name(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert question_answers.ucast assert question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -607,6 +621,7 @@ def _validate_complete_response(answers): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers _validate_complete_response(question_answers.ucast) assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -622,6 +637,7 @@ def _validate_complete_response(answers): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate @@ -637,6 +653,7 @@ def _validate_complete_response(answers): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers _validate_complete_response(question_answers.ucast) _validate_complete_response(question_answers.mcast_now) @@ -652,6 +669,7 @@ def _validate_complete_response(answers): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.mcast_now assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -681,6 +699,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert question_answers.mcast_aggregate @@ -692,6 +711,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -703,6 +723,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -715,6 +736,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -728,6 +750,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast expected_nsec_record = cast(r.DNSNsec, list(question_answers.mcast_now)[0]) assert const._TYPE_A not in expected_nsec_record.rdtypes @@ -741,6 +764,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -752,6 +776,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -763,6 +788,7 @@ def test_known_answer_supression(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert question_answers.mcast_aggregate @@ -774,6 +800,7 @@ def test_known_answer_supression(): generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -827,6 +854,7 @@ def test_multi_packet_known_answer_supression(): packets = generated.packets() assert len(packets) > 1 question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -868,6 +896,7 @@ def test_known_answer_supression_service_type_enumeration_query(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert question_answers.mcast_aggregate @@ -898,6 +927,7 @@ def test_known_answer_supression_service_type_enumeration_query(): ) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert not question_answers.mcast_aggregate @@ -938,6 +968,7 @@ def test_upper_case_enumeration_query(): generated.add_question(question) packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now assert question_answers.mcast_aggregate @@ -948,6 +979,19 @@ def test_upper_case_enumeration_query(): zc.close() +def test_enumeration_query_with_no_registered_services(): + zc = Zeroconf(interfaces=['127.0.0.1']) + _clear_cache(zc) + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) + generated.add_question(question) + packets = generated.packets() + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert not question_answers + # unregister + zc.close() + + # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio @@ -1000,6 +1044,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.mcast_now assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -1024,6 +1069,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.mcast_now assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -1047,6 +1093,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -1075,6 +1122,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) + assert question_answers assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second @@ -1235,8 +1283,22 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu now = current_time_millis() _clear_cache(zc) + aiozc.zeroconf.registry.async_add( + ServiceInfo( + "_hap._tcp.local.", + "other._hap._tcp.local.", + 80, + 0, + 0, + {"md": "known"}, + "ash-2.local.", + addresses=[socket.inet_aton("1.2.3.4")], + ) + ) + services = aiozc.zeroconf.registry.async_get_infos_type("_hap._tcp.local.") + assert len(services) == 1 generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) question.unicast = False known_answer = r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' @@ -1246,9 +1308,10 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu now = r.current_time_millis() packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now - assert not question_answers.mcast_aggregate + assert question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second assert zc.question_history.suppresses(question, now, {known_answer}) @@ -1261,20 +1324,32 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): zc = aiozc.zeroconf now = current_time_millis() _clear_cache(zc) - + info = ServiceInfo( + "_hap._tcp.local.", + "qu._hap._tcp.local.", + 80, + 0, + 0, + {"md": "known"}, + "ash-2.local.", + addresses=[socket.inet_aton("1.2.3.4")], + ) + aiozc.zeroconf.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) question.unicast = True known_answer = r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'notqu._hap._tcp.local.' ) generated.add_question(question) generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) - assert not question_answers.ucast - assert not question_answers.mcast_now + assert question_answers + assert "qu._hap._tcp.local." in str(question_answers) + assert not question_answers.ucast # has not multicast recently + assert question_answers.mcast_now assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second assert not zc.question_history.suppresses(question, now, {known_answer}) From e60cc41730f209eddd2a54b0c424b1fb604ce00a Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 12 Nov 2023 19:00:20 +0000 Subject: [PATCH 1026/1433] 0.124.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9877061..2594d5b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.124.0 (2023-11-12) + +### Feature + +* Avoid decoding known answers if we have no answers to give ([#1308](https://github.com/python-zeroconf/python-zeroconf/issues/1308)) ([`605dc9c`](https://github.com/python-zeroconf/python-zeroconf/commit/605dc9ccd843a535802031f051b3d93310186ad1)) +* Small speed up to process incoming packets ([#1309](https://github.com/python-zeroconf/python-zeroconf/issues/1309)) ([`56ef908`](https://github.com/python-zeroconf/python-zeroconf/commit/56ef90865189c01d2207abcc5e2efe3a7a022fa1)) + ## v0.123.0 (2023-11-12) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 87b971349..ba4c19c9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.123.0" +version = "0.124.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2e9aad9f0..0794fe254 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.123.0' +__version__ = '0.124.0' __license__ = 'LGPL' From d192d33b1f05aa95a89965e86210aec086673a17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Nov 2023 13:51:59 -0600 Subject: [PATCH 1027/1433] feat: speed up service browser queries when browsing many types (#1311) --- src/zeroconf/_services/browser.pxd | 13 +++++++++++-- src/zeroconf/_services/browser.py | 18 ++++++++---------- tests/services/test_browser.py | 14 ++++++++------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index c9b98a421..0cd0aeea8 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -2,6 +2,7 @@ import cython from .._cache cimport DNSCache +from .._history cimport QuestionHistory from .._protocol.outgoing cimport DNSOutgoing, DNSPointer, DNSQuestion, DNSRecord from .._record_update cimport RecordUpdate from .._updates cimport RecordUpdateListener @@ -11,7 +12,7 @@ from . cimport Signal, SignalRegistrationInterface cdef bint TYPE_CHECKING cdef object cached_possible_types -cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT +cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT, _MAX_MSG_TYPICAL, _DNS_PACKET_HEADER_LEN cdef cython.uint _TYPE_PTR cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED cdef cython.set _ADDRESS_RECORD_TYPES @@ -24,8 +25,16 @@ cdef class _DNSPointerOutgoingBucket: cpdef add(self, cython.uint max_compressed_size, DNSQuestion question, cython.set answers) +@cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord) +cpdef generate_service_query( + object zc, + float now, + list type_, + bint multicast, + object question_type +) -@cython.locals(answer=DNSPointer) +@cython.locals(answer=DNSPointer, query_buckets=list, question=DNSQuestion, max_compressed_size=cython.uint, max_bucket_size=cython.uint, query_bucket=_DNSPointerOutgoingBucket) cdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) cdef class QueryScheduler: diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 15af8d91c..c69076f31 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -164,24 +164,22 @@ def _group_ptr_queries_with_known_answers( def generate_service_query( - zc: 'Zeroconf', - now: float, - types_: List[str], - multicast: bool = True, - question_type: Optional[DNSQuestionType] = None, + zc: 'Zeroconf', now: float_, types_: List[str], multicast: bool, question_type: Optional[DNSQuestionType] ) -> List[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} qu_question = not multicast if question_type is None else question_type == DNSQuestionType.QU + question_history = zc.question_history + cache = zc.cache for type_ in types_: question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) question.unicast = qu_question known_answers = { record - for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) - if not record.is_stale(now) + for record in cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) + if record.is_stale(now) is False } - if not qu_question and zc.question_history.suppresses(question, now, known_answers): + if not qu_question and question_history.suppresses(question, now, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue if TYPE_CHECKING: @@ -189,8 +187,8 @@ def generate_service_query( else: pointer_known_answers = known_answers questions_with_known_answers[question] = pointer_known_answers - if not qu_question: - zc.question_history.add_question_at_time(question, now, known_answers) + if qu_question is False: + question_history.add_question_at_time(question, now, known_answers) return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 268a9b209..a658ded98 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1010,32 +1010,34 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert zc.question_history.suppresses(question, now, other_known_answers) # The known answer list is different, do not suppress - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) assert outs zc.cache.async_add_records([answer]) # The known answer list contains all the asked questions in the history # we should suppress - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) assert not outs # We do not suppress once the question history expires - outs = _services_browser.generate_service_query(zc, now + 1000, [name], multicast=True) + outs = _services_browser.generate_service_query( + zc, now + 1000, [name], multicast=True, question_type=None + ) assert outs # We do not suppress QU queries ever - outs = _services_browser.generate_service_query(zc, now, [name], multicast=False) + outs = _services_browser.generate_service_query(zc, now, [name], multicast=False, question_type=None) assert outs zc.question_history.async_expire(now + 2000) # No suppression after clearing the history - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) assert outs # The previous query we just sent is still remembered and # the next one is suppressed - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) assert not outs await aiozc.async_close() From cfa1fd691d76a6b59769fb500a57732a4e120ac9 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 12 Nov 2023 20:01:41 +0000 Subject: [PATCH 1028/1433] 0.125.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2594d5b2d..b24e6b8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.125.0 (2023-11-12) + +### Feature + +* Speed up service browser queries when browsing many types ([#1311](https://github.com/python-zeroconf/python-zeroconf/issues/1311)) ([`d192d33`](https://github.com/python-zeroconf/python-zeroconf/commit/d192d33b1f05aa95a89965e86210aec086673a17)) + ## v0.124.0 (2023-11-12) ### Feature diff --git a/pyproject.toml b/pyproject.toml index ba4c19c9c..b8b771d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.124.0" +version = "0.125.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0794fe254..0db4cab33 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.124.0' +__version__ = '0.125.0' __license__ = 'LGPL' From 9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Nov 2023 12:16:15 -0600 Subject: [PATCH 1029/1433] feat: speed up writing name compression for outgoing packets (#1312) --- bench/outgoing.py | 2 +- src/zeroconf/_protocol/outgoing.pxd | 16 +++++--- src/zeroconf/_protocol/outgoing.py | 62 +++++++++++++++++------------ 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/bench/outgoing.py b/bench/outgoing.py index bb5d99ced..5c8f2e6fa 100644 --- a/bench/outgoing.py +++ b/bench/outgoing.py @@ -158,7 +158,7 @@ def generate_packets() -> DNSOutgoing: def make_outgoing_message() -> None: - out.state = State.init + out.state = State.init.value out.finished = False out.packets() diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 2374f8b39..2cd9410a8 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -21,8 +21,8 @@ cdef object PACK_BYTE cdef object PACK_SHORT cdef object PACK_LONG -cdef object STATE_INIT -cdef object STATE_FINISHED +cdef unsigned int STATE_INIT +cdef unsigned int STATE_FINISHED cdef object LOGGING_IS_ENABLED_FOR cdef object LOGGING_DEBUG @@ -40,7 +40,7 @@ cdef class DNSOutgoing: cdef public cython.list data cdef public unsigned int size cdef public bint allow_long - cdef public object state + cdef public unsigned int state cdef public cython.list questions cdef public cython.list answers cdef public cython.list authorities @@ -91,6 +91,8 @@ cdef class DNSOutgoing: ) cpdef write_name(self, cython.str name) + cdef _write_link_to_name(self, unsigned int index) + cpdef write_short(self, object value) cpdef write_string(self, cython.bytes value) @@ -98,6 +100,8 @@ cdef class DNSOutgoing: cpdef _write_utf(self, cython.str value) @cython.locals( + debug_enable=bint, + made_progress=bint, questions_offset=object, answer_offset=object, authority_offset=object, @@ -107,7 +111,7 @@ cdef class DNSOutgoing: authorities_written=object, additionals_written=object, ) - cdef _packets(self) + cpdef packets(self) cpdef add_question_or_all_cache(self, DNSCache cache, object now, str name, object type_, object class_) @@ -124,6 +128,6 @@ cdef class DNSOutgoing: cpdef add_additional_answer(self, DNSRecord record) - cpdef is_query(self) + cpdef bint is_query(self) - cpdef is_response(self) + cpdef bint is_response(self) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index f4f68c3de..41c832f6f 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -61,8 +61,8 @@ class State(enum.Enum): finished = 1 -STATE_INIT = State.init -STATE_FINISHED = State.finished +STATE_INIT = State.init.value +STATE_FINISHED = State.finished.value LOGGING_IS_ENABLED_FOR = log.isEnabledFor LOGGING_DEBUG = logging.DEBUG @@ -277,30 +277,41 @@ def write_name(self, name: str_) -> None: """ # split name into each label - name_length = 0 if name.endswith('.'): - name = name[: len(name) - 1] - labels = name.split('.') - # Write each new label or a pointer to the existing - # on in the packet + name = name[:-1] + + index = self.names.get(name, 0) + if index: + self._write_link_to_name(index) + return + start_size = self.size - for count in range(len(labels)): - label = name if count == 0 else '.'.join(labels[count:]) - index = self.names.get(label, 0) + labels = name.split('.') + # Write each new label or a pointer to the existing one in the packet + self.names[name] = start_size + self._write_utf(labels[0]) + + name_length = 0 + for count in range(1, len(labels)): + partial_name = '.'.join(labels[count:]) + index = self.names.get(partial_name, 0) if index: - # If part of the name already exists in the packet, - # create a pointer to it - self._write_byte((index >> 8) | 0xC0) - self._write_byte(index & 0xFF) + self._write_link_to_name(index) return if name_length == 0: name_length = len(name.encode('utf-8')) - self.names[label] = start_size + name_length - len(label.encode('utf-8')) + self.names[partial_name] = start_size + name_length - len(partial_name.encode('utf-8')) self._write_utf(labels[count]) # this is the end of a name self._write_byte(0) + def _write_link_to_name(self, index: int_) -> None: + # If part of the name already exists in the packet, + # create a pointer to it + self._write_byte((index >> 8) | 0xC0) + self._write_byte(index & 0xFF) + def _write_question(self, question: DNSQuestion_) -> bool: """Writes a question to the packet""" start_data_length = len(self.data) @@ -406,9 +417,6 @@ def packets(self) -> List[bytes]: will be written out to a single oversized packet no more than _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP fragmentation potentially).""" - return self._packets() - - def _packets(self) -> List[bytes]: if self.state == STATE_FINISHED: return self.packets_data @@ -445,6 +453,8 @@ def _packets(self) -> List[bytes]: authorities_written = self._write_records_from_offset(self.authorities, authority_offset) additionals_written = self._write_records_from_offset(self.additionals, additional_offset) + made_progress = bool(self.data) + self._insert_short_at_start(additionals_written) self._insert_short_at_start(authorities_written) self._insert_short_at_start(answers_written) @@ -479,16 +489,16 @@ def _packets(self) -> List[bytes]: self._insert_short_at_start(self.id) self.packets_data.append(b''.join(self.data)) - self._reset_for_next_packet() - if ( - not questions_written - and not answers_written - and not authorities_written - and not additionals_written - and (self.questions or self.answers or self.authorities or self.additionals) - ): + if not made_progress: + # Generating an empty packet is not a desirable outcome, but currently + # too many internals rely on this behavior. So, we'll just return an + # empty packet and log a warning until this can be refactored at a later + # date. log.warning("packets() made no progress adding records; returning") break + + self._reset_for_next_packet() + self.state = STATE_FINISHED return self.packets_data From 55cf4ccdff886a136db4e2133d3e6cdd001a8bd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Nov 2023 13:26:29 -0600 Subject: [PATCH 1030/1433] feat: speed up outgoing packet writer (#1313) --- bench/outgoing.py | 3 +- src/zeroconf/_protocol/outgoing.pxd | 47 ++++++++++++++++++----------- src/zeroconf/_protocol/outgoing.py | 46 +++++++++++++++++----------- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/bench/outgoing.py b/bench/outgoing.py index 5c8f2e6fa..d832a05b4 100644 --- a/bench/outgoing.py +++ b/bench/outgoing.py @@ -158,9 +158,10 @@ def generate_packets() -> DNSOutgoing: def make_outgoing_message() -> None: + out.packets() out.state = State.init.value out.finished = False - out.packets() + out._reset_for_next_packet() count = 100000 diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 2cd9410a8..0f757af82 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -15,8 +15,11 @@ cdef cython.uint _FLAGS_TC cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _MAX_MSG_TYPICAL + cdef bint TYPE_CHECKING +cdef unsigned int SHORT_CACHE_MAX + cdef object PACK_BYTE cdef object PACK_SHORT cdef object PACK_LONG @@ -28,6 +31,7 @@ cdef object LOGGING_IS_ENABLED_FOR cdef object LOGGING_DEBUG cdef cython.tuple BYTE_TABLE +cdef cython.tuple SHORT_LOOKUP cdef class DNSOutgoing: @@ -46,13 +50,15 @@ cdef class DNSOutgoing: cdef public cython.list authorities cdef public cython.list additionals - cdef _reset_for_next_packet(self) + cpdef _reset_for_next_packet(self) - cdef _write_byte(self, object value) + cdef _write_byte(self, cython.uint value) - cdef _insert_short_at_start(self, object value) + cdef void _insert_short_at_start(self, unsigned int value) - cdef _replace_short(self, object index, object value) + cdef _replace_short(self, cython.uint index, cython.uint value) + + cdef _get_short(self, cython.uint value) cdef _write_int(self, object value) @@ -61,10 +67,12 @@ cdef class DNSOutgoing: @cython.locals( d=cython.bytes, data_view=cython.list, + index=cython.uint, length=cython.uint ) cdef cython.bint _write_record(self, DNSRecord record, object now) + @cython.locals(class_=cython.uint) cdef _write_record_class(self, DNSEntry record) @cython.locals( @@ -72,13 +80,16 @@ cdef class DNSOutgoing: ) cdef cython.bint _check_data_limit_or_rollback(self, cython.uint start_data_length, cython.uint start_size) - cdef _write_questions_from_offset(self, object questions_offset) + @cython.locals(questions_written=cython.uint) + cdef cython.uint _write_questions_from_offset(self, unsigned int questions_offset) - cdef _write_answers_from_offset(self, object answer_offset) + @cython.locals(answers_written=cython.uint) + cdef cython.uint _write_answers_from_offset(self, unsigned int answer_offset) - cdef _write_records_from_offset(self, cython.list records, object offset) + @cython.locals(records_written=cython.uint) + cdef cython.uint _write_records_from_offset(self, cython.list records, unsigned int offset) - cdef _has_more_to_add(self, object questions_offset, object answer_offset, object authority_offset, object additional_offset) + cdef bint _has_more_to_add(self, unsigned int questions_offset, unsigned int answer_offset, unsigned int authority_offset, unsigned int additional_offset) cdef _write_ttl(self, DNSRecord record, object now) @@ -93,23 +104,25 @@ cdef class DNSOutgoing: cdef _write_link_to_name(self, unsigned int index) - cpdef write_short(self, object value) + cpdef write_short(self, cython.uint value) cpdef write_string(self, cython.bytes value) + @cython.locals(utfstr=bytes) cpdef _write_utf(self, cython.str value) @cython.locals( debug_enable=bint, made_progress=bint, - questions_offset=object, - answer_offset=object, - authority_offset=object, - additional_offset=object, - questions_written=object, - answers_written=object, - authorities_written=object, - additionals_written=object, + has_more_to_add=bint, + questions_offset="unsigned int", + answer_offset="unsigned int", + authority_offset="unsigned int", + additional_offset="unsigned int", + questions_written="unsigned int", + answers_written="unsigned int", + authorities_written="unsigned int", + additionals_written="unsigned int", ) cpdef packets(self) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 41c832f6f..d3b47ae6c 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -53,7 +53,10 @@ PACK_SHORT = Struct('>H').pack PACK_LONG = Struct('>L').pack +SHORT_CACHE_MAX = 128 + BYTE_TABLE = tuple(PACK_BYTE(i) for i in range(256)) +SHORT_LOOKUP = tuple(PACK_SHORT(i) for i in range(SHORT_CACHE_MAX)) class State(enum.Enum): @@ -220,17 +223,21 @@ def _write_byte(self, value: int_) -> None: self.data.append(BYTE_TABLE[value]) self.size += 1 + def _get_short(self, value: int_) -> bytes: + """Convert an unsigned short to 2 bytes.""" + return SHORT_LOOKUP[value] if value < SHORT_CACHE_MAX else PACK_SHORT(value) + def _insert_short_at_start(self, value: int_) -> None: """Inserts an unsigned short at the start of the packet""" - self.data.insert(0, PACK_SHORT(value)) + self.data.insert(0, self._get_short(value)) def _replace_short(self, index: int_, value: int_) -> None: """Replaces an unsigned short in a certain position in the packet""" - self.data[index] = PACK_SHORT(value) + self.data[index] = self._get_short(value) def write_short(self, value: int_) -> None: """Writes an unsigned short to the packet""" - self.data.append(PACK_SHORT(value)) + self.data.append(self._get_short(value)) self.size += 2 def _write_int(self, value: Union[float, int]) -> None: @@ -323,10 +330,11 @@ def _write_question(self, question: DNSQuestion_) -> bool: def _write_record_class(self, record: Union[DNSQuestion_, DNSRecord_]) -> None: """Write out the record class including the unique/unicast (QU) bit.""" - if record.unique and self.multicast: - self.write_short(record.class_ | _CLASS_UNIQUE) + class_ = record.class_ + if record.unique is True and self.multicast is True: + self.write_short(class_ | _CLASS_UNIQUE) else: - self.write_short(record.class_) + self.write_short(class_) def _write_ttl(self, record: DNSRecord_, now: float_) -> None: """Write out the record ttl.""" @@ -417,21 +425,20 @@ def packets(self) -> List[bytes]: will be written out to a single oversized packet no more than _MAX_MSG_ABSOLUTE in length (and hence will be subject to IP fragmentation potentially).""" + packets_data = self.packets_data + if self.state == STATE_FINISHED: - return self.packets_data + return packets_data questions_offset = 0 answer_offset = 0 authority_offset = 0 additional_offset = 0 # we have to at least write out the question - first_time = True - debug_enable = LOGGING_IS_ENABLED_FOR(LOGGING_DEBUG) + debug_enable = LOGGING_IS_ENABLED_FOR(LOGGING_DEBUG) is True + has_more_to_add = True - while first_time or self._has_more_to_add( - questions_offset, answer_offset, authority_offset, additional_offset - ): - first_time = False + while has_more_to_add: if debug_enable: log.debug( "offsets = questions=%d, answers=%d, authorities=%d, additionals=%d", @@ -473,9 +480,11 @@ def packets(self) -> List[bytes]: additional_offset, ) - if self.is_query() and self._has_more_to_add( + has_more_to_add = self._has_more_to_add( questions_offset, answer_offset, authority_offset, additional_offset - ): + ) + + if has_more_to_add and self.is_query(): # https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 if debug_enable: # pragma: no branch log.debug("Setting TC flag") @@ -488,7 +497,7 @@ def packets(self) -> List[bytes]: else: self._insert_short_at_start(self.id) - self.packets_data.append(b''.join(self.data)) + packets_data.append(b''.join(self.data)) if not made_progress: # Generating an empty packet is not a desirable outcome, but currently @@ -498,7 +507,8 @@ def packets(self) -> List[bytes]: log.warning("packets() made no progress adding records; returning") break - self._reset_for_next_packet() + if has_more_to_add: + self._reset_for_next_packet() self.state = STATE_FINISHED - return self.packets_data + return packets_data From bf2cfdedcc8fadbefe3fa88097791aeb5bf4ffe3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 13 Nov 2023 19:36:14 +0000 Subject: [PATCH 1031/1433] 0.126.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24e6b8b3..c035860d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.126.0 (2023-11-13) + +### Feature + +* Speed up outgoing packet writer ([#1313](https://github.com/python-zeroconf/python-zeroconf/issues/1313)) ([`55cf4cc`](https://github.com/python-zeroconf/python-zeroconf/commit/55cf4ccdff886a136db4e2133d3e6cdd001a8bd6)) +* Speed up writing name compression for outgoing packets ([#1312](https://github.com/python-zeroconf/python-zeroconf/issues/1312)) ([`9caeabb`](https://github.com/python-zeroconf/python-zeroconf/commit/9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b)) + ## v0.125.0 (2023-11-12) ### Feature diff --git a/pyproject.toml b/pyproject.toml index b8b771d2a..50a29bbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.125.0" +version = "0.126.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0db4cab33..0334de400 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.125.0' +__version__ = '0.126.0' __license__ = 'LGPL' From bfe4c24881a7259713425df5ab00ffe487518841 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Nov 2023 16:58:40 -0600 Subject: [PATCH 1032/1433] feat: small speed up to processing incoming dns records (#1315) --- src/zeroconf/_cache.pxd | 2 +- src/zeroconf/_dns.pxd | 62 ++++++++------- src/zeroconf/_dns.py | 6 +- src/zeroconf/_handlers/query_handler.py | 16 ++-- src/zeroconf/_handlers/record_manager.py | 6 +- src/zeroconf/_history.pxd | 2 +- src/zeroconf/_listener.py | 4 +- src/zeroconf/_protocol/incoming.pxd | 25 +++--- src/zeroconf/_protocol/incoming.py | 96 +++++++++++++++--------- src/zeroconf/_protocol/outgoing.py | 2 +- src/zeroconf/_services/browser.pxd | 2 +- src/zeroconf/_services/browser.py | 6 +- src/zeroconf/_services/info.pxd | 2 +- src/zeroconf/_services/info.py | 6 +- tests/test_protocol.py | 1 + 15 files changed, 136 insertions(+), 102 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 1f94c21e2..ef1c1353c 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -24,7 +24,7 @@ cdef class DNSCache: cdef public cython.dict cache cdef public cython.dict service_cache - cpdef async_add_records(self, object entries) + cpdef bint async_add_records(self, object entries) cpdef async_remove_records(self, object entries) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 6785d1a3a..255181f8b 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -4,19 +4,21 @@ import cython from ._protocol.outgoing cimport DNSOutgoing -cdef object _LEN_BYTE -cdef object _LEN_SHORT -cdef object _LEN_INT +cdef cython.uint _LEN_BYTE +cdef cython.uint _LEN_SHORT +cdef cython.uint _LEN_INT -cdef object _NAME_COMPRESSION_MIN_SIZE -cdef object _BASE_MAX_SIZE +cdef cython.uint _NAME_COMPRESSION_MIN_SIZE +cdef cython.uint _BASE_MAX_SIZE cdef cython.uint _EXPIRE_FULL_TIME_MS cdef cython.uint _EXPIRE_STALE_TIME_MS cdef cython.uint _RECENT_TIME_MS -cdef object _CLASS_UNIQUE -cdef object _CLASS_MASK +cdef cython.uint _TYPE_ANY + +cdef cython.uint _CLASS_UNIQUE +cdef cython.uint _CLASS_MASK cdef object current_time_millis @@ -25,36 +27,40 @@ cdef class DNSEntry: cdef public str key cdef public str name cdef public cython.uint type - cdef public object class_ - cdef public object unique + cdef public cython.uint class_ + cdef public bint unique + + cdef _set_class(self, cython.uint class_) - cdef _dns_entry_matches(self, DNSEntry other) + cdef bint _dns_entry_matches(self, DNSEntry other) cdef class DNSQuestion(DNSEntry): cdef public cython.int _hash + cpdef bint answered_by(self, DNSRecord rec) + cdef class DNSRecord(DNSEntry): cdef public cython.float ttl cdef public cython.float created - cdef _suppressed_by_answer(self, DNSRecord answer) + cdef bint _suppressed_by_answer(self, DNSRecord answer) @cython.locals( answers=cython.list, ) - cpdef suppressed_by(self, object msg) + cpdef bint suppressed_by(self, object msg) cpdef get_remaining_ttl(self, cython.float now) cpdef get_expiration_time(self, cython.uint percent) - cpdef is_expired(self, cython.float now) + cpdef bint is_expired(self, cython.float now) - cpdef is_stale(self, cython.float now) + cpdef bint is_stale(self, cython.float now) - cpdef is_recent(self, cython.float now) + cpdef bint is_recent(self, cython.float now) cpdef reset_ttl(self, DNSRecord other) @@ -66,7 +72,7 @@ cdef class DNSAddress(DNSRecord): cdef public object address cdef public object scope_id - cdef _eq(self, DNSAddress other) + cdef bint _eq(self, DNSAddress other) cpdef write(self, DNSOutgoing out) @@ -74,10 +80,10 @@ cdef class DNSAddress(DNSRecord): cdef class DNSHinfo(DNSRecord): cdef public cython.int _hash - cdef public object cpu - cdef public object os + cdef public str cpu + cdef public str os - cdef _eq(self, DNSHinfo other) + cdef bint _eq(self, DNSHinfo other) cpdef write(self, DNSOutgoing out) @@ -87,29 +93,29 @@ cdef class DNSPointer(DNSRecord): cdef public str alias cdef public str alias_key - cdef _eq(self, DNSPointer other) + cdef bint _eq(self, DNSPointer other) cpdef write(self, DNSOutgoing out) cdef class DNSText(DNSRecord): cdef public cython.int _hash - cdef public object text + cdef public bytes text - cdef _eq(self, DNSText other) + cdef bint _eq(self, DNSText other) cpdef write(self, DNSOutgoing out) cdef class DNSService(DNSRecord): cdef public cython.int _hash - cdef public object priority - cdef public object weight - cdef public object port + cdef public cython.uint priority + cdef public cython.uint weight + cdef public cython.uint port cdef public str server cdef public str server_key - cdef _eq(self, DNSService other) + cdef bint _eq(self, DNSService other) cpdef write(self, DNSOutgoing out) @@ -119,7 +125,7 @@ cdef class DNSNsec(DNSRecord): cdef public object next_name cdef public cython.list rdtypes - cdef _eq(self, DNSNsec other) + cdef bint _eq(self, DNSNsec other) cpdef write(self, DNSOutgoing out) @@ -129,7 +135,7 @@ cdef class DNSRRSet: cdef cython.dict _lookup @cython.locals(other=DNSRecord) - cpdef suppresses(self, DNSRecord record) + cpdef bint suppresses(self, DNSRecord record) @cython.locals( record=DNSRecord, diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 0b43f410a..3e9f074a2 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -67,10 +67,13 @@ class DNSEntry: __slots__ = ('key', 'name', 'type', 'class_', 'unique') - def __init__(self, name: str, type_: _int, class_: _int) -> None: + def __init__(self, name: str, type_: int, class_: int) -> None: self.name = name self.key = name.lower() self.type = type_ + self._set_class(class_) + + def _set_class(self, class_: _int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 @@ -371,7 +374,6 @@ class DNSText(DNSRecord): def __init__( self, name: str, type_: int, class_: int, ttl: int, text: bytes, created: Optional[float] = None ) -> None: - assert isinstance(text, (bytes, type(None))) super().__init__(name, type_, class_, ttl, created) self.text = text self._hash = hash((self.key, type_, self.class_, text)) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 0af72f4c6..c66d9c302 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -167,7 +167,7 @@ def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry is not None and maybe_entry.is_recent(self._now) is True) + return bool(maybe_entry is not None and maybe_entry.is_recent(self._now)) def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: """Check if an answer was seen in the last second. @@ -202,7 +202,7 @@ def _add_service_type_enumeration_query_answers( dns_pointer = DNSPointer( _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, 0.0 ) - if known_answers.suppresses(dns_pointer) is False: + if not known_answers.suppresses(dns_pointer): answer_set[dns_pointer] = set() def _add_pointer_answers( @@ -213,7 +213,7 @@ def _add_pointer_answers( # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.1. dns_pointer = service._dns_pointer(None) - if known_answers.suppresses(dns_pointer) is True: + if known_answers.suppresses(dns_pointer): continue answer_set[dns_pointer] = { service._dns_service(None), @@ -237,7 +237,7 @@ def _add_address_answers( seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) - elif known_answers.suppresses(dns_address) is False: + elif not known_answers.suppresses(dns_address): answers.append(dns_address) missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: @@ -272,12 +272,12 @@ def _answer_question( # https://tools.ietf.org/html/rfc6763#section-12.2. service = services[0] dns_service = service._dns_service(None) - if known_answers.suppresses(dns_service) is False: + if not known_answers.suppresses(dns_service): answer_set[dns_service] = service._get_address_and_nsec_records(None) elif strategy_type == _ANSWER_STRATEGY_TEXT: # pragma: no branch service = services[0] dns_text = service._dns_text(None) - if known_answers.suppresses(dns_text) is False: + if not known_answers.suppresses(dns_text): answer_set[dns_text] = set() return answer_set @@ -307,7 +307,7 @@ def async_response( # pylint: disable=unused-argument # at least one answer strategy answers: List[DNSRecord] = [] for msg in msgs: - if msg.is_probe() is True: + if msg.is_probe(): is_probe = True else: answers.extend(msg.answers()) @@ -319,7 +319,7 @@ def async_response( # pylint: disable=unused-argument now = msg.now for strategy in strategies: question = strategy.question - is_unicast = question.unique is True # unique and unicast are the same flag + is_unicast = question.unique # unique and unicast are the same flag if not is_unicast: if known_answers_set is None: # pragma: no branch known_answers_set = known_answers.lookup_set() diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 6fb11f55b..cbf88abd9 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -106,14 +106,14 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: ) record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) - if record.unique is True: # https://tools.ietf.org/html/rfc6762#section-10.2 + if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record_type, record.class_)) if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = cache.async_get_unique(record) - if record.is_expired(now_float) is False: + if not record.is_expired(now_float): if maybe_entry is not None: maybe_entry.reset_ttl(record) else: @@ -151,7 +151,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: new = False if other_adds or address_adds: new = cache.async_add_records(address_adds) - if cache.async_add_records(other_adds) is True: + if cache.async_add_records(other_adds): new = True # Removes are processed last since # ServiceInfo could generate an un-needed query diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index d4e1c8332..c1ff7619a 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -12,7 +12,7 @@ cdef class QuestionHistory: cpdef add_question_at_time(self, DNSQuestion question, float now, cython.set known_answers) @cython.locals(than=cython.double, previous_question=cython.tuple, previous_known_answers=cython.set) - cpdef suppresses(self, DNSQuestion question, cython.double now, cython.set known_answers) + cpdef bint suppresses(self, DNSQuestion question, cython.double now, cython.set known_answers) @cython.locals(than=cython.double, now_known_answers=cython.tuple) cpdef async_expire(self, cython.double now) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 700029e17..23d245785 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -113,7 +113,7 @@ def _process_datagram_at_time( self.data == data and (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < self.last_time and self.last_message is not None - and self.last_message.has_qu_question() is False + and not self.last_message.has_qu_question() ): # Guard against duplicate packets if debug: @@ -169,7 +169,7 @@ def _process_datagram_at_time( ) return - if msg.is_query() is False: + if not msg.is_query(): self._record_manager.async_updates_from_response(msg) return diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index c39ab9a64..3bfc57f25 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -52,32 +52,33 @@ cdef class DNSIncoming: cdef public bytes data cdef const unsigned char [:] view cdef unsigned int _data_len - cdef public cython.dict name_cache - cdef public cython.list questions + cdef cython.dict _name_cache + cdef cython.list _questions cdef cython.list _answers - cdef public object id - cdef public cython.uint num_questions - cdef public cython.uint num_answers - cdef public cython.uint num_authorities - cdef public cython.uint num_additionals - cdef public object valid + cdef public cython.uint id + cdef cython.uint _num_questions + cdef cython.uint _num_answers + cdef cython.uint _num_authorities + cdef cython.uint _num_additionals + cdef public bint valid cdef public object now cdef cython.float _now_float cdef public object scope_id cdef public object source + cdef bint _has_qu_question @cython.locals( question=DNSQuestion ) - cpdef has_qu_question(self) + cpdef bint has_qu_question(self) - cpdef is_query(self) + cpdef bint is_query(self) - cpdef is_probe(self) + cpdef bint is_probe(self) cpdef answers(self) - cpdef is_response(self) + cpdef bint is_response(self) @cython.locals( off=cython.uint, diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 6a7451e71..fd5fafb68 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -80,19 +80,20 @@ class DNSIncoming: 'data', 'view', '_data_len', - 'name_cache', - 'questions', + '_name_cache', + '_questions', '_answers', 'id', - 'num_questions', - 'num_answers', - 'num_authorities', - 'num_additionals', + '_num_questions', + '_num_answers', + '_num_authorities', + '_num_additionals', 'valid', 'now', '_now_float', 'scope_id', 'source', + '_has_qu_question', ) def __init__( @@ -108,20 +109,21 @@ def __init__( self.data = data self.view = data self._data_len = len(data) - self.name_cache: Dict[int, List[str]] = {} - self.questions: List[DNSQuestion] = [] + self._name_cache: Dict[int, List[str]] = {} + self._questions: List[DNSQuestion] = [] self._answers: List[DNSRecord] = [] self.id = 0 - self.num_questions = 0 - self.num_answers = 0 - self.num_authorities = 0 - self.num_additionals = 0 + self._num_questions = 0 + self._num_answers = 0 + self._num_authorities = 0 + self._num_additionals = 0 self.valid = False self._did_read_others = False self.now = now or current_time_millis() self._now_float = self.now self.source = source self.scope_id = scope_id + self._has_qu_question = False try: self._initial_parse() except DECODE_EXCEPTIONS: @@ -142,24 +144,43 @@ def is_response(self) -> bool: def has_qu_question(self) -> bool: """Returns true if any question is a QU question.""" - if not self.num_questions: - return False - for question in self.questions: - # QU questions use the same bit as unique - if question.unique: - return True - return False + return self._has_qu_question @property def truncated(self) -> bool: """Returns true if this is a truncated.""" return (self.flags & _FLAGS_TC) == _FLAGS_TC + @property + def questions(self) -> List[DNSQuestion]: + """Questions in the packet.""" + return self._questions + + @property + def num_questions(self) -> int: + """Number of questions in the packet.""" + return self._num_questions + + @property + def num_answers(self) -> int: + """Number of answers in the packet.""" + return self._num_answers + + @property + def num_authorities(self) -> int: + """Number of authorities in the packet.""" + return self._num_authorities + + @property + def num_additionals(self) -> int: + """Number of additionals in the packet.""" + return self._num_additionals + def _initial_parse(self) -> None: """Parse the data needed to initalize the packet object.""" self._read_header() self._read_questions() - if not self.num_questions: + if not self._num_questions: self._read_others() self.valid = True @@ -190,7 +211,7 @@ def answers(self) -> List[DNSRecord]: def is_probe(self) -> bool: """Returns true if this is a probe.""" - return self.num_authorities > 0 + return self._num_authorities > 0 def __repr__(self) -> str: return '' % ', '.join( @@ -198,11 +219,11 @@ def __repr__(self) -> str: 'id=%s' % self.id, 'flags=%s' % self.flags, 'truncated=%s' % self.truncated, - 'n_q=%s' % self.num_questions, - 'n_ans=%s' % self.num_answers, - 'n_auth=%s' % self.num_authorities, - 'n_add=%s' % self.num_additionals, - 'questions=%s' % self.questions, + 'n_q=%s' % self._num_questions, + 'n_ans=%s' % self._num_answers, + 'n_auth=%s' % self._num_authorities, + 'n_add=%s' % self._num_additionals, + 'questions=%s' % self._questions, 'answers=%s' % self.answers(), ] ) @@ -212,21 +233,24 @@ def _read_header(self) -> None: ( self.id, self.flags, - self.num_questions, - self.num_answers, - self.num_authorities, - self.num_additionals, + self._num_questions, + self._num_answers, + self._num_authorities, + self._num_additionals, ) = UNPACK_6H(self.data) self.offset += 12 def _read_questions(self) -> None: """Reads questions section of packet""" - for _ in range(self.num_questions): + questions = self._questions + for _ in range(self._num_questions): name = self._read_name() type_, class_ = UNPACK_HH(self.data, self.offset) self.offset += 4 question = DNSQuestion(name, type_, class_) - self.questions.append(question) + if question.unique: # QU questions use the same bit as unique + self._has_qu_question = True + questions.append(question) def _read_character_string(self) -> str: """Reads a character string from the packet""" @@ -246,7 +270,7 @@ def _read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" self._did_read_others = True - n = self.num_answers + self.num_authorities + self.num_additionals + n = self._num_answers + self._num_authorities + self._num_additionals for _ in range(n): domain = self._read_name() type_, class_, ttl, length = UNPACK_HHiH(self.data, self.offset) @@ -352,7 +376,7 @@ def _read_name(self) -> str: seen_pointers: Set[int] = set() original_offset = self.offset self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) - self.name_cache[original_offset] = labels + self._name_cache[original_offset] = labels name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: raise IncomingDecodeError( @@ -394,12 +418,12 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: raise IncomingDecodeError( f"DNS compression pointer at {off} was seen again from {self.source}" ) - linked_labels = self.name_cache.get(link_py_int) + linked_labels = self._name_cache.get(link_py_int) if not linked_labels: linked_labels = [] seen_pointers.add(link_py_int) self._decode_labels_at_offset(link, linked_labels, seen_pointers) - self.name_cache[link_py_int] = linked_labels + self._name_cache[link_py_int] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: raise IncomingDecodeError( diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index d3b47ae6c..0438cc83f 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -331,7 +331,7 @@ def _write_question(self, question: DNSQuestion_) -> bool: def _write_record_class(self, record: Union[DNSQuestion_, DNSRecord_]) -> None: """Write out the record class including the unique/unicast (QU) bit.""" class_ = record.class_ - if record.unique is True and self.multicast is True: + if record.unique is True and self.multicast: self.write_short(class_ | _CLASS_UNIQUE) else: self.write_short(class_) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 0cd0aeea8..25c0f5844 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -25,7 +25,7 @@ cdef class _DNSPointerOutgoingBucket: cpdef add(self, cython.uint max_compressed_size, DNSQuestion question, cython.set answers) -@cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord) +@cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord, qu_question=bint) cpdef generate_service_query( object zc, float now, diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index c69076f31..ca8c9aa55 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -177,7 +177,7 @@ def generate_service_query( known_answers = { record for record in cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) - if record.is_stale(now) is False + if not record.is_stale(now) } if not qu_question and question_history.suppresses(question, now, known_answers): log.debug("Asking %s was suppressed by the question history", question) @@ -187,7 +187,7 @@ def generate_service_query( else: pointer_known_answers = known_answers questions_with_known_answers[question] = pointer_known_answers - if qu_question is False: + if not qu_question: question_history.add_question_at_time(question, now, known_answers) return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) @@ -440,7 +440,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record continue # If its expired or already exists in the cache it cannot be updated. - if old_record is not None or record.is_expired(now) is True: + if old_record is not None or record.is_expired(now): continue if record_type in _ADDRESS_RECORD_TYPES: diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 0461bf001..b7977466e 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -61,7 +61,7 @@ cdef class ServiceInfo(RecordUpdateListener): cpdef async_update_records(self, object zc, cython.float now, cython.list records) @cython.locals(cache=DNSCache) - cpdef _load_from_cache(self, object zc, cython.float now) + cpdef bint _load_from_cache(self, object zc, cython.float now) cdef _unpack_text_into_properties(self) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fab6b4109..fbf28af23 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -420,7 +420,7 @@ def _get_ip_addresses_from_cache_lifo( """Set IPv6 addresses from the cache.""" address_list: List[Union[IPv4Address, IPv6Address]] = [] for record in self._get_address_records_from_cache_by_type(zc, type): - if record.is_expired(now) is True: + if record.is_expired(now): continue ip_addr = _cached_ip_addresses_wrapper(record.address) if ip_addr is not None: @@ -463,7 +463,7 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo Returns True if a new record was added. """ - if record.is_expired(now) is True: + if record.is_expired(now): return False record_key = record.key @@ -779,7 +779,7 @@ async def async_request( now = current_time_millis() - if self._load_from_cache(zc, now) is True: + if self._load_from_cache(zc, now): return True if TYPE_CHECKING: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index a8593850f..0a8531042 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -316,6 +316,7 @@ def test_massive_probe_packet_split(self): parsed1 = r.DNSIncoming(packets[0]) assert parsed1.questions[0].unicast is True assert len(parsed1.questions) == 30 + assert parsed1.num_questions == 30 assert parsed1.num_authorities == 88 assert parsed1.truncated parsed2 = r.DNSIncoming(packets[1]) From 0d60b61538a5d4b6f44b2369333b6e916a0a55b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Nov 2023 23:41:35 -0600 Subject: [PATCH 1033/1433] feat: speed up incoming packet reader (#1314) --- src/zeroconf/_dns.py | 1 - src/zeroconf/_protocol/incoming.pxd | 48 +++++++++++----------- src/zeroconf/_protocol/incoming.py | 64 ++++++++++++++++------------- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 3e9f074a2..4ca429a81 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -244,7 +244,6 @@ def __init__( class_: int, ttl: int, address: bytes, - *, scope_id: Optional[int] = None, created: Optional[float] = None, ) -> None: diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 3bfc57f25..07ae6e78e 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -21,11 +21,6 @@ cdef cython.uint _FLAGS_TC cdef cython.uint _FLAGS_QR_QUERY cdef cython.uint _FLAGS_QR_RESPONSE -cdef object UNPACK_3H -cdef object UNPACK_6H -cdef object UNPACK_HH -cdef object UNPACK_HHiH - cdef object DECODE_EXCEPTIONS cdef object IncomingDecodeError @@ -62,7 +57,6 @@ cdef class DNSIncoming: cdef cython.uint _num_additionals cdef public bint valid cdef public object now - cdef cython.float _now_float cdef public object scope_id cdef public object source cdef bint _has_qu_question @@ -81,49 +75,53 @@ cdef class DNSIncoming: cpdef bint is_response(self) @cython.locals( - off=cython.uint, - label_idx=cython.uint, - length=cython.uint, - link=cython.uint, - link_data=cython.uint, + off="unsigned int", + label_idx="unsigned int", + length="unsigned int", + link="unsigned int", + link_data="unsigned int", link_py_int=object, linked_labels=cython.list ) - cdef cython.uint _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) + cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) + @cython.locals(offset="unsigned int") cdef _read_header(self) cdef _initial_parse(self) @cython.locals( - end=cython.uint, - length=cython.uint + end="unsigned int", + length="unsigned int", + offset="unsigned int" ) cdef _read_others(self) + @cython.locals(offset="unsigned int") cdef _read_questions(self) @cython.locals( - length=cython.uint, + length="unsigned int", ) cdef str _read_character_string(self) cdef bytes _read_string(self, unsigned int length) @cython.locals( - name_start=cython.uint + name_start="unsigned int", + offset="unsigned int" ) - cdef _read_record(self, object domain, unsigned int type_, object class_, object ttl, unsigned int length) + cdef _read_record(self, object domain, unsigned int type_, unsigned int class_, unsigned int ttl, unsigned int length) @cython.locals( - offset=cython.uint, - offset_plus_one=cython.uint, - offset_plus_two=cython.uint, - window=cython.uint, - bit=cython.uint, - byte=cython.uint, - i=cython.uint, - bitmap_length=cython.uint, + offset="unsigned int", + offset_plus_one="unsigned int", + offset_plus_two="unsigned int", + window="unsigned int", + bit="unsigned int", + byte="unsigned int", + i="unsigned int", + bitmap_length="unsigned int", ) cdef _read_bitmap(self, unsigned int end) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index fd5fafb68..9e208b639 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -60,10 +60,6 @@ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) -UNPACK_3H = struct.Struct(b'!3H').unpack_from -UNPACK_6H = struct.Struct(b'!6H').unpack_from -UNPACK_HH = struct.Struct(b'!HH').unpack_from -UNPACK_HHiH = struct.Struct(b'!HHiH').unpack_from _seen_logs: Dict[str, Union[int, tuple]] = {} _str = str @@ -90,7 +86,6 @@ class DNSIncoming: '_num_additionals', 'valid', 'now', - '_now_float', 'scope_id', 'source', '_has_qu_question', @@ -120,7 +115,6 @@ def __init__( self.valid = False self._did_read_others = False self.now = now or current_time_millis() - self._now_float = self.now self.source = source self.scope_id = scope_id self._has_qu_question = False @@ -230,23 +224,28 @@ def __repr__(self) -> str: def _read_header(self) -> None: """Reads header portion of packet""" - ( - self.id, - self.flags, - self._num_questions, - self._num_answers, - self._num_authorities, - self._num_additionals, - ) = UNPACK_6H(self.data) + view = self.view + offset = self.offset self.offset += 12 + # The header has 6 unsigned shorts in network order + self.id = view[offset] << 8 | view[offset + 1] + self.flags = view[offset + 2] << 8 | view[offset + 3] + self._num_questions = view[offset + 4] << 8 | view[offset + 5] + self._num_answers = view[offset + 6] << 8 | view[offset + 7] + self._num_authorities = view[offset + 8] << 8 | view[offset + 9] + self._num_additionals = view[offset + 10] << 8 | view[offset + 11] def _read_questions(self) -> None: """Reads questions section of packet""" + view = self.view questions = self._questions for _ in range(self._num_questions): name = self._read_name() - type_, class_ = UNPACK_HH(self.data, self.offset) + offset = self.offset self.offset += 4 + # The question has 2 unsigned shorts in network order + type_ = view[offset] << 8 | view[offset + 1] + class_ = view[offset + 2] << 8 | view[offset + 3] question = DNSQuestion(name, type_, class_) if question.unique: # QU questions use the same bit as unique self._has_qu_question = True @@ -270,11 +269,18 @@ def _read_others(self) -> None: """Reads the answers, authorities and additionals section of the packet""" self._did_read_others = True + view = self.view n = self._num_answers + self._num_authorities + self._num_additionals for _ in range(n): domain = self._read_name() - type_, class_, ttl, length = UNPACK_HHiH(self.data, self.offset) + offset = self.offset self.offset += 10 + # type_, class_ and length are unsigned shorts in network order + # ttl is an unsigned long in network order https://www.rfc-editor.org/errata/eid2130 + type_ = view[offset] << 8 | view[offset + 1] + class_ = view[offset + 2] << 8 | view[offset + 3] + ttl = view[offset + 4] << 24 | view[offset + 5] << 16 | view[offset + 6] << 8 | view[offset + 7] + length = view[offset + 8] << 8 | view[offset + 9] end = self.offset + length rec = None try: @@ -300,16 +306,19 @@ def _read_record( ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(4)) - dns_address.created = self._now_float - return dns_address + return DNSAddress(domain, type_, class_, ttl, self._read_string(4), None, self.now) if type_ in (_TYPE_CNAME, _TYPE_PTR): return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) if type_ == _TYPE_TXT: return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) if type_ == _TYPE_SRV: - priority, weight, port = UNPACK_3H(self.data, self.offset) + view = self.view + offset = self.offset self.offset += 6 + # The SRV record has 3 unsigned shorts in network order + priority = view[offset] << 8 | view[offset + 1] + weight = view[offset + 2] << 8 | view[offset + 3] + port = view[offset + 4] << 8 | view[offset + 5] return DNSService( domain, type_, @@ -332,10 +341,7 @@ def _read_record( self.now, ) if type_ == _TYPE_AAAA: - dns_address = DNSAddress(domain, type_, class_, ttl, self._read_string(16)) - dns_address.created = self._now_float - dns_address.scope_id = self.scope_id - return dns_address + return DNSAddress(domain, type_, class_, ttl, self._read_string(16), self.scope_id, self.now) if type_ == _TYPE_NSEC: name_start = self.offset return DNSNsec( @@ -356,12 +362,13 @@ def _read_record( def _read_bitmap(self, end: _int) -> List[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] + view = self.view while self.offset < end: offset = self.offset offset_plus_one = offset + 1 offset_plus_two = offset + 2 - window = self.view[offset] - bitmap_length = self.view[offset_plus_one] + window = view[offset] + bitmap_length = view[offset_plus_one] bitmap_end = offset_plus_two + bitmap_length for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]): for bit in range(0, 8): @@ -386,8 +393,9 @@ def _read_name(self) -> str: def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. + view = self.view while off < self._data_len: - length = self.view[off] + length = view[off] if length == 0: return off + DNS_COMPRESSION_HEADER_LEN @@ -403,7 +411,7 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: ) # We have a DNS compression pointer - link_data = self.view[off + 1] + link_data = view[off + 1] link = (length & 0x3F) * 256 + link_data link_py_int = link if link > self._data_len: From cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Nov 2023 00:27:46 -0600 Subject: [PATCH 1034/1433] feat: small speed up to writing outgoing packets (#1316) --- src/zeroconf/_protocol/outgoing.pxd | 5 +++-- src/zeroconf/_protocol/outgoing.py | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 0f757af82..52237f09d 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -32,6 +32,7 @@ cdef object LOGGING_DEBUG cdef cython.tuple BYTE_TABLE cdef cython.tuple SHORT_LOOKUP +cdef cython.dict LONG_LOOKUP cdef class DNSOutgoing: @@ -70,7 +71,7 @@ cdef class DNSOutgoing: index=cython.uint, length=cython.uint ) - cdef cython.bint _write_record(self, DNSRecord record, object now) + cdef cython.bint _write_record(self, DNSRecord record, float now) @cython.locals(class_=cython.uint) cdef _write_record_class(self, DNSEntry record) @@ -91,7 +92,7 @@ cdef class DNSOutgoing: cdef bint _has_more_to_add(self, unsigned int questions_offset, unsigned int answer_offset, unsigned int authority_offset, unsigned int additional_offset) - cdef _write_ttl(self, DNSRecord record, object now) + cdef _write_ttl(self, DNSRecord record, float now) @cython.locals( labels=cython.list, diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 0438cc83f..e421681c9 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -31,6 +31,8 @@ from .._logger import log from ..const import ( _CLASS_UNIQUE, + _DNS_HOST_TTL, + _DNS_OTHER_TTL, _DNS_PACKET_HEADER_LEN, _FLAGS_QR_MASK, _FLAGS_QR_QUERY, @@ -57,6 +59,7 @@ BYTE_TABLE = tuple(PACK_BYTE(i) for i in range(256)) SHORT_LOOKUP = tuple(PACK_SHORT(i) for i in range(SHORT_CACHE_MAX)) +LONG_LOOKUP = {i: PACK_LONG(i) for i in (_DNS_OTHER_TTL, _DNS_HOST_TTL, 0)} class State(enum.Enum): @@ -242,7 +245,12 @@ def write_short(self, value: int_) -> None: def _write_int(self, value: Union[float, int]) -> None: """Writes an unsigned integer to the packet""" - self.data.append(PACK_LONG(int(value))) + value_as_int = int(value) + long_bytes = LONG_LOOKUP.get(value_as_int) + if long_bytes is not None: + self.data.append(long_bytes) + else: + self.data.append(PACK_LONG(value_as_int)) self.size += 4 def write_string(self, value: bytes_) -> None: From 1b5cc2459359db9b08782ae7f75e7914ab0e1bf0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 15 Nov 2023 06:36:48 +0000 Subject: [PATCH 1035/1433] 0.127.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c035860d0..dba51f2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v0.127.0 (2023-11-15) + +### Feature + +* Small speed up to writing outgoing packets ([#1316](https://github.com/python-zeroconf/python-zeroconf/issues/1316)) ([`cd28476`](https://github.com/python-zeroconf/python-zeroconf/commit/cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65)) +* Speed up incoming packet reader ([#1314](https://github.com/python-zeroconf/python-zeroconf/issues/1314)) ([`0d60b61`](https://github.com/python-zeroconf/python-zeroconf/commit/0d60b61538a5d4b6f44b2369333b6e916a0a55b4)) +* Small speed up to processing incoming dns records ([#1315](https://github.com/python-zeroconf/python-zeroconf/issues/1315)) ([`bfe4c24`](https://github.com/python-zeroconf/python-zeroconf/commit/bfe4c24881a7259713425df5ab00ffe487518841)) + ## v0.126.0 (2023-11-13) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 50a29bbe1..02990c46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.126.0" +version = "0.127.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0334de400..b04da841e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.126.0' +__version__ = '0.127.0' __license__ = 'LGPL' From 72fed787df295016f705ee43c6901a8277b43df7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Nov 2023 14:44:10 -0600 Subject: [PATCH 1036/1433] chore: add benchmark to create and destroy an instance (#1317) --- bench/create_destory.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 bench/create_destory.py diff --git a/bench/create_destory.py b/bench/create_destory.py new file mode 100644 index 000000000..f1941423c --- /dev/null +++ b/bench/create_destory.py @@ -0,0 +1,23 @@ +"""Benchmark for AsyncZeroconf.""" +import asyncio +import time + +from zeroconf.asyncio import AsyncZeroconf + +iterations = 10000 + + +async def _create_destroy(count: int) -> None: + for _ in range(count): + async with AsyncZeroconf() as zc: + await zc.zeroconf.async_wait_for_start() + + +async def _run() -> None: + start = time.perf_counter() + await _create_destroy(iterations) + duration = time.perf_counter() - start + print(f"Creating and destroying {iterations} Zeroconf instances took {duration} seconds") + + +asyncio.run(_run()) From a20084281e66bdb9c37183a5eb992435f5b866ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Dec 2023 13:16:45 -1000 Subject: [PATCH 1037/1433] feat: speed up unpacking TXT record data in ServiceInfo (#1318) --- bench/txt_properties.py | 22 ++++++++++++++++++++++ src/zeroconf/_services/info.pxd | 3 ++- src/zeroconf/_services/info.py | 19 +++++++++---------- 3 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 bench/txt_properties.py diff --git a/bench/txt_properties.py b/bench/txt_properties.py new file mode 100644 index 000000000..792d5312d --- /dev/null +++ b/bench/txt_properties.py @@ -0,0 +1,22 @@ +import timeit + +from zeroconf import ServiceInfo + +info = ServiceInfo( + "_test._tcp.local.", + "test._test._tcp.local.", + properties=( + b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==" + ), +) + + +def process_properties() -> None: + info._properties = None + info.properties + + +count = 100000 +time = timeit.Timer(process_properties).timeit(count) +print(f"Processing {count} properties took {time} seconds") diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index b7977466e..3506c3a91 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -63,7 +63,8 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(cache=DNSCache) cpdef bint _load_from_cache(self, object zc, cython.float now) - cdef _unpack_text_into_properties(self) + @cython.locals(length="unsigned char", index="unsigned int", key_value=bytes, key_sep_value=tuple) + cdef void _unpack_text_into_properties(self) cdef _set_properties(self, cython.dict properties) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fbf28af23..f363b55b6 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -388,27 +388,26 @@ def _set_text(self, text: bytes) -> None: def _unpack_text_into_properties(self) -> None: """Unpacks the text field into properties""" text = self.text - if not text: + end = len(text) + if end == 0: # Properties should be set atomically # in case another thread is reading them self._properties = {} return index = 0 - pairs: List[bytes] = [] - end = len(text) + properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} while index < end: length = text[index] index += 1 - pairs.append(text[index : index + length]) + key_value = text[index : index + length] + key_sep_value = key_value.partition(b'=') + key = key_sep_value[0] + if key not in properties: + properties[key] = key_sep_value[2] or None index += length - # Reverse the list so that the first item in the list - # is the last item in the text field. This is important - # to preserve backwards compatibility where the first - # key always wins if the key is seen multiple times. - pairs.reverse() - self._properties = {key: value or None for key, _, value in (pair.partition(b'=') for pair in pairs)} + self._properties = properties def get_name(self) -> str: """Name accessor""" From 1c2f194dd265eeee8d41cc3e3aa6fcbbcff0b0c5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 2 Dec 2023 23:25:05 +0000 Subject: [PATCH 1038/1433] 0.128.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dba51f2c2..8233562aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.128.0 (2023-12-02) + +### Feature + +* Speed up unpacking TXT record data in ServiceInfo ([#1318](https://github.com/python-zeroconf/python-zeroconf/issues/1318)) ([`a200842`](https://github.com/python-zeroconf/python-zeroconf/commit/a20084281e66bdb9c37183a5eb992435f5b866ac)) + ## v0.127.0 (2023-11-15) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 02990c46e..4bfe3a826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.127.0" +version = "0.128.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b04da841e..b994698b9 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.127.0' +__version__ = '0.128.0' __license__ = 'LGPL' From 1682991b985b1f7b2bf0cff1a7eb7793070e7cb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 10:38:27 -1000 Subject: [PATCH 1039/1433] fix: correct handling of IPv6 addresses with scope_id in ServiceInfo (#1322) --- examples/browser.py | 10 +++--- src/zeroconf/_services/info.pxd | 8 +++++ src/zeroconf/_services/info.py | 53 +++++++++++++++++++++-------- tests/services/test_info.py | 59 ++++++++++++++++++++++++++++++--- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index 60933e2a4..a456a9ebf 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -51,18 +51,18 @@ def on_service_state_change( parser.add_argument('--debug', action='store_true') parser.add_argument('--find', action='store_true', help='Browse all available services') version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6', action='store_true') version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument('--v4-only', action='store_true') args = parser.parse_args() if args.debug: logging.getLogger('zeroconf').setLevel(logging.DEBUG) - if args.v6: - ip_version = IPVersion.All - elif args.v6_only: + if args.v6_only: ip_version = IPVersion.V6Only - else: + elif args.v4_only: ip_version = IPVersion.V4Only + else: + ip_version = IPVersion.All zeroconf = Zeroconf(ip_version=ip_version) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 3506c3a91..b7a2ee30c 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -32,6 +32,14 @@ cdef object _IPVersion_V4Only_value cdef cython.set _ADDRESS_RECORD_TYPES cdef bint TYPE_CHECKING +cdef bint IPADDRESS_SUPPORTS_SCOPE_ID + +cdef _get_ip_address_object_from_record(DNSAddress record) + +@cython.locals(address_str=str) +cdef _str_without_scope_id(object addr) + +cdef _ip_bytes_and_scope_to_address(object addr, object scope_id) cdef class ServiceInfo(RecordUpdateListener): diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index f363b55b6..e9e257636 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -22,6 +22,7 @@ import asyncio import random +import sys from functools import lru_cache from ipaddress import IPv4Address, IPv6Address, _BaseAddress, ip_address from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast @@ -78,12 +79,15 @@ # the A/AAAA/SRV records for a host. _AVOID_SYNC_DELAY_RANDOM_INTERVAL = (20, 120) +bytes_ = bytes float_ = float int_ = int DNS_QUESTION_TYPE_QU = DNSQuestionType.QU DNS_QUESTION_TYPE_QM = DNSQuestionType.QM +IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) + if TYPE_CHECKING: from .._core import Zeroconf @@ -110,6 +114,29 @@ def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4 _cached_ip_addresses_wrapper = _cached_ip_addresses +def _get_ip_address_object_from_record(record: DNSAddress) -> Optional[Union[IPv4Address, IPv6Address]]: + """Get the IP address object from the record.""" + if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id is not None: + return _ip_bytes_and_scope_to_address(record.address, record.scope_id) + return _cached_ip_addresses_wrapper(record.address) + + +def _ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: + """Convert the bytes and scope to an IP address object.""" + base_address = _cached_ip_addresses_wrapper(address) + if base_address is not None and base_address.is_link_local: + return _cached_ip_addresses_wrapper(f"{base_address}%{scope}") + return base_address + + +def _str_without_scope_id(addr: Union[IPv4Address, IPv6Address]) -> str: + """Return the string representation of the address without the scope id.""" + if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: + address_str = str(addr) + return address_str.partition('%')[0] + return str(addr) + + class ServiceInfo(RecordUpdateListener): """Service information. @@ -177,6 +204,7 @@ def __init__( raise TypeError("addresses and parsed_addresses cannot be provided together") if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException + self.interface_index = interface_index self.text = b'' self.type = type_ self._name = name @@ -199,7 +227,6 @@ def __init__( self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl - self.interface_index = interface_index self._new_records_futures: Optional[Set[asyncio.Future]] = None self._dns_address_cache: Optional[List[DNSAddress]] = None self._dns_pointer_cache: Optional[DNSPointer] = None @@ -243,7 +270,10 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - addr = _cached_ip_addresses_wrapper(address) + if IPADDRESS_SUPPORTS_SCOPE_ID and len(address) == 16 and self.interface_index is not None: + addr = _ip_bytes_and_scope_to_address(address, self.interface_index) + else: + addr = _cached_ip_addresses_wrapper(address) if addr is None: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" @@ -322,10 +352,10 @@ def ip_addresses_by_version( def _ip_addresses_by_version_value( self, version_value: int_ - ) -> Union[List[IPv4Address], List[IPv6Address], List[_BaseAddress]]: + ) -> Union[List[IPv4Address], List[IPv6Address]]: """Backend for addresses_by_version that uses the raw value.""" if version_value == _IPVersion_All_value: - return [*self._ipv4_addresses, *self._ipv6_addresses] + return [*self._ipv4_addresses, *self._ipv6_addresses] # type: ignore[return-value] if version_value == _IPVersion_V4Only_value: return self._ipv4_addresses return self._ipv6_addresses @@ -339,7 +369,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: This means the first address will always be the most recently added address of the given IP version. """ - return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] + return [_str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local @@ -351,12 +381,7 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st This means the first address will always be the most recently added address of the given IP version. """ - if self.interface_index is None: - return self.parsed_addresses(version) - return [ - f"{addr}%{self.interface_index}" if addr.version == 6 and addr.is_link_local else str(addr) - for addr in self._ip_addresses_by_version_value(version.value) - ] + return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: """Sets properties and text of this info from a dictionary""" @@ -421,8 +446,8 @@ def _get_ip_addresses_from_cache_lifo( for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue - ip_addr = _cached_ip_addresses_wrapper(record.address) - if ip_addr is not None: + ip_addr = _get_ip_address_object_from_record(record) + if ip_addr is not None and ip_addr not in address_list: address_list.append(ip_addr) address_list.reverse() # Reverse to get LIFO order return address_list @@ -471,7 +496,7 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo dns_address_record = record if TYPE_CHECKING: assert isinstance(dns_address_record, DNSAddress) - ip_addr = _cached_ip_addresses_wrapper(dns_address_record.address) + ip_addr = _get_ip_address_object_from_record(dns_address_record) if ip_addr is None: log.warning( "Encountered invalid address while processing %s: %s", diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 7d437d232..482b3b0ce 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -7,6 +7,7 @@ import logging import os import socket +import sys import threading import unittest from ipaddress import ip_address @@ -538,6 +539,7 @@ def test_multiple_addresses(): assert info.addresses == [address, address] assert info.parsed_addresses() == [address_parsed, address_parsed] assert info.parsed_scoped_addresses() == [address_parsed, address_parsed] + ipaddress_supports_scope_id = sys.version_info >= (3, 9, 0) if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): address_v6_parsed = "2001:db8::1" @@ -576,14 +578,18 @@ def test_multiple_addresses(): assert info.ip_addresses_by_version(r.IPVersion.All) == [ ip_address(address), ip_address(address_v6), - ip_address(address_v6_ll), + ip_address(address_v6_ll_scoped_parsed) + if ipaddress_supports_scope_id + else ip_address(address_v6_ll), ] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ip_address(address)] assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6, address_v6_ll] assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ ip_address(address_v6), - ip_address(address_v6_ll), + ip_address(address_v6_ll_scoped_parsed) + if ipaddress_supports_scope_id + else ip_address(address_v6_ll), ] assert info.parsed_addresses() == [address_parsed, address_v6_parsed, address_v6_ll_parsed] assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] @@ -591,15 +597,60 @@ def test_multiple_addresses(): assert info.parsed_scoped_addresses() == [ address_parsed, address_v6_parsed, - address_v6_ll_scoped_parsed, + address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ address_v6_parsed, - address_v6_ll_scoped_parsed, + address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, ] +@unittest.skipIf(sys.version_info < (3, 9, 0), 'Requires newer python') +def test_scoped_addresses_from_cache(): + type_ = "_http._tcp.local." + registration_name = f"scoped.{type_}" + zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + host = "scoped.local." + + zeroconf.cache.async_add_records( + [ + r.DNSPointer( + type_, + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + registration_name, + ), + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + 0, + 0, + 80, + host, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6"), + scope_id=12, + ), + ] + ) + + # New kwarg way + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zeroconf) + assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%12"] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%12")] + zeroconf.close() + + # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio From 46e5351661f1eb8e95a2dab97025fa8f33a3b63b Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Dec 2023 20:46:41 +0000 Subject: [PATCH 1040/1433] 0.128.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8233562aa..944bb8b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.128.1 (2023-12-10) + +### Fix + +* Correct handling of IPv6 addresses with scope_id in ServiceInfo ([#1322](https://github.com/python-zeroconf/python-zeroconf/issues/1322)) ([`1682991`](https://github.com/python-zeroconf/python-zeroconf/commit/1682991b985b1f7b2bf0cff1a7eb7793070e7cb1)) + ## v0.128.0 (2023-12-02) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 4bfe3a826..8bce0b513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.0" +version = "0.128.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b994698b9..64cd00338 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.0' +__version__ = '0.128.1' __license__ = 'LGPL' From a0dac46c01202b3d5a0823ac1928fc1d75332522 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 11:04:16 -1000 Subject: [PATCH 1041/1433] fix: match cython version for dev deps to build deps (#1325) --- poetry.lock | 305 +++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 2 files changed, 158 insertions(+), 149 deletions(-) diff --git a/poetry.lock b/poetry.lock index ebcbf373b..71c5d27cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,15 +1,14 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "async-timeout" -version = "4.0.2" +version = "4.0.3" description = "Timeout context manager for asyncio programs" -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] [package.dependencies] @@ -19,7 +18,6 @@ typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -29,63 +27,71 @@ files = [ [[package]] name = "coverage" -version = "7.2.3" +version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5"}, - {file = "coverage-7.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4"}, - {file = "coverage-7.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2"}, - {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013"}, - {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa"}, - {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b"}, - {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257"}, - {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535"}, - {file = "coverage-7.2.3-cp310-cp310-win32.whl", hash = "sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91"}, - {file = "coverage-7.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e"}, - {file = "coverage-7.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79"}, - {file = "coverage-7.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc"}, - {file = "coverage-7.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df"}, - {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623"}, - {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d"}, - {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93"}, - {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"}, - {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4"}, - {file = "coverage-7.2.3-cp311-cp311-win32.whl", hash = "sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925"}, - {file = "coverage-7.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910"}, - {file = "coverage-7.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c"}, - {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9"}, - {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1"}, - {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1"}, - {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21"}, - {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841"}, - {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48"}, - {file = "coverage-7.2.3-cp37-cp37m-win32.whl", hash = "sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1"}, - {file = "coverage-7.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f"}, - {file = "coverage-7.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab"}, - {file = "coverage-7.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040"}, - {file = "coverage-7.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1"}, - {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911"}, - {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462"}, - {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c"}, - {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd"}, - {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152"}, - {file = "coverage-7.2.3-cp38-cp38-win32.whl", hash = "sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22"}, - {file = "coverage-7.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367"}, - {file = "coverage-7.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235"}, - {file = "coverage-7.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21"}, - {file = "coverage-7.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934"}, - {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859"}, - {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9"}, - {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539"}, - {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe"}, - {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4"}, - {file = "coverage-7.2.3-cp39-cp39-win32.whl", hash = "sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30"}, - {file = "coverage-7.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a"}, - {file = "coverage-7.2.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0"}, - {file = "coverage-7.2.3.tar.gz", hash = "sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, ] [package.dependencies] @@ -96,64 +102,80 @@ toml = ["tomli"] [[package]] name = "cython" -version = "0.29.34" -description = "The Cython compiler for writing C extensions for the Python language." -category = "dev" +version = "3.0.6" +description = "The Cython compiler for writing C extensions in the Python language." optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "Cython-0.29.34-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:742544024ddb74314e2d597accdb747ed76bd126e61fcf49940a5b5be0a8f381"}, - {file = "Cython-0.29.34-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:03daae07f8cbf797506446adae512c3dd86e7f27a62a541fa1ee254baf43e32c"}, - {file = "Cython-0.29.34-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5a8de3e793a576e40ca9b4f5518610cd416273c7dc5e254115656b6e4ec70663"}, - {file = "Cython-0.29.34-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:60969d38e6a456a67e7ef8ae20668eff54e32ba439d4068ccf2854a44275a30f"}, - {file = "Cython-0.29.34-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:21b88200620d80cfe193d199b259cdad2b9af56f916f0f7f474b5a3631ca0caa"}, - {file = "Cython-0.29.34-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:308c8f1e58bf5e6e8a1c4dcf8abbd2d13d0f9b1e582f4d9ae8b89857342d8bb5"}, - {file = "Cython-0.29.34-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d8f822fb6ecd5d88c42136561f82960612421154fc5bf23c57103a367bb91356"}, - {file = "Cython-0.29.34-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56866323f1660cecb4d5ff3a1fba92a56b91b7cfae0a8253777aa4bdb3bdf9a8"}, - {file = "Cython-0.29.34-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e971db8aeb12e7c0697cefafe65eefcc33ff1224ae3d8c7f83346cbc42c6c270"}, - {file = "Cython-0.29.34-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4401270b0dc464c23671e2e9d52a60985f988318febaf51b047190e855bbe7d"}, - {file = "Cython-0.29.34-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:dce0a36d163c05ae8b21200059511217d79b47baf2b7b0f926e8367bd7a3cc24"}, - {file = "Cython-0.29.34-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dbd79221869ee9a6ccc4953b2c8838bb6ae08ab4d50ea4b60d7894f03739417b"}, - {file = "Cython-0.29.34-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0f4229df10bc4545ebbeaaf96ebb706011d8b333e54ed202beb03f2bee0a50e"}, - {file = "Cython-0.29.34-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fd1ea21f1cebf33ae288caa0f3e9b5563a709f4df8925d53bad99be693fc0d9b"}, - {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d7ef5f68f4c5baa93349ea54a352f8716d18bee9a37f3e93eff38a5d4e9b7262"}, - {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:459994d1de0f99bb18fad9f2325f760c4b392b1324aef37bcc1cd94922dfce41"}, - {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:1d6c809e2f9ce5950bbc52a1d2352ef3d4fc56186b64cb0d50c8c5a3c1d17661"}, - {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f674ceb5f722d364395f180fbac273072fc1a266aab924acc9cfd5afc645aae1"}, - {file = "Cython-0.29.34-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9489de5b2044dcdfd9d6ca8242a02d560137b3c41b1f5ae1c4f6707d66d6e44d"}, - {file = "Cython-0.29.34-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5c121dc185040f4333bfded68963b4529698e1b6d994da56be32c97a90c896b6"}, - {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b6149f7cc5b31bccb158c5b968e5a8d374fdc629792e7b928a9b66e08b03fca5"}, - {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0ab3cbf3d62b0354631a45dc93cfcdf79098663b1c65a6033af4a452b52217a7"}, - {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:4a2723447d1334484681d5aede34184f2da66317891f94b80e693a2f96a8f1a7"}, - {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e40cf86aadc29ecd1cb6de67b0d9488705865deea4fc185c7ad56d7a6fc78703"}, - {file = "Cython-0.29.34-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8c3cd8bb8e880a3346f5685601004d96e0a2221e73edcaeea57ea848618b4ac6"}, - {file = "Cython-0.29.34-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0e9032cd650b0cb1d2c2ef2623f5714c14d14c28d7647d589c3eeed0baf7428e"}, - {file = "Cython-0.29.34-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bdb3285660e3068438791ace7dd7b1efd6b442a10b5c8d7a4f0c9d184d08c8ed"}, - {file = "Cython-0.29.34-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a8ad755f9364e720f10a36734a1c7a5ced5c679446718b589259261438a517c9"}, - {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7595d29eaee95633dd8060f50f0e54b27472d01587659557ebcfe39da3ea946b"}, - {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ef7879668214d80ea3914c17e7d4e1ebf4242e0dd4dabe95ca5ccbe75589a5"}, - {file = "Cython-0.29.34-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ccb223b5f0fd95d8d27561efc0c14502c0945f1a32274835831efa5d5baddfc1"}, - {file = "Cython-0.29.34-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:11b1b278b8edef215caaa5250ad65a10023bfa0b5a93c776552248fc6f60098d"}, - {file = "Cython-0.29.34-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5718319a01489688fdd22ddebb8e2fcbbd60be5f30de4336ea7063c3ae29fbe5"}, - {file = "Cython-0.29.34-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:cfb2302ef617d647ee590a4c0a00ba3c2da05f301dcefe7721125565d2e51351"}, - {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:67b850cf46b861bc27226d31e1d87c0e69869a02f8d3cc5d5bef549764029879"}, - {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0963266dad685812c1dbb758fcd4de78290e3adc7db271c8664dcde27380b13e"}, - {file = "Cython-0.29.34-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7879992487d9060a61393eeefe00d299210256928dce44d887b6be313d342bac"}, - {file = "Cython-0.29.34-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:44733366f1604b0c327613b6918469284878d2f5084297d10d26072fc6948d51"}, - {file = "Cython-0.29.34-py2.py3-none-any.whl", hash = "sha256:be4f6b7be75a201c290c8611c0978549c60353890204573078e865423dbe3c83"}, - {file = "Cython-0.29.34.tar.gz", hash = "sha256:1909688f5d7b521a60c396d20bba9e47a1b2d2784bfb085401e1e1e7d29a29a8"}, + {file = "Cython-3.0.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcdfbf6fc7d0bd683d55e617c3d5a5f25b28ce8b405bc1e89054fc7c52a97e5"}, + {file = "Cython-3.0.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccbee314f8d15ee8ddbe270859dda427e1187123f2c7c41526d1f260eee6c8f7"}, + {file = "Cython-3.0.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b992f36ffa1294921fca5f6488ea192fadd75770dc64fa25975379382551e9"}, + {file = "Cython-3.0.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2e90a75d405070f3c41e701bb8005892f14d42322f1d8fd00a61d660bbae7"}, + {file = "Cython-3.0.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4121c1160bc1bd8828546e8ce45906bd9ff27799d14747ce3fbbc9d67efbb1b8"}, + {file = "Cython-3.0.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:519814b8f80869ee5f9ee2cb2363e5c310067c0298cbea291c556b22da1ef6ae"}, + {file = "Cython-3.0.6-cp310-cp310-win32.whl", hash = "sha256:b029d8c754ef867ab4d67fc2477dde9782bf0409cb8e4024a7d29cf5aff37530"}, + {file = "Cython-3.0.6-cp310-cp310-win_amd64.whl", hash = "sha256:2262390f453eedf600e084b074144286576ed2a56bb7fbfe15ad8d9499eceb52"}, + {file = "Cython-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfe8c7ac60363769ed8d91fca26398aaa9640368ab999a79b0ccb5e788d3bcf8"}, + {file = "Cython-3.0.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e31a9b18ec6ce57eb3479df920e6093596fe4ba8010dcc372720040386b4bdb"}, + {file = "Cython-3.0.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca2542f1f34f0141475b13777df040c31f2073a055097734a0a793ac3a4fb72"}, + {file = "Cython-3.0.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c1c38dad4bd85e142ccbe2f88122807f8d5a75352321e1e4baf2b293df7c6"}, + {file = "Cython-3.0.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc4b4e76c1414584bb55465dfb6f41dd6bd27fd53fb41ddfcaca9edf00c1f80e"}, + {file = "Cython-3.0.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:805a2c532feee09aeed064eaeb7b6ee35cbab650569d0a3756975f3cc4f246cf"}, + {file = "Cython-3.0.6-cp311-cp311-win32.whl", hash = "sha256:dcdb9a177c7c385fe0c0709a9a6790b6508847d67dcac76bb65a2c7ea447efe5"}, + {file = "Cython-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:b8640b7f6503292c358cef925df5a69adf230045719893ffe20ad98024fdf7ae"}, + {file = "Cython-3.0.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:16b3b02cc7b3bc42ee1a0118b1465ca46b0f3fb32d003e6f1a3a352a819bb9a3"}, + {file = "Cython-3.0.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11e1d9b153573c425846b627bef52b3b99cb73d4fbfbb136e500a878d4b5e803"}, + {file = "Cython-3.0.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a7a406f78c2f297bf82136ff5deac3150288446005ed1e56552a9e3ac1469f"}, + {file = "Cython-3.0.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88be4fbc760de8f313df89ca8256098c0963c9ec72f3aa88538384b80ef1a6ef"}, + {file = "Cython-3.0.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea2e5a7c503b41618bfb10e4bc610f780ab1c729280531b5cabb24e05aa21cf2"}, + {file = "Cython-3.0.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d296b48e1410cab50220a28a834167f2d7ac6c0e7de12834d66e42248a1b0f6"}, + {file = "Cython-3.0.6-cp312-cp312-win32.whl", hash = "sha256:7f19e99c6e334e9e30dfa844c3ca4ac09931b94dbba406c646bde54687aed758"}, + {file = "Cython-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:9cae02e26967ffb6503c6e91b77010acbadfb7189a5a11d6158d634fb0f73679"}, + {file = "Cython-3.0.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cb6a54543869a5b0ad009d86eb0ebc0879fab838392bfd253ad6d4f5e0f17d84"}, + {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d2d9e53bf021cc7a5c7b6b537b5b5a7ba466ba7348d498aa17499d0ad12637e"}, + {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05d15854b2b363b35c755d22015c1c2fc590b8128202f8c9eb85578461101d9c"}, + {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5548316497a3b8b2d9da575ea143476472db90dee73c67def061621940f78ae"}, + {file = "Cython-3.0.6-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9b853e0855e4b3d164c05b24718e5e2df369e5af54f47cb8d923c4f497dfc92c"}, + {file = "Cython-3.0.6-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2c77f97f462a40a319dda7e28c1669370cb26f9175f3e8f9bab99d2f8f3f2f09"}, + {file = "Cython-3.0.6-cp36-cp36m-win32.whl", hash = "sha256:3ac8b6734f2cad5640f2da21cd33cf88323547d07e445fb7453ab38ec5033b1f"}, + {file = "Cython-3.0.6-cp36-cp36m-win_amd64.whl", hash = "sha256:8dd5f5f3587909ff71f0562f50e00d4b836c948e56e8f74897b12f38a29e41b9"}, + {file = "Cython-3.0.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9c0472c6394750469062deb2c166125b10411636f63a0418b5c36a60d0c9a96a"}, + {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97081932c8810bb99cb26b4b0402202a1764b58ee287c8b306071d2848148c24"}, + {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e781b3880dfd0d4d37983c9d414bfd5f26c2141f6d763d20ef1964a0a4cb2405"}, + {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef88c46e91e21772a5d3b6b1e70a6da5fe098154ad4768888129b1c05e93bba7"}, + {file = "Cython-3.0.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a38b9e7a252ec27dbc21ee8f00f09a896e88285eebb6ed99207b2ff1ea6af28e"}, + {file = "Cython-3.0.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4975cdaf720d29288ec225b76b4f4471ff03f4f8b51841ba85d6587699ab2ad5"}, + {file = "Cython-3.0.6-cp37-cp37m-win32.whl", hash = "sha256:9b89463ea330318461ca47d3e49b5f606e7e82446b6f37e5c19b60392439674c"}, + {file = "Cython-3.0.6-cp37-cp37m-win_amd64.whl", hash = "sha256:0ca8f379b47417bfad98faeb14bf8a3966fc92cf69f8aaf7635cf6885e50d001"}, + {file = "Cython-3.0.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b3dda1e80eb577b9563cee6cf31923a7b88836b9f9be0043ec545b138b95d8e8"}, + {file = "Cython-3.0.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e34e9a96f98c379100ef4192994a311678fb5c9af34c83ba5230223577581"}, + {file = "Cython-3.0.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:345d9112fde4ae0347d656f58591fd52017c61a19779c95423bb38735fe4a401"}, + {file = "Cython-3.0.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25da0e51331ac12ff16cd858d1d836e092c984e1dc45d338166081d3802297c0"}, + {file = "Cython-3.0.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:eebbf09089b4988b9f398ed46f168892e32fcfeec346b15954fdd818aa103456"}, + {file = "Cython-3.0.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e3ed0c125556324fa49b9e92bea13be7b158fcae6f72599d63c8733688257788"}, + {file = "Cython-3.0.6-cp38-cp38-win32.whl", hash = "sha256:86e1e5a5c9157a547d0a769de59c98a1fc5e46cfad976f32f60423cc6de11052"}, + {file = "Cython-3.0.6-cp38-cp38-win_amd64.whl", hash = "sha256:0d45a84a315bd84d1515cd3571415a0ee0709eb4e2cd4b13668ede928af344a7"}, + {file = "Cython-3.0.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a8e788e64b659bb8fe980bc37da3118e1f7285dec40c5fb293adabc74d4205f2"}, + {file = "Cython-3.0.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a77a174c7fb13d80754c8bf9912efd3f3696d13285b2f568eca17324263b3f7"}, + {file = "Cython-3.0.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1074e84752cd0daf3226823ddbc37cca8bc45f61c94a1db2a34e641f2b9b0797"}, + {file = "Cython-3.0.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49d5cae02d56e151e1481e614a1af9a0fe659358f2aa5eca7a18f05aa641db61"}, + {file = "Cython-3.0.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b94610fa49e36db068446cfd149a42e3246f38a4256bbe818512ac181446b4b"}, + {file = "Cython-3.0.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fabb2d14dd71add618a7892c40ffec584d1dae1e477caa193778e52e06821d83"}, + {file = "Cython-3.0.6-cp39-cp39-win32.whl", hash = "sha256:ce442c0be72ab014c305399d955b78c3d1e69d5a5ce24398122b605691b69078"}, + {file = "Cython-3.0.6-cp39-cp39-win_amd64.whl", hash = "sha256:8a05f79a0761fc76c42e945e5a9cb5d7986aa9e8e526fdf52bd9ca61a12d4567"}, + {file = "Cython-3.0.6-py2.py3-none-any.whl", hash = "sha256:5921a175ea20779d4443ef99276cfa9a1a47de0e32d593be7679be741c9ed93b"}, + {file = "Cython-3.0.6.tar.gz", hash = "sha256:399d185672c667b26eabbdca420c98564583798af3bc47670a8a09e9f19dd660"}, ] [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.2.0" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] @@ -163,7 +185,6 @@ test = ["pytest (>=6)"] name = "ifaddr" version = "0.2.0" description = "Cross-platform network interface and IP address enumeration library" -category = "main" optional = false python-versions = "*" files = [ @@ -173,14 +194,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "6.6.0" +version = "6.7.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, - {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, + {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, + {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, ] [package.dependencies] @@ -190,13 +210,12 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -206,26 +225,24 @@ files = [ [[package]] name = "packaging" -version = "23.1" +version = "23.2" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] name = "pluggy" -version = "1.0.0" +version = "1.2.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.dependencies] @@ -237,14 +254,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.3.1" +version = "7.4.3" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] @@ -257,13 +273,12 @@ pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "0.20.3" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -281,14 +296,13 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy [[package]] name = "pytest-cov" -version = "4.0.0" +version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, - {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] @@ -300,14 +314,13 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale [[package]] name = "pytest-timeout" -version = "2.1.0" +version = "2.2.0" description = "pytest plugin to abort hanging tests" -category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "pytest-timeout-2.1.0.tar.gz", hash = "sha256:c07ca07404c612f8abbe22294b23c368e2e5104b521c1790195561f37e1ac3d9"}, - {file = "pytest_timeout-2.1.0-py3-none-any.whl", hash = "sha256:f6f50101443ce70ad325ceb4473c4255e9d74e3c7cd0ef827309dfa4c0d975c6"}, + {file = "pytest-timeout-2.2.0.tar.gz", hash = "sha256:3b0b95dabf3cb50bac9ef5ca912fa0cfc286526af17afc806824df20c2f72c90"}, + {file = "pytest_timeout-2.2.0-py3-none-any.whl", hash = "sha256:bde531e096466f49398a59f2dde76fa78429a09a12411466f88a07213e220de2"}, ] [package.dependencies] @@ -317,7 +330,6 @@ pytest = ">=5.0.0" name = "setuptools" version = "65.7.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -334,7 +346,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -344,21 +355,19 @@ files = [ [[package]] name = "typing-extensions" -version = "4.5.0" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] [[package]] name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -373,4 +382,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "1b871ae566e35d2aa05a22a4ff564eaec72807a4c37a012e41f8287831435b74" +content-hash = "5d7b707a062b320ee2930929c2b948e1e542f16eba9363175eaa09f09b111a02" diff --git a/pyproject.toml b/pyproject.toml index 8bce0b513..06215b5db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ ifaddr = ">=0.1.7" pytest = "^7.2.0" pytest-cov = "^4.0.0" pytest-asyncio = "^0.20.3" -cython = "^0.29.32" +cython = "^3.0.5" setuptools = "^65.6.3" pytest-timeout = "^2.1.0" From ecea4e4217892ca8cf763074ac3e5d1b898acd21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 11:10:16 -1000 Subject: [PATCH 1042/1433] fix: timestamps missing double precision (#1324) --- src/zeroconf/_cache.pxd | 6 +++--- src/zeroconf/_cache.py | 4 ++-- src/zeroconf/_dns.pxd | 14 +++++++------- src/zeroconf/_handlers/answers.pxd | 4 ++-- .../_handlers/multicast_outgoing_queue.pxd | 2 +- src/zeroconf/_handlers/record_manager.pxd | 2 +- src/zeroconf/_handlers/record_manager.py | 6 +++--- src/zeroconf/_listener.pxd | 6 +++--- src/zeroconf/_protocol/outgoing.pxd | 6 +++--- src/zeroconf/_protocol/outgoing.py | 4 ++-- src/zeroconf/_services/browser.pxd | 4 ++-- src/zeroconf/_services/info.pxd | 12 ++++++------ src/zeroconf/_updates.pxd | 2 +- src/zeroconf/_utils/time.pxd | 2 +- 14 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index ef1c1353c..84107957a 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -36,7 +36,7 @@ cdef class DNSCache: @cython.locals( record=DNSRecord, ) - cpdef async_expire(self, float now) + cpdef async_expire(self, double now) @cython.locals( records=cython.dict, @@ -68,6 +68,6 @@ cdef class DNSCache: @cython.locals( record=DNSRecord, - created_float=cython.float, + created_double=double, ) - cpdef async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, float now) + cpdef async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 83206e79d..35a13cf64 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -243,7 +243,7 @@ def async_mark_unique_records_older_than_1s_to_expire( answers_rrset = set(answers) for name, type_, class_ in unique_types: for record in self.async_all_by_details(name, type_, class_): - created_float = record.created - if (now - created_float > _ONE_SECOND) and record not in answers_rrset: + created_double = record.created + if (now - created_double > _ONE_SECOND) and record not in answers_rrset: # Expire in 1s record.set_created_ttl(now, 1) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 255181f8b..d4116a66a 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -43,7 +43,7 @@ cdef class DNSQuestion(DNSEntry): cdef class DNSRecord(DNSEntry): cdef public cython.float ttl - cdef public cython.float created + cdef public double created cdef bint _suppressed_by_answer(self, DNSRecord answer) @@ -52,19 +52,19 @@ cdef class DNSRecord(DNSEntry): ) cpdef bint suppressed_by(self, object msg) - cpdef get_remaining_ttl(self, cython.float now) + cpdef get_remaining_ttl(self, double now) - cpdef get_expiration_time(self, cython.uint percent) + cpdef double get_expiration_time(self, cython.uint percent) - cpdef bint is_expired(self, cython.float now) + cpdef bint is_expired(self, double now) - cpdef bint is_stale(self, cython.float now) + cpdef bint is_stale(self, double now) - cpdef bint is_recent(self, cython.float now) + cpdef bint is_recent(self, double now) cpdef reset_ttl(self, DNSRecord other) - cpdef set_created_ttl(self, cython.float now, cython.float ttl) + cpdef set_created_ttl(self, double now, cython.float ttl) cdef class DNSAddress(DNSRecord): diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index 7efc45c70..5a3010ad9 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -15,8 +15,8 @@ cdef class QuestionAnswers: cdef class AnswerGroup: - cdef public float send_after - cdef public float send_before + cdef public double send_after + cdef public double send_before cdef public cython.dict answers diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd index 59a4fb2a9..1a8d6741f 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd @@ -19,7 +19,7 @@ cdef class MulticastOutgoingQueue: cdef object _aggregation_delay @cython.locals(last_group=AnswerGroup, random_int=cython.uint) - cpdef async_add(self, float now, cython.dict answers) + cpdef async_add(self, double now, cython.dict answers) @cython.locals(pending=AnswerGroup) cdef _remove_answers_from_queue(self, cython.dict answers) diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 8775108b5..0f543aff2 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -31,7 +31,7 @@ cdef class RecordManager: record=DNSRecord, answers=cython.list, maybe_entry=DNSRecord, - now_float=cython.float + now_double=double ) cpdef async_updates_from_response(self, DNSIncoming msg) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index cbf88abd9..129acd0b6 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -84,7 +84,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: other_adds: List[DNSRecord] = [] removes: Set[DNSRecord] = set() now = msg.now - now_float = now + now_double = now unique_types: Set[Tuple[str, int, int]] = set() cache = self.cache answers = msg.answers() @@ -113,7 +113,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: record = cast(_UniqueRecordsType, record) maybe_entry = cache.async_get_unique(record) - if not record.is_expired(now_float): + if not record.is_expired(now_double): if maybe_entry is not None: maybe_entry.reset_ttl(record) else: @@ -129,7 +129,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now_float) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now_double) if updates: self.async_updates(now, updates) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 729e0de69..8b144653e 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -22,18 +22,18 @@ cdef class AsyncListener: cdef ServiceRegistry _registry cdef RecordManager _record_manager cdef public cython.bytes data - cdef public cython.float last_time + cdef public double last_time cdef public DNSIncoming last_message cdef public object transport cdef public object sock_description cdef public cython.dict _deferred cdef public cython.dict _timers - @cython.locals(now=cython.float, debug=cython.bint) + @cython.locals(now=double, debug=cython.bint) cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) @cython.locals(msg=DNSIncoming) - cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, cython.float now, bytes data, cython.tuple addrs) + cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, double now, bytes data, cython.tuple addrs) cdef _cancel_any_timers_for_addr(self, object addr) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 52237f09d..3460f0c74 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -71,7 +71,7 @@ cdef class DNSOutgoing: index=cython.uint, length=cython.uint ) - cdef cython.bint _write_record(self, DNSRecord record, float now) + cdef cython.bint _write_record(self, DNSRecord record, double now) @cython.locals(class_=cython.uint) cdef _write_record_class(self, DNSEntry record) @@ -92,7 +92,7 @@ cdef class DNSOutgoing: cdef bint _has_more_to_add(self, unsigned int questions_offset, unsigned int answer_offset, unsigned int authority_offset, unsigned int additional_offset) - cdef _write_ttl(self, DNSRecord record, float now) + cdef _write_ttl(self, DNSRecord record, double now) @cython.locals( labels=cython.list, @@ -135,7 +135,7 @@ cdef class DNSOutgoing: cpdef add_answer(self, DNSIncoming inp, DNSRecord record) - @cython.locals(now_float=cython.float) + @cython.locals(now_double=double) cpdef add_answer_at_time(self, DNSRecord record, object now) cpdef add_authorative_answer(self, DNSPointer record) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index e421681c9..e94cd0d2d 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -152,8 +152,8 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: """Adds an answer if it does not expire by a certain time""" - now_float = now - if record is not None and (now_float == 0 or not record.is_expired(now_float)): + now_double = now + if record is not None and (now_double == 0 or not record.is_expired(now_double)): self.answers.append((record, now)) def add_authorative_answer(self, record: DNSPointer) -> None: diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 25c0f5844..a1d79b08d 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -28,7 +28,7 @@ cdef class _DNSPointerOutgoingBucket: @cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord, qu_question=bint) cpdef generate_service_query( object zc, - float now, + double now, list type_, bint multicast, object question_type @@ -73,7 +73,7 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _enqueue_callback(self, object state_change, object type_, object name) @cython.locals(record_update=RecordUpdate, record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) - cpdef async_update_records(self, object zc, cython.float now, cython.list records) + cpdef async_update_records(self, object zc, double now, cython.list records) cpdef cython.list _names_matching_types(self, object types) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index b7a2ee30c..ae24c7698 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -66,10 +66,10 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public cython.set _get_address_and_nsec_records_cache @cython.locals(record_update=RecordUpdate, update=bint, cache=DNSCache) - cpdef async_update_records(self, object zc, cython.float now, cython.list records) + cpdef async_update_records(self, object zc, double now, cython.list records) @cython.locals(cache=DNSCache) - cpdef bint _load_from_cache(self, object zc, cython.float now) + cpdef bint _load_from_cache(self, object zc, double now) @cython.locals(length="unsigned char", index="unsigned int", key_value=bytes, key_sep_value=tuple) cdef void _unpack_text_into_properties(self) @@ -79,21 +79,21 @@ cdef class ServiceInfo(RecordUpdateListener): cdef _set_text(self, cython.bytes text) @cython.locals(record=DNSAddress) - cdef _get_ip_addresses_from_cache_lifo(self, object zc, cython.float now, object type) + cdef _get_ip_addresses_from_cache_lifo(self, object zc, double now, object type) @cython.locals( dns_service_record=DNSService, dns_text_record=DNSText, dns_address_record=DNSAddress ) - cdef bint _process_record_threadsafe(self, object zc, DNSRecord record, cython.float now) + cdef bint _process_record_threadsafe(self, object zc, DNSRecord record, double now) @cython.locals(cache=DNSCache) cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) - cdef _set_ipv4_addresses_from_cache(self, object zc, object now) + cdef _set_ipv4_addresses_from_cache(self, object zc, double now) - cdef _set_ipv6_addresses_from_cache(self, object zc, object now) + cdef _set_ipv6_addresses_from_cache(self, object zc, double now) cdef cython.list _ip_addresses_by_version_value(self, object version_value) diff --git a/src/zeroconf/_updates.pxd b/src/zeroconf/_updates.pxd index 23edf6432..e1b44a12e 100644 --- a/src/zeroconf/_updates.pxd +++ b/src/zeroconf/_updates.pxd @@ -4,6 +4,6 @@ import cython cdef class RecordUpdateListener: - cpdef async_update_records(self, object zc, cython.float now, cython.list records) + cpdef async_update_records(self, object zc, double now, cython.list records) cpdef async_update_records_complete(self) diff --git a/src/zeroconf/_utils/time.pxd b/src/zeroconf/_utils/time.pxd index 367f39b66..a96002866 100644 --- a/src/zeroconf/_utils/time.pxd +++ b/src/zeroconf/_utils/time.pxd @@ -1,4 +1,4 @@ -cpdef current_time_millis() +cpdef double current_time_millis() cpdef millis_to_seconds(object millis) From 868c2551d82a6279a2351de519b0091cc08b714a Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Dec 2023 21:21:02 +0000 Subject: [PATCH 1043/1433] 0.128.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 944bb8b74..1ccf586a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.128.2 (2023-12-10) + +### Fix + +* Timestamps missing double precision ([#1324](https://github.com/python-zeroconf/python-zeroconf/issues/1324)) ([`ecea4e4`](https://github.com/python-zeroconf/python-zeroconf/commit/ecea4e4217892ca8cf763074ac3e5d1b898acd21)) +* Match cython version for dev deps to build deps ([#1325](https://github.com/python-zeroconf/python-zeroconf/issues/1325)) ([`a0dac46`](https://github.com/python-zeroconf/python-zeroconf/commit/a0dac46c01202b3d5a0823ac1928fc1d75332522)) + ## v0.128.1 (2023-12-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 06215b5db..0f389bc77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.1" +version = "0.128.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 64cd00338..ab3ec6338 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.1' +__version__ = '0.128.2' __license__ = 'LGPL' From cd7a16a32c37b2f7a2e90d3c749525a5393bad57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 13:04:13 -1000 Subject: [PATCH 1044/1433] fix: correct nsec record writing (#1326) --- src/zeroconf/_dns.py | 16 ++++++++++------ tests/test_protocol.py | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 4ca429a81..66fb5b86d 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -480,17 +480,21 @@ def __init__( def write(self, out: 'DNSOutgoing') -> None: """Used in constructing an outgoing packet.""" bitmap = bytearray(b'\0' * 32) + total_octets = 0 for rdtype in self.rdtypes: if rdtype > 255: # mDNS only supports window 0 - continue - offset = rdtype % 256 - byte = offset // 8 + raise ValueError(f"rdtype {rdtype} is too large for NSEC") + byte = rdtype // 8 total_octets = byte + 1 - bitmap[byte] |= 0x80 >> (offset % 8) + bitmap[byte] |= 0x80 >> (rdtype % 8) + if total_octets == 0: + # NSEC must have at least one rdtype + # Writing an empty bitmap is not allowed + raise ValueError("NSEC must have at least one rdtype") out_bytes = bytes(bitmap[0:total_octets]) out.write_name(self.next_name) - out.write_short(0) - out.write_short(len(out_bytes)) + out._write_byte(0) # Always window 0 + out._write_byte(len(out_bytes)) out.write_string(out_bytes) def __eq__(self, other: Any) -> bool: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 0a8531042..c830b6c3f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -12,6 +12,8 @@ import unittest.mock from typing import cast +import pytest + import zeroconf as r from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis @@ -65,7 +67,22 @@ def test_parse_own_packet_nsec(self): parsed = r.DNSIncoming(generated.packets()[0]) assert answer in parsed.answers() - # Types > 255 should be ignored + # Now with the higher RD type first + answer = r.DNSNsec( + 'eufy HomeBase2-2464._hap._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'eufy HomeBase2-2464._hap._tcp.local.', + [const._TYPE_SRV, const._TYPE_TXT], + ) + + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time(answer, 0) + parsed = r.DNSIncoming(generated.packets()[0]) + assert answer in parsed.answers() + + # Types > 255 should raise an exception answer_invalid_types = r.DNSNsec( 'eufy HomeBase2-2464._hap._tcp.local.', const._TYPE_NSEC, @@ -76,8 +93,22 @@ def test_parse_own_packet_nsec(self): ) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time(answer_invalid_types, 0) - parsed = r.DNSIncoming(generated.packets()[0]) - assert answer in parsed.answers() + with pytest.raises(ValueError, match='rdtype 1000 is too large for NSEC'): + generated.packets() + + # Empty rdtypes are not allowed + answer_invalid_types = r.DNSNsec( + 'eufy HomeBase2-2464._hap._tcp.local.', + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + 'eufy HomeBase2-2464._hap._tcp.local.', + [], + ) + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + generated.add_answer_at_time(answer_invalid_types, 0) + with pytest.raises(ValueError, match='NSEC must have at least one rdtype'): + generated.packets() def test_parse_own_packet_response(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) From 816c0917c24875bd2e7a7fae54e0b3cc80c5794f Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Dec 2023 23:13:09 +0000 Subject: [PATCH 1045/1433] 0.128.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ccf586a2..e25f2880a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.128.3 (2023-12-10) + +### Fix + +* Correct nsec record writing ([#1326](https://github.com/python-zeroconf/python-zeroconf/issues/1326)) ([`cd7a16a`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7a16a32c37b2f7a2e90d3c749525a5393bad57)) + ## v0.128.2 (2023-12-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0f389bc77..78c6d73c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.2" +version = "0.128.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ab3ec6338..8e5526cd2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.2' +__version__ = '0.128.3' __license__ = 'LGPL' From 39c40051d7a63bdc63a3e2dfa20bd944fee4e761 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Dec 2023 13:31:19 -1000 Subject: [PATCH 1046/1433] fix: re-expose ServiceInfo._set_properties for backwards compat (#1327) --- src/zeroconf/_services/info.pxd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index ae24c7698..1f71daa52 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -74,7 +74,7 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(length="unsigned char", index="unsigned int", key_value=bytes, key_sep_value=tuple) cdef void _unpack_text_into_properties(self) - cdef _set_properties(self, cython.dict properties) + cpdef _set_properties(self, cython.dict properties) cdef _set_text(self, cython.bytes text) From 878a726b302530cd904b2e8fd0d48dfce6b165d3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 10 Dec 2023 23:41:00 +0000 Subject: [PATCH 1047/1433] 0.128.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e25f2880a..d8b6d01f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.128.4 (2023-12-10) + +### Fix + +* Re-expose ServiceInfo._set_properties for backwards compat ([#1327](https://github.com/python-zeroconf/python-zeroconf/issues/1327)) ([`39c4005`](https://github.com/python-zeroconf/python-zeroconf/commit/39c40051d7a63bdc63a3e2dfa20bd944fee4e761)) + ## v0.128.3 (2023-12-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 78c6d73c4..f6230672e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.3" +version = "0.128.4" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 8e5526cd2..7cdb30f51 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.3' +__version__ = '0.128.4' __license__ = 'LGPL' From e2f9f81dbc54c3dd527eeb3298897d63f99d33f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 11:07:42 -1000 Subject: [PATCH 1048/1433] fix: performance regression with ServiceInfo IPv6Addresses (#1330) --- build_ext.py | 1 + src/zeroconf/_services/info.pxd | 14 ++-- src/zeroconf/_services/info.py | 57 ++++---------- src/zeroconf/_utils/ipaddress.pxd | 14 ++++ src/zeroconf/_utils/ipaddress.py | 121 ++++++++++++++++++++++++++++++ tests/utils/test_ipaddress.py | 24 ++++++ 6 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 src/zeroconf/_utils/ipaddress.pxd create mode 100644 src/zeroconf/_utils/ipaddress.py create mode 100644 tests/utils/test_ipaddress.py diff --git a/build_ext.py b/build_ext.py index d2f32685e..0f02f53a4 100644 --- a/build_ext.py +++ b/build_ext.py @@ -39,6 +39,7 @@ def build(setup_kwargs: Any) -> None: "src/zeroconf/_services/info.py", "src/zeroconf/_services/registry.py", "src/zeroconf/_updates.py", + "src/zeroconf/_utils/ipaddress.py", "src/zeroconf/_utils/time.py", ], compiler_directives={"language_level": "3"}, # Python 3 diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 1f71daa52..ec19fcc6e 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -6,11 +6,15 @@ from .._dns cimport DNSAddress, DNSNsec, DNSPointer, DNSRecord, DNSService, DNST from .._protocol.outgoing cimport DNSOutgoing from .._record_update cimport RecordUpdate from .._updates cimport RecordUpdateListener +from .._utils.ipaddress cimport ( + get_ip_address_object_from_record, + ip_bytes_and_scope_to_address, + str_without_scope_id, +) from .._utils.time cimport current_time_millis cdef object _resolve_all_futures_to_none -cdef object _cached_ip_addresses_wrapper cdef object _TYPE_SRV cdef object _TYPE_TXT @@ -33,13 +37,7 @@ cdef cython.set _ADDRESS_RECORD_TYPES cdef bint TYPE_CHECKING cdef bint IPADDRESS_SUPPORTS_SCOPE_ID - -cdef _get_ip_address_object_from_record(DNSAddress record) - -@cython.locals(address_str=str) -cdef _str_without_scope_id(object addr) - -cdef _ip_bytes_and_scope_to_address(object addr, object scope_id) +cdef object cached_ip_addresses cdef class ServiceInfo(RecordUpdateListener): diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index e9e257636..704c46b64 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -23,8 +23,7 @@ import asyncio import random import sys -from functools import lru_cache -from ipaddress import IPv4Address, IPv6Address, _BaseAddress, ip_address +from ipaddress import IPv4Address, IPv6Address, _BaseAddress from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast from .._dns import ( @@ -47,6 +46,12 @@ run_coro_with_timeout, wait_for_future_set_or_timeout, ) +from .._utils.ipaddress import ( + cached_ip_addresses, + get_ip_address_object_from_record, + ip_bytes_and_scope_to_address, + str_without_scope_id, +) from .._utils.name import service_type_name from .._utils.net import IPVersion, _encode_address from .._utils.time import current_time_millis @@ -67,6 +72,8 @@ _TYPE_TXT, ) +IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) + _IPVersion_All_value = IPVersion.All.value _IPVersion_V4Only_value = IPVersion.V4Only.value # https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 @@ -86,7 +93,6 @@ DNS_QUESTION_TYPE_QU = DNSQuestionType.QU DNS_QUESTION_TYPE_QM = DNSQuestionType.QM -IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) if TYPE_CHECKING: from .._core import Zeroconf @@ -102,41 +108,6 @@ def instance_name_from_service_info(info: "ServiceInfo", strict: bool = True) -> return info.name[: -len(service_name) - 1] -@lru_cache(maxsize=512) -def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4Address, IPv6Address]]: - """Cache IP addresses.""" - try: - return ip_address(address) - except ValueError: - return None - - -_cached_ip_addresses_wrapper = _cached_ip_addresses - - -def _get_ip_address_object_from_record(record: DNSAddress) -> Optional[Union[IPv4Address, IPv6Address]]: - """Get the IP address object from the record.""" - if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id is not None: - return _ip_bytes_and_scope_to_address(record.address, record.scope_id) - return _cached_ip_addresses_wrapper(record.address) - - -def _ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: - """Convert the bytes and scope to an IP address object.""" - base_address = _cached_ip_addresses_wrapper(address) - if base_address is not None and base_address.is_link_local: - return _cached_ip_addresses_wrapper(f"{base_address}%{scope}") - return base_address - - -def _str_without_scope_id(addr: Union[IPv4Address, IPv6Address]) -> str: - """Return the string representation of the address without the scope id.""" - if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: - address_str = str(addr) - return address_str.partition('%')[0] - return str(addr) - - class ServiceInfo(RecordUpdateListener): """Service information. @@ -271,9 +242,9 @@ def addresses(self, value: List[bytes]) -> None: for address in value: if IPADDRESS_SUPPORTS_SCOPE_ID and len(address) == 16 and self.interface_index is not None: - addr = _ip_bytes_and_scope_to_address(address, self.interface_index) + addr = ip_bytes_and_scope_to_address(address, self.interface_index) else: - addr = _cached_ip_addresses_wrapper(address) + addr = cached_ip_addresses(address) if addr is None: raise TypeError( "Addresses must either be IPv4 or IPv6 strings, bytes, or integers;" @@ -369,7 +340,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: This means the first address will always be the most recently added address of the given IP version. """ - return [_str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] + return [str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local @@ -446,7 +417,7 @@ def _get_ip_addresses_from_cache_lifo( for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue - ip_addr = _get_ip_address_object_from_record(record) + ip_addr = get_ip_address_object_from_record(record) if ip_addr is not None and ip_addr not in address_list: address_list.append(ip_addr) address_list.reverse() # Reverse to get LIFO order @@ -496,7 +467,7 @@ def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: flo dns_address_record = record if TYPE_CHECKING: assert isinstance(dns_address_record, DNSAddress) - ip_addr = _get_ip_address_object_from_record(dns_address_record) + ip_addr = get_ip_address_object_from_record(dns_address_record) if ip_addr is None: log.warning( "Encountered invalid address while processing %s: %s", diff --git a/src/zeroconf/_utils/ipaddress.pxd b/src/zeroconf/_utils/ipaddress.pxd new file mode 100644 index 000000000..098c6ff9a --- /dev/null +++ b/src/zeroconf/_utils/ipaddress.pxd @@ -0,0 +1,14 @@ +cdef bint TYPE_CHECKING +cdef bint IPADDRESS_SUPPORTS_SCOPE_ID + +from .._dns cimport DNSAddress + + +cpdef get_ip_address_object_from_record(DNSAddress record) + +@cython.locals(address_str=str) +cpdef str_without_scope_id(object addr) + +cpdef ip_bytes_and_scope_to_address(object addr, object scope_id) + +cdef object cached_ip_addresses_wrapper diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py new file mode 100644 index 000000000..b946efb55 --- /dev/null +++ b/src/zeroconf/_utils/ipaddress.py @@ -0,0 +1,121 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" +import sys +from functools import lru_cache +from ipaddress import AddressValueError, IPv4Address, IPv6Address, NetmaskValueError +from typing import Any, Optional, Union + +from .._dns import DNSAddress +from ..const import _TYPE_AAAA + +bytes_ = bytes +int_ = int +IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) + + +class ZeroconfIPv4Address(IPv4Address): + + __slots__ = ("_str", "_is_link_local") + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize a new IPv4 address.""" + super().__init__(*args, **kwargs) + self._str = super().__str__() + self._is_link_local = super().is_link_local + + def __str__(self) -> str: + """Return the string representation of the IPv4 address.""" + return self._str + + @property + def is_link_local(self) -> bool: + """Return True if this is a link-local address.""" + return self._is_link_local + + +class ZeroconfIPv6Address(IPv6Address): + + __slots__ = ("_str", "_is_link_local") + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize a new IPv6 address.""" + super().__init__(*args, **kwargs) + self._str = super().__str__() + self._is_link_local = super().is_link_local + + def __str__(self) -> str: + """Return the string representation of the IPv6 address.""" + return self._str + + @property + def is_link_local(self) -> bool: + """Return True if this is a link-local address.""" + return self._is_link_local + + +@lru_cache(maxsize=512) +def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4Address, IPv6Address]]: + """Cache IP addresses.""" + try: + return ZeroconfIPv4Address(address) + except (AddressValueError, NetmaskValueError): + pass + + try: + return ZeroconfIPv6Address(address) + except (AddressValueError, NetmaskValueError): + return None + + +cached_ip_addresses_wrapper = _cached_ip_addresses +cached_ip_addresses = cached_ip_addresses_wrapper + + +def get_ip_address_object_from_record(record: DNSAddress) -> Optional[Union[IPv4Address, IPv6Address]]: + """Get the IP address object from the record.""" + if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id is not None: + return ip_bytes_and_scope_to_address(record.address, record.scope_id) + return cached_ip_addresses_wrapper(record.address) + + +def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: + """Convert the bytes and scope to an IP address object.""" + base_address = cached_ip_addresses_wrapper(address) + if base_address is not None and base_address.is_link_local: + return cached_ip_addresses_wrapper(f"{base_address}%{scope}") + return base_address + + +def str_without_scope_id(addr: Union[IPv4Address, IPv6Address]) -> str: + """Return the string representation of the address without the scope id.""" + if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: + address_str = str(addr) + return address_str.partition('%')[0] + return str(addr) + + +__all__ = ( + "cached_ip_addresses", + "get_ip_address_object_from_record", + "ip_bytes_and_scope_to_address", + "str_without_scope_id", +) diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py new file mode 100644 index 000000000..9dd558f27 --- /dev/null +++ b/tests/utils/test_ipaddress.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python + +"""Unit tests for zeroconf._utils.ipaddress.""" + +from zeroconf._utils import ipaddress + + +def test_cached_ip_addresses_wrapper(): + """Test the cached_ip_addresses_wrapper.""" + assert ipaddress.cached_ip_addresses('') is None + assert ipaddress.cached_ip_addresses('foo') is None + assert ( + str(ipaddress.cached_ip_addresses(b'&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F')) + == '2606:2800:220:1:248:1893:25c8:1946' + ) + assert ipaddress.cached_ip_addresses('::1') == ipaddress.IPv6Address('::1') + + ipv4 = ipaddress.cached_ip_addresses('169.254.0.0') + assert ipv4 is not None + assert ipv4.is_link_local is True + + ipv6 = ipaddress.cached_ip_addresses('fe80::1') + assert ipv6 is not None + assert ipv6.is_link_local is True From ede0a2a153ec28536905217cdc801cba9cdcacf7 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 13 Dec 2023 21:17:09 +0000 Subject: [PATCH 1049/1433] 0.128.5 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b6d01f0..4d59b0971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.128.5 (2023-12-13) + +### Fix + +* Performance regression with ServiceInfo IPv6Addresses ([#1330](https://github.com/python-zeroconf/python-zeroconf/issues/1330)) ([`e2f9f81`](https://github.com/python-zeroconf/python-zeroconf/commit/e2f9f81dbc54c3dd527eeb3298897d63f99d33f4)) + ## v0.128.4 (2023-12-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index f6230672e..0fea63f98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.4" +version = "0.128.5" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7cdb30f51..7199ca502 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.4' +__version__ = '0.128.5' __license__ = 'LGPL' From a1c84dc6adeebd155faec1a647c0f70d70de2945 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 12:47:33 -1000 Subject: [PATCH 1050/1433] feat: cache is_unspecified for zeroconf ip address objects (#1331) --- src/zeroconf/_utils/ipaddress.py | 16 ++++++++++++++-- tests/utils/test_ipaddress.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index b946efb55..abb1306f3 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -34,13 +34,14 @@ class ZeroconfIPv4Address(IPv4Address): - __slots__ = ("_str", "_is_link_local") + __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv4 address.""" super().__init__(*args, **kwargs) self._str = super().__str__() self._is_link_local = super().is_link_local + self._is_unspecified = super().is_unspecified def __str__(self) -> str: """Return the string representation of the IPv4 address.""" @@ -51,16 +52,22 @@ def is_link_local(self) -> bool: """Return True if this is a link-local address.""" return self._is_link_local + @property + def is_unspecified(self) -> bool: + """Return True if this is an unspecified address.""" + return self._is_unspecified + class ZeroconfIPv6Address(IPv6Address): - __slots__ = ("_str", "_is_link_local") + __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv6 address.""" super().__init__(*args, **kwargs) self._str = super().__str__() self._is_link_local = super().is_link_local + self._is_unspecified = super().is_unspecified def __str__(self) -> str: """Return the string representation of the IPv6 address.""" @@ -71,6 +78,11 @@ def is_link_local(self) -> bool: """Return True if this is a link-local address.""" return self._is_link_local + @property + def is_unspecified(self) -> bool: + """Return True if this is an unspecified address.""" + return self._is_unspecified + @lru_cache(maxsize=512) def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4Address, IPv6Address]]: diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 9dd558f27..3ec1a9a77 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -18,7 +18,19 @@ def test_cached_ip_addresses_wrapper(): ipv4 = ipaddress.cached_ip_addresses('169.254.0.0') assert ipv4 is not None assert ipv4.is_link_local is True + assert ipv4.is_unspecified is False + + ipv4 = ipaddress.cached_ip_addresses('0.0.0.0') + assert ipv4 is not None + assert ipv4.is_link_local is False + assert ipv4.is_unspecified is True ipv6 = ipaddress.cached_ip_addresses('fe80::1') assert ipv6 is not None assert ipv6.is_link_local is True + assert ipv6.is_unspecified is False + + ipv6 = ipaddress.cached_ip_addresses('0:0:0:0:0:0:0:0') + assert ipv6 is not None + assert ipv6.is_link_local is False + assert ipv6.is_unspecified is True From d29553ab7de6b7af70769ddb804fe2aaf492f320 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 13:05:57 -1000 Subject: [PATCH 1051/1433] feat: ensure ServiceInfo.properties always returns bytes (#1333) --- src/zeroconf/_services/info.pxd | 1 + src/zeroconf/_services/info.py | 26 +++++++++++++++----------- tests/test_services.py | 1 + 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index ec19fcc6e..6ab774248 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -72,6 +72,7 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(length="unsigned char", index="unsigned int", key_value=bytes, key_sep_value=tuple) cdef void _unpack_text_into_properties(self) + @cython.locals(properties_contain_str=bint) cpdef _set_properties(self, cython.dict properties) cdef _set_text(self, cython.bytes text) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 704c46b64..1397dcec5 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -191,7 +191,7 @@ def __init__( self.priority = priority self.server = server if server else None self.server_key = server.lower() if server else None - self._properties: Optional[Dict[Union[str, bytes], Optional[Union[str, bytes]]]] = None + self._properties: Optional[Dict[bytes, Optional[bytes]]] = None if isinstance(properties, bytes): self._set_text(properties) else: @@ -260,14 +260,8 @@ def addresses(self, value: List[bytes]) -> None: self._ipv6_addresses.append(addr) @property - def properties(self) -> Dict[Union[str, bytes], Optional[Union[str, bytes]]]: - """If properties were set in the constructor this property returns the original dictionary - of type `Dict[Union[bytes, str], Any]`. - - If properties are coming from the network, after decoding a TXT record, the keys are always - bytes and the values are either bytes, if there was a value, even empty, or `None`, if there - was none. No further decoding is attempted. The type returned is `Dict[bytes, Optional[bytes]]`. - """ + def properties(self) -> Dict[bytes, Optional[bytes]]: + """Return properties as bytes.""" if self._properties is None: self._unpack_text_into_properties() if TYPE_CHECKING: @@ -356,21 +350,31 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: """Sets properties and text of this info from a dictionary""" - self._properties = properties list_: List[bytes] = [] + properties_contain_str = False result = b'' for key, value in properties.items(): if isinstance(key, str): key = key.encode('utf-8') + properties_contain_str = True record = key if value is not None: if not isinstance(value, bytes): value = str(value).encode('utf-8') + properties_contain_str = True record += b'=' + value list_.append(record) for item in list_: result = b''.join((result, bytes((len(item),)), item)) + if not properties_contain_str: + # If there are no str keys or values, we can use the properties + # as-is, without decoding them, otherwise calling + # self.properties will lazy decode them, which is expensive. + if TYPE_CHECKING: + self._properties = cast("Dict[bytes, Optional[bytes]]", properties) + else: + self._properties = properties self.text = result def _set_text(self, text: bytes) -> None: @@ -392,7 +396,7 @@ def _unpack_text_into_properties(self) -> None: return index = 0 - properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] = {} + properties: Dict[bytes, Optional[bytes]] = {} while index < end: length = text[index] index += 1 diff --git a/tests/test_services.py b/tests/test_services.py index e21c23d94..87bb6fc9b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -133,6 +133,7 @@ def update_service(self, zeroconf, type, name): assert info.properties[b'prop_blank'] == properties['prop_blank'] assert info.properties[b'prop_true'] == b'1' assert info.properties[b'prop_false'] == b'0' + assert info.addresses == addresses[:1] # no V6 by default assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) From 9b595a1dcacf109c699953219d70fe36296c7318 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 13:28:13 -1000 Subject: [PATCH 1052/1433] feat: add decoded_properties method to ServiceInfo (#1332) --- src/zeroconf/_services/info.pxd | 4 ++++ src/zeroconf/_services/info.py | 19 +++++++++++++++++++ tests/test_services.py | 9 +++++++++ 3 files changed, 32 insertions(+) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 6ab774248..c53342cbc 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -53,6 +53,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public str server cdef public str server_key cdef public cython.dict _properties + cdef public cython.dict _decoded_properties cdef public object host_ttl cdef public object other_ttl cdef public object interface_index @@ -72,6 +73,9 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(length="unsigned char", index="unsigned int", key_value=bytes, key_sep_value=tuple) cdef void _unpack_text_into_properties(self) + @cython.locals(k=bytes, v=bytes) + cdef void _generate_decoded_properties(self) + @cython.locals(properties_contain_str=bint) cpdef _set_properties(self, cython.dict properties) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 1397dcec5..962e76bff 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -143,6 +143,7 @@ class ServiceInfo(RecordUpdateListener): "server", "server_key", "_properties", + "_decoded_properties", "host_ttl", "other_ttl", "interface_index", @@ -192,6 +193,7 @@ def __init__( self.server = server if server else None self.server_key = server.lower() if server else None self._properties: Optional[Dict[bytes, Optional[bytes]]] = None + self._decoded_properties: Optional[Dict[str, Optional[str]]] = None if isinstance(properties, bytes): self._set_text(properties) else: @@ -268,6 +270,15 @@ def properties(self) -> Dict[bytes, Optional[bytes]]: assert self._properties is not None return self._properties + @property + def decoded_properties(self) -> Dict[str, Optional[str]]: + """Return properties as strings.""" + if self._decoded_properties is None: + self._generate_decoded_properties() + if TYPE_CHECKING: + assert self._decoded_properties is not None + return self._decoded_properties + def async_clear_cache(self) -> None: """Clear the cache for this service info.""" self._dns_address_cache = None @@ -384,6 +395,14 @@ def _set_text(self, text: bytes) -> None: self.text = text # Clear the properties cache self._properties = None + self._decoded_properties = None + + def _generate_decoded_properties(self) -> None: + """Generates decoded properties from the properties""" + self._decoded_properties = { + k.decode("ascii", "replace"): None if v is None else v.decode("utf-8", "replace") + for k, v in self.properties.items() + } def _unpack_text_into_properties(self) -> None: """Unpacks the text field into properties""" diff --git a/tests/test_services.py b/tests/test_services.py index 87bb6fc9b..b7bebfa9b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -134,6 +134,13 @@ def update_service(self, zeroconf, type, name): assert info.properties[b'prop_true'] == b'1' assert info.properties[b'prop_false'] == b'0' + assert info.decoded_properties['prop_none'] is None + assert info.decoded_properties['prop_string'] == b'a_prop'.decode('utf-8') + assert info.decoded_properties['prop_float'] == '1.0' + assert info.decoded_properties['prop_blank'] == b'a blanked string'.decode('utf-8') + assert info.decoded_properties['prop_true'] == '1' + assert info.decoded_properties['prop_false'] == '0' + assert info.addresses == addresses[:1] # no V6 by default assert set(info.addresses_by_version(r.IPVersion.All)) == set(addresses) @@ -194,11 +201,13 @@ def update_service(self, zeroconf, type, name): info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None assert info.properties[b'prop_blank'] == properties['prop_blank'] + assert info.decoded_properties['prop_blank'] == b'an updated string'.decode('utf-8') cached_info = ServiceInfo(subtype, registration_name) cached_info.load_from_cache(zeroconf_browser) assert cached_info.properties is not None assert cached_info.properties[b'prop_blank'] == properties['prop_blank'] + assert cached_info.decoded_properties['prop_blank'] == b'an updated string'.decode('utf-8') zeroconf_registrar.unregister_service(info_service) service_removed.wait(1) From 9cd3e24ab625f993e4f48e399fa2e27aa4d26f93 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 13 Dec 2023 23:36:42 +0000 Subject: [PATCH 1053/1433] 0.129.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d59b0971..af0da45e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v0.129.0 (2023-12-13) + +### Feature + +* Add decoded_properties method to ServiceInfo ([#1332](https://github.com/python-zeroconf/python-zeroconf/issues/1332)) ([`9b595a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9b595a1dcacf109c699953219d70fe36296c7318)) +* Ensure ServiceInfo.properties always returns bytes ([#1333](https://github.com/python-zeroconf/python-zeroconf/issues/1333)) ([`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) +* Cache is_unspecified for zeroconf ip address objects ([#1331](https://github.com/python-zeroconf/python-zeroconf/issues/1331)) ([`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) + ## v0.128.5 (2023-12-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 0fea63f98..c30d5ba27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.128.5" +version = "0.129.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7199ca502..b2f0da536 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.128.5' +__version__ = '0.129.0' __license__ = 'LGPL' From 6c2d6e63dffac3be7465a0a917efde14f742d677 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Dec 2023 13:41:40 -1000 Subject: [PATCH 1054/1433] chore: ensure properties change is mentioned in the CHANGELOG.md file (#1334) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af0da45e5..32e70bff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ * Ensure ServiceInfo.properties always returns bytes ([#1333](https://github.com/python-zeroconf/python-zeroconf/issues/1333)) ([`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) * Cache is_unspecified for zeroconf ip address objects ([#1331](https://github.com/python-zeroconf/python-zeroconf/issues/1331)) ([`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) +### Technically breaking change + +* `ServiceInfo.properties` always returns a dictionary with type `dict[bytes, bytes | None]` instead of a mix `str` and `bytes`. It was only possible to get a mixed dictionary if it was manually passed in when `ServiceInfo` was constructed. + ## v0.128.5 (2023-12-13) ### Fix From f78a196db632c4fe017a34f1af8a58903c15a575 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Dec 2023 11:06:26 -1000 Subject: [PATCH 1055/1433] fix: ensure IPv6 scoped address construction uses the string cache (#1336) --- src/zeroconf/_utils/ipaddress.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index abb1306f3..b0b551ff1 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -113,7 +113,8 @@ def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Unio """Convert the bytes and scope to an IP address object.""" base_address = cached_ip_addresses_wrapper(address) if base_address is not None and base_address.is_link_local: - return cached_ip_addresses_wrapper(f"{base_address}%{scope}") + # Avoid expensive __format__ call by using PyUnicode_Join + return cached_ip_addresses_wrapper("".join((str(base_address), "%", str(scope)))) return base_address From 6560fad584e0d392962c9a9248759f17c416620e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 08:35:22 -1000 Subject: [PATCH 1056/1433] fix: microsecond precision loss in the query handler (#1339) --- src/zeroconf/_handlers/query_handler.pxd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 8c42144ca..3e726a533 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -39,7 +39,7 @@ cdef class _QueryResponse: cdef bint _is_probe cdef cython.list _questions - cdef float _now + cdef double _now cdef DNSCache _cache cdef cython.dict _additionals cdef cython.set _ucast @@ -91,7 +91,7 @@ cdef class QueryHandler: known_answers_set=cython.set, is_unicast=bint, is_probe=object, - now=float + now=double ) cpdef async_response(self, cython.list msgs, cython.bint unicast_source) From 157185f28bf1e83e6811e2a5cd1fa9b38966f780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 08:35:57 -1000 Subject: [PATCH 1057/1433] feat: small performance improvement constructing outgoing questions (#1340) --- src/zeroconf/_protocol/outgoing.pxd | 6 +++--- src/zeroconf/_protocol/outgoing.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 3460f0c74..4353757a0 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -127,16 +127,16 @@ cdef class DNSOutgoing: ) cpdef packets(self) - cpdef add_question_or_all_cache(self, DNSCache cache, object now, str name, object type_, object class_) + cpdef add_question_or_all_cache(self, DNSCache cache, double now, str name, object type_, object class_) - cpdef add_question_or_one_cache(self, DNSCache cache, object now, str name, object type_, object class_) + cpdef add_question_or_one_cache(self, DNSCache cache, double now, str name, object type_, object class_) cpdef add_question(self, DNSQuestion question) cpdef add_answer(self, DNSIncoming inp, DNSRecord record) @cython.locals(now_double=double) - cpdef add_answer_at_time(self, DNSRecord record, object now) + cpdef add_answer_at_time(self, DNSRecord record, double now) cpdef add_authorative_answer(self, DNSPointer record) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index e94cd0d2d..57f981690 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -148,9 +148,9 @@ def add_question(self, record: DNSQuestion) -> None: def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: """Adds an answer""" if not record.suppressed_by(inp): - self.add_answer_at_time(record, 0) + self.add_answer_at_time(record, 0.0) - def add_answer_at_time(self, record: Optional[DNSRecord], now: Union[float, int]) -> None: + def add_answer_at_time(self, record: Optional[DNSRecord], now: float_) -> None: """Adds an answer if it does not expire by a certain time""" now_double = now if record is not None and (now_double == 0 or not record.is_expired(now_double)): From 810a3093c5a9411ee97740b468bd706bdf4a95de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 08:36:39 -1000 Subject: [PATCH 1058/1433] feat: small performance improvement for ServiceInfo asking questions (#1341) --- src/zeroconf/_services/info.pxd | 2 +- src/zeroconf/_services/info.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index c53342cbc..ecc2a5344 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -124,4 +124,4 @@ cdef class ServiceInfo(RecordUpdateListener): cpdef async_clear_cache(self) @cython.locals(cache=DNSCache) - cdef _generate_request_query(self, object zc, object now, object question_type) + cdef _generate_request_query(self, object zc, double now, object question_type) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 962e76bff..3a27e10a0 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -845,7 +845,7 @@ def _generate_request_query( out.add_question_or_one_cache(cache, now, name, _TYPE_TXT, _CLASS_IN) out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_A, _CLASS_IN) out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_AAAA, _CLASS_IN) - if question_type == DNS_QUESTION_TYPE_QU: + if question_type is DNS_QUESTION_TYPE_QU: for question in out.questions: question.unicast = True return out From 73d3ab90dd3b59caab771235dd6dbedf05bfe0b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 08:41:01 -1000 Subject: [PATCH 1059/1433] feat: small performance improvement for converting time (#1342) --- src/zeroconf/_utils/time.pxd | 2 +- src/zeroconf/_utils/time.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_utils/time.pxd b/src/zeroconf/_utils/time.pxd index a96002866..f6e70fe75 100644 --- a/src/zeroconf/_utils/time.pxd +++ b/src/zeroconf/_utils/time.pxd @@ -1,4 +1,4 @@ cpdef double current_time_millis() -cpdef millis_to_seconds(object millis) +cpdef millis_to_seconds(double millis) diff --git a/src/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py index c6811585c..600d90285 100644 --- a/src/zeroconf/_utils/time.py +++ b/src/zeroconf/_utils/time.py @@ -26,15 +26,17 @@ _float = float -def current_time_millis() -> float: +def current_time_millis() -> _float: """Current time in milliseconds. The current implemention uses `time.monotonic` but may change in the future. + + The design requires the time to match asyncio.loop.time() """ return time.monotonic() * 1000 -def millis_to_seconds(millis: _float) -> float: +def millis_to_seconds(millis: _float) -> _float: """Convert milliseconds to seconds.""" return millis / 1000.0 From 6f23656576daa04e3de44e100f3ddd60ee4c560d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 08:45:41 -1000 Subject: [PATCH 1060/1433] fix: ensure question history suppresses duplicates (#1338) --- src/zeroconf/_history.pxd | 10 +++++----- tests/test_history.py | 9 +++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index c1ff7619a..02a0fc9ec 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -9,10 +9,10 @@ cdef class QuestionHistory: cdef cython.dict _history - cpdef add_question_at_time(self, DNSQuestion question, float now, cython.set known_answers) + cpdef add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers) - @cython.locals(than=cython.double, previous_question=cython.tuple, previous_known_answers=cython.set) - cpdef bint suppresses(self, DNSQuestion question, cython.double now, cython.set known_answers) + @cython.locals(than=double, previous_question=cython.tuple, previous_known_answers=cython.set) + cpdef bint suppresses(self, DNSQuestion question, double now, cython.set known_answers) - @cython.locals(than=cython.double, now_known_answers=cython.tuple) - cpdef async_expire(self, cython.double now) + @cython.locals(than=double, now_known_answers=cython.tuple) + cpdef async_expire(self, double now) diff --git a/tests/test_history.py b/tests/test_history.py index a8b8ae146..fca57be2a 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -47,11 +47,16 @@ def test_question_suppression(): def test_question_expire(): history = QuestionHistory() - question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) other_known_answers: Set[r.DNSRecord] = { r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + 'known-to-other._hap._tcp.local.', + created=now, ) } history.add_question_at_time(question, now, other_known_answers) From 7a24b88ee2a7f9d65a4fa6a636d79fdc757b6ce5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 09:26:12 -1000 Subject: [PATCH 1061/1433] chore: add get_percentage_remaining_ttl helper to DNSRecord (#1343) --- src/zeroconf/_dns.pxd | 2 ++ src/zeroconf/_dns.py | 5 +++++ tests/test_dns.py | 20 ++++++++++++++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index d4116a66a..720805177 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -54,6 +54,8 @@ cdef class DNSRecord(DNSEntry): cpdef get_remaining_ttl(self, double now) + cpdef unsigned int get_percentage_remaining_ttl(self, double now) + cpdef double get_expiration_time(self, cython.uint percent) cpdef bint is_expired(self, double now) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 66fb5b86d..262dbb5f4 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -193,6 +193,11 @@ def get_expiration_time(self, percent: _int) -> float: by a certain percentage.""" return self.created + (percent * self.ttl * 10) + def get_percentage_remaining_ttl(self, now: _float) -> _int: + """Returns the percentage remaining of the ttl between 0-100.""" + remain = (self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) - now) / self.ttl / 10 + return 0 if remain <= 0 else round(remain) + # TODO: Switch to just int here def get_remaining_ttl(self, now: _float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" diff --git a/tests/test_dns.py b/tests/test_dns.py index 0eac568dd..b7e5a8790 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -6,7 +6,6 @@ import logging import os import socket -import time import unittest import unittest.mock @@ -86,19 +85,32 @@ def test_dns_record_abc(self): record.write(None) # type: ignore[arg-type] def test_dns_record_reset_ttl(self): - record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) - time.sleep(1) - record2 = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) + start = r.current_time_millis() + record = r.DNSRecord( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, created=start + ) + later = start + 1000 + record2 = r.DNSRecord( + 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, created=later + ) now = r.current_time_millis() assert record.created != record2.created assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) + assert record.get_percentage_remaining_ttl(now) != record2.get_percentage_remaining_ttl(now) + assert record2.get_percentage_remaining_ttl(later) == 100 + assert record2.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 record.reset_ttl(record2) assert record.ttl == record2.ttl assert record.created == record2.created assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) + assert record.get_percentage_remaining_ttl(now) == record2.get_percentage_remaining_ttl(now) + assert record.get_percentage_remaining_ttl(later) == 100 + assert record2.get_percentage_remaining_ttl(later) == 100 + assert record.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 + assert record2.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 def test_service_info_dunder(self): type_ = "_test-srvc-type._tcp.local." From a0b8aed93c898aff685483bd0202e59e04c6c63c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 10:09:31 -1000 Subject: [PATCH 1062/1433] chore: partially revert add get_percentage_remaining_ttl helper to DNSRecord (#1344) --- src/zeroconf/_dns.pxd | 2 -- src/zeroconf/_dns.py | 5 ----- tests/test_dns.py | 8 -------- 3 files changed, 15 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 720805177..d4116a66a 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -54,8 +54,6 @@ cdef class DNSRecord(DNSEntry): cpdef get_remaining_ttl(self, double now) - cpdef unsigned int get_percentage_remaining_ttl(self, double now) - cpdef double get_expiration_time(self, cython.uint percent) cpdef bint is_expired(self, double now) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 262dbb5f4..66fb5b86d 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -193,11 +193,6 @@ def get_expiration_time(self, percent: _int) -> float: by a certain percentage.""" return self.created + (percent * self.ttl * 10) - def get_percentage_remaining_ttl(self, now: _float) -> _int: - """Returns the percentage remaining of the ttl between 0-100.""" - remain = (self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) - now) / self.ttl / 10 - return 0 if remain <= 0 else round(remain) - # TODO: Switch to just int here def get_remaining_ttl(self, now: _float) -> Union[int, float]: """Returns the remaining TTL in seconds.""" diff --git a/tests/test_dns.py b/tests/test_dns.py index b7e5a8790..055621356 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -97,20 +97,12 @@ def test_dns_record_reset_ttl(self): assert record.created != record2.created assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) - assert record.get_percentage_remaining_ttl(now) != record2.get_percentage_remaining_ttl(now) - assert record2.get_percentage_remaining_ttl(later) == 100 - assert record2.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 record.reset_ttl(record2) assert record.ttl == record2.ttl assert record.created == record2.created assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) - assert record.get_percentage_remaining_ttl(now) == record2.get_percentage_remaining_ttl(now) - assert record.get_percentage_remaining_ttl(later) == 100 - assert record2.get_percentage_remaining_ttl(later) == 100 - assert record.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 - assert record2.get_percentage_remaining_ttl(later + (const._DNS_HOST_TTL * 1000 / 2)) == 50 def test_service_info_dunder(self): type_ = "_test-srvc-type._tcp.local." From 7de655b6f05012f20a3671e0bcdd44a1913d7b52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 13:59:17 -1000 Subject: [PATCH 1063/1433] feat: small speed up to processing incoming records (#1345) --- src/zeroconf/_services/browser.pxd | 6 +++--- src/zeroconf/_services/info.pxd | 2 +- src/zeroconf/_updates.pxd | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index a1d79b08d..a2d55acf3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -70,10 +70,10 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _generate_ready_queries(self, object first_request, object now) - cpdef _enqueue_callback(self, object state_change, object type_, object name) + cpdef void _enqueue_callback(self, object state_change, object type_, object name) @cython.locals(record_update=RecordUpdate, record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) - cpdef async_update_records(self, object zc, double now, cython.list records) + cpdef void async_update_records(self, object zc, double now, cython.list records) cpdef cython.list _names_matching_types(self, object types) @@ -89,4 +89,4 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef _cancel_send_timer(self) - cpdef async_update_records_complete(self) + cpdef void async_update_records_complete(self) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index ecc2a5344..c17723eb7 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -65,7 +65,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public cython.set _get_address_and_nsec_records_cache @cython.locals(record_update=RecordUpdate, update=bint, cache=DNSCache) - cpdef async_update_records(self, object zc, double now, cython.list records) + cpdef void async_update_records(self, object zc, double now, cython.list records) @cython.locals(cache=DNSCache) cpdef bint _load_from_cache(self, object zc, double now) diff --git a/src/zeroconf/_updates.pxd b/src/zeroconf/_updates.pxd index e1b44a12e..3547d7295 100644 --- a/src/zeroconf/_updates.pxd +++ b/src/zeroconf/_updates.pxd @@ -4,6 +4,6 @@ import cython cdef class RecordUpdateListener: - cpdef async_update_records(self, object zc, double now, cython.list records) + cpdef void async_update_records(self, object zc, double now, cython.list records) - cpdef async_update_records_complete(self) + cpdef void async_update_records_complete(self) From c65d869aec731b803484871e9d242a984f9f5848 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 16:48:31 -1000 Subject: [PATCH 1064/1433] feat: significantly improve efficiency of the ServiceBrowser scheduler (#1335) --- examples/browser.py | 2 +- src/zeroconf/_services/browser.pxd | 87 ++-- src/zeroconf/_services/browser.py | 441 +++++++++++----- src/zeroconf/const.py | 7 +- tests/__init__.py | 31 +- tests/services/test_browser.py | 804 +++++++++++++++++++++-------- tests/test_asyncio.py | 190 +++---- 7 files changed, 1083 insertions(+), 479 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index a456a9ebf..237de013f 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -66,7 +66,7 @@ def on_service_state_change( zeroconf = Zeroconf(ip_version=ip_version) - services = ["_http._tcp.local.", "_hap._tcp.local."] + services = ["_http._tcp.local.", "_hap._tcp.local.", "_esphomelib._tcp.local.", "_airplay._tcp.local."] if args.find: services = list(ZeroconfServiceTypes.find(zc=zeroconf)) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index a2d55acf3..88a5321d3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -14,41 +14,86 @@ cdef bint TYPE_CHECKING cdef object cached_possible_types cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT, _MAX_MSG_TYPICAL, _DNS_PACKET_HEADER_LEN cdef cython.uint _TYPE_PTR +cdef object _CLASS_IN cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED cdef cython.set _ADDRESS_RECORD_TYPES +cdef float RESCUE_RECORD_RETRY_TTL_PERCENTAGE + +cdef object _MDNS_PORT, _BROWSER_TIME + +cdef object QU_QUESTION + +cdef object _FLAGS_QR_QUERY + +cdef object heappop, heappush + +cdef class _ScheduledPTRQuery: + + cdef public str alias + cdef public str name + cdef public unsigned int ttl + cdef public bint cancelled + cdef public double expire_time_millis + cdef public double when_millis cdef class _DNSPointerOutgoingBucket: - cdef public object now + cdef public double now_millis cdef public DNSOutgoing out cdef public cython.uint bytes cpdef add(self, cython.uint max_compressed_size, DNSQuestion question, cython.set answers) @cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord, qu_question=bint) -cpdef generate_service_query( +cpdef list generate_service_query( object zc, - double now, - list type_, + double now_millis, + set types_, bint multicast, object question_type ) @cython.locals(answer=DNSPointer, query_buckets=list, question=DNSQuestion, max_compressed_size=cython.uint, max_bucket_size=cython.uint, query_bucket=_DNSPointerOutgoingBucket) -cdef _group_ptr_queries_with_known_answers(object now, object multicast, cython.dict question_with_known_answers) +cdef list _group_ptr_queries_with_known_answers(double now_millis, bint multicast, cython.dict question_with_known_answers) cdef class QueryScheduler: - cdef cython.set _types - cdef cython.dict _next_time - cdef object _first_random_delay_interval - cdef cython.dict _delay + cdef object _zc + cdef set _types + cdef str _addr + cdef int _port + cdef bint _multicast + cdef tuple _first_random_delay_interval + cdef double _min_time_between_queries_millis + cdef object _loop + cdef unsigned int _startup_queries_sent + cdef public dict _next_scheduled_for_alias + cdef public list _query_heap + cdef object _next_run + cdef double _clock_resolution_millis + cdef object _question_type + + cpdef void schedule_ptr_first_refresh(self, DNSPointer pointer) + + cdef void _schedule_ptr_refresh(self, DNSPointer pointer, double expire_time_millis, double refresh_time_millis) + + cdef void _schedule_ptr_query(self, _ScheduledPTRQuery scheduled_query) - cpdef millis_to_wait(self, object now) + @cython.locals(scheduled=_ScheduledPTRQuery) + cpdef void cancel_ptr_refresh(self, DNSPointer pointer) - cpdef reschedule_type(self, object type_, object next_time) + @cython.locals(current=_ScheduledPTRQuery, expire_time=double) + cpdef void reschedule_ptr_first_refresh(self, DNSPointer pointer) - cpdef process_ready_types(self, object now) + @cython.locals(ttl_millis='unsigned int', additional_wait=double, next_query_time=double) + cpdef void schedule_rescue_query(self, _ScheduledPTRQuery query, double now_millis, float additional_percentage) + + cpdef void _process_startup_queries(self) + + @cython.locals(query=_ScheduledPTRQuery, next_scheduled=_ScheduledPTRQuery, next_when=double) + cpdef void _process_ready_types(self) + + cpdef void async_send_ready_queries(self, bint first_request, double now_millis, set ready_types) cdef class _ServiceBrowserBase(RecordUpdateListener): @@ -56,20 +101,12 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cdef public object zc cdef DNSCache _cache cdef object _loop - cdef public object addr - cdef public object port - cdef public object multicast - cdef public object question_type cdef public cython.dict _pending_handlers cdef public object _service_state_changed cdef public QueryScheduler query_scheduler cdef public bint done - cdef public object _first_request - cdef public object _next_send_timer cdef public object _query_sender_task - cpdef _generate_ready_queries(self, object first_request, object now) - cpdef void _enqueue_callback(self, object state_change, object type_, object name) @cython.locals(record_update=RecordUpdate, record=DNSRecord, cache=DNSCache, service=DNSRecord, pointer=DNSPointer) @@ -77,16 +114,6 @@ cdef class _ServiceBrowserBase(RecordUpdateListener): cpdef cython.list _names_matching_types(self, object types) - cpdef reschedule_type(self, object type_, object now, object next_time) - cpdef _fire_service_state_changed_event(self, cython.tuple event) - cpdef _async_send_ready_queries_schedule_next(self) - - cpdef _async_schedule_next(self, object now) - - cpdef _async_send_ready_queries(self, object now) - - cpdef _cancel_send_timer(self) - cpdef void async_update_records_complete(self) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index ca8c9aa55..4d7646a2a 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -21,14 +21,17 @@ """ import asyncio +import heapq import queue import random import threading +import time import warnings from functools import partial from types import TracebackType # noqa # used in type hints from typing import ( TYPE_CHECKING, + Any, Callable, Dict, Iterable, @@ -56,7 +59,6 @@ from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _ADDRESS_RECORD_TYPES, - _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, _CLASS_IN, _DNS_PACKET_HEADER_LEN, @@ -82,6 +84,12 @@ SERVICE_STATE_CHANGE_REMOVED = ServiceStateChange.Removed SERVICE_STATE_CHANGE_UPDATED = ServiceStateChange.Updated +QU_QUESTION = DNSQuestionType.QU + +STARTUP_QUERIES = 4 + +RESCUE_RECORD_RETRY_TTL_PERCENTAGE = 0.1 + if TYPE_CHECKING: from .._core import Zeroconf @@ -92,23 +100,97 @@ _QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] +heappop = heapq.heappop +heappush = heapq.heappush + + +class _ScheduledPTRQuery: + + __slots__ = ('alias', 'name', 'ttl', 'cancelled', 'expire_time_millis', 'when_millis') + + def __init__( + self, alias: str, name: str, ttl: int, expire_time_millis: float, when_millis: float + ) -> None: + """Create a scheduled query.""" + self.alias = alias + self.name = name + self.ttl = ttl + # Since queries are stored in a heap we need to track if they are cancelled + # so we can remove them from the heap when they are cancelled as it would + # be too expensive to search the heap for the record to remove and instead + # we just mark it as cancelled and ignore it when we pop it off the heap + # when the query is due. + self.cancelled = False + # Expire time millis is the actual millisecond time the record will expire + self.expire_time_millis = expire_time_millis + # When millis is the millisecond time the query should be sent + # For the first query this is the refresh time which is 75% of the TTL + # + # For subsequent queries we increase the time by 10% of the TTL + # until we reach the expire time and then we stop because it means + # we failed to rescue the record. + self.when_millis = when_millis + + def __repr__(self) -> str: + """Return a string representation of the scheduled query.""" + return ( + f"<{self.__class__.__name__} " + f"alias={self.alias} " + f"name={self.name} " + f"ttl={self.ttl} " + f"cancelled={self.cancelled} " + f"expire_time_millis={self.expire_time_millis} " + f"when_millis={self.when_millis}" + ">" + ) + + def __lt__(self, other: '_ScheduledPTRQuery') -> bool: + """Compare two scheduled queries.""" + if type(other) is _ScheduledPTRQuery: + return self.when_millis < other.when_millis + return NotImplemented + + def __le__(self, other: '_ScheduledPTRQuery') -> bool: + """Compare two scheduled queries.""" + if type(other) is _ScheduledPTRQuery: + return self.when_millis < other.when_millis or self.__eq__(other) + return NotImplemented + + def __eq__(self, other: Any) -> bool: + """Compare two scheduled queries.""" + if type(other) is _ScheduledPTRQuery: + return self.when_millis == other.when_millis + return NotImplemented + + def __ge__(self, other: '_ScheduledPTRQuery') -> bool: + """Compare two scheduled queries.""" + if type(other) is _ScheduledPTRQuery: + return self.when_millis > other.when_millis or self.__eq__(other) + return NotImplemented + + def __gt__(self, other: '_ScheduledPTRQuery') -> bool: + """Compare two scheduled queries.""" + if type(other) is _ScheduledPTRQuery: + return self.when_millis > other.when_millis + return NotImplemented + class _DNSPointerOutgoingBucket: """A DNSOutgoing bucket.""" - __slots__ = ('now', 'out', 'bytes') + __slots__ = ('now_millis', 'out', 'bytes') - def __init__(self, now: float, multicast: bool) -> None: - """Create a bucke to wrap a DNSOutgoing.""" - self.now = now - self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast=multicast) + def __init__(self, now_millis: float, multicast: bool) -> None: + """Create a bucket to wrap a DNSOutgoing.""" + self.now_millis = now_millis + self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast) self.bytes = 0 def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer]) -> None: """Add a new set of questions and known answers to the outgoing.""" self.out.add_question(question) for answer in answers: - self.out.add_answer_at_time(answer, self.now) + self.out.add_answer_at_time(answer, self.now_millis) self.bytes += max_compressed_size @@ -127,7 +209,7 @@ def group_ptr_queries_with_known_answers( def _group_ptr_queries_with_known_answers( - now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers + now_millis: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers ) -> List[DNSOutgoing]: """Inner wrapper for group_ptr_queries_with_known_answers.""" # This is the maximum size the query + known answers can be with name compression. @@ -156,7 +238,7 @@ def _group_ptr_queries_with_known_answers( # If a single question and known answers won't fit in a packet # we will end up generating multiple packets, but there will never # be multiple questions - query_bucket = _DNSPointerOutgoingBucket(now, multicast) + query_bucket = _DNSPointerOutgoingBucket(now_millis, multicast) query_bucket.add(max_compressed_size, question, answers) query_buckets.append(query_bucket) @@ -164,11 +246,15 @@ def _group_ptr_queries_with_known_answers( def generate_service_query( - zc: 'Zeroconf', now: float_, types_: List[str], multicast: bool, question_type: Optional[DNSQuestionType] + zc: 'Zeroconf', + now_millis: float_, + types_: Set[str], + multicast: bool, + question_type: Optional[DNSQuestionType], ) -> List[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} - qu_question = not multicast if question_type is None else question_type == DNSQuestionType.QU + qu_question = not multicast if question_type is None else question_type is QU_QUESTION question_history = zc.question_history cache = zc.cache for type_ in types_: @@ -177,9 +263,9 @@ def generate_service_query( known_answers = { record for record in cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) - if not record.is_stale(now) + if not record.is_stale(now_millis) } - if not qu_question and question_history.suppresses(question, now, known_answers): + if not qu_question and question_history.suppresses(question, now_millis, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue if TYPE_CHECKING: @@ -188,9 +274,9 @@ def generate_service_query( pointer_known_answers = known_answers questions_with_known_answers[question] = pointer_known_answers if not qu_question: - question_history.add_question_at_time(question, now, known_answers) + question_history.add_question_at_time(question, now_millis, known_answers) - return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) + return _group_ptr_queries_with_known_answers(now_millis, multicast, questions_with_known_answers) def _on_change_dispatcher( @@ -223,25 +309,51 @@ class QueryScheduler: """ - __slots__ = ('_types', '_next_time', '_first_random_delay_interval', '_delay') + __slots__ = ( + '_zc', + '_types', + '_addr', + '_port', + '_multicast', + '_first_random_delay_interval', + '_min_time_between_queries_millis', + '_loop', + '_startup_queries_sent', + '_next_scheduled_for_alias', + '_query_heap', + '_next_run', + '_clock_resolution_millis', + '_question_type', + ) def __init__( self, + zc: "Zeroconf", types: Set[str], + addr: Optional[str], + port: int, + multicast: bool, delay: int, first_random_delay_interval: Tuple[int, int], + question_type: Optional[DNSQuestionType], ) -> None: + self._zc = zc self._types = types - self._next_time: Dict[str, float] = {} + self._addr = addr + self._port = port + self._multicast = multicast self._first_random_delay_interval = first_random_delay_interval - self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self._types} - - def start(self, now: float_) -> None: - """Start the scheduler.""" - self._generate_first_next_time(now) - - def _generate_first_next_time(self, now: float_) -> None: - """Generate the initial next query times. + self._min_time_between_queries_millis = delay + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._startup_queries_sent = 0 + self._next_scheduled_for_alias: Dict[str, _ScheduledPTRQuery] = {} + self._query_heap: list[_ScheduledPTRQuery] = [] + self._next_run: Optional[asyncio.TimerHandle] = None + self._clock_resolution_millis = time.get_clock_info('monotonic').resolution * 1000 + self._question_type = question_type + + def start(self, loop: asyncio.AbstractEventLoop) -> None: + """Start the scheduler. https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 To avoid accidental synchronization when, for some reason, multiple @@ -250,43 +362,173 @@ def _generate_first_next_time(self, now: float_) -> None: also delay the first query of the series by a randomly chosen amount in the range 20-120 ms. """ - delay = millis_to_seconds(random.randint(*self._first_random_delay_interval)) - next_time = now + delay - self._next_time = {check_type_: next_time for check_type_ in self._types} - - def millis_to_wait(self, now: float_) -> float: - """Returns the number of milliseconds to wait for the next event.""" - # Wait for the type has the smallest next time - next_time = min(self._next_time.values()) - return 0 if next_time <= now else next_time - now - - def reschedule_type(self, type_: str_, next_time: float_) -> bool: - """Reschedule the query for a type to happen sooner.""" - if next_time >= self._next_time[type_]: - return False - self._next_time[type_] = next_time - return True - - def _force_reschedule_type(self, type_: str_, next_time: float_) -> None: - """Force a reschedule of a type.""" - self._next_time[type_] = next_time - - def process_ready_types(self, now: float_) -> List[str]: - """Generate a list of ready types that is due and schedule the next time.""" - if self.millis_to_wait(now): - return [] + start_delay = millis_to_seconds(random.randint(*self._first_random_delay_interval)) + self._loop = loop + self._next_run = loop.call_later(start_delay, self._process_startup_queries) + + def stop(self) -> None: + """Stop the scheduler.""" + if self._next_run is not None: + self._next_run.cancel() + self._next_run = None + self._next_scheduled_for_alias.clear() + self._query_heap.clear() + + def schedule_ptr_first_refresh(self, pointer: DNSPointer) -> None: + """Schedule a query for a pointer.""" + expire_time_millis = pointer.get_expiration_time(100) + refresh_time_millis = pointer.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + self._schedule_ptr_refresh(pointer, expire_time_millis, refresh_time_millis) + + def _schedule_ptr_refresh( + self, pointer: DNSPointer, expire_time_millis: float_, refresh_time_millis: float_ + ) -> None: + """Schedule a query for a pointer.""" + ttl = int(pointer.ttl) if isinstance(pointer.ttl, float) else pointer.ttl + scheduled_ptr_query = _ScheduledPTRQuery( + pointer.alias, pointer.name, ttl, expire_time_millis, refresh_time_millis + ) + self._schedule_ptr_query(scheduled_ptr_query) + + def _schedule_ptr_query(self, scheduled_query: _ScheduledPTRQuery) -> None: + """Schedule a query for a pointer.""" + self._next_scheduled_for_alias[scheduled_query.alias] = scheduled_query + heappush(self._query_heap, scheduled_query) + + def cancel_ptr_refresh(self, pointer: DNSPointer) -> None: + """Cancel a query for a pointer.""" + scheduled = self._next_scheduled_for_alias.pop(pointer.alias, None) + if scheduled: + scheduled.cancelled = True + + def reschedule_ptr_first_refresh(self, pointer: DNSPointer) -> None: + """Reschedule a query for a pointer.""" + current = self._next_scheduled_for_alias.get(pointer.alias) + refresh_time_millis = pointer.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) + if current is not None: + # If the expire time is within self._min_time_between_queries_millis + # of the current scheduled time avoid churn by not rescheduling + if ( + -self._min_time_between_queries_millis + <= refresh_time_millis - current.when_millis + <= self._min_time_between_queries_millis + ): + return + current.cancelled = True + expire_time_millis = pointer.get_expiration_time(100) + self._schedule_ptr_refresh(pointer, expire_time_millis, refresh_time_millis) + + def schedule_rescue_query( + self, query: _ScheduledPTRQuery, now_millis: float_, additional_percentage: float_ + ) -> None: + """Reschedule a query for a pointer at an additional percentage of expiration.""" + ttl_millis = query.ttl * 1000 + additional_wait = ttl_millis * additional_percentage + next_query_time = now_millis + additional_wait + if next_query_time >= query.expire_time_millis: + # If we would schedule past the expire time + # there is no point in scheduling as we already + # tried to rescue the record and failed + return + scheduled_ptr_query = _ScheduledPTRQuery( + query.alias, query.name, query.ttl, query.expire_time_millis, next_query_time + ) + self._schedule_ptr_query(scheduled_ptr_query) + + def _process_startup_queries(self) -> None: + if TYPE_CHECKING: + assert self._loop is not None + # This is a safety to ensure we stop sending queries if Zeroconf instance + # is stopped without the browser being cancelled + if self._zc.done: + return + + now_millis = current_time_millis() + + # At first we will send STARTUP_QUERIES queries to get the cache populated + self.async_send_ready_queries(self._startup_queries_sent == 0, now_millis, self._types) + self._startup_queries_sent += 1 + + # Once we finish sending the initial queries we will + # switch to a strategy of sending queries only when we + # need to refresh records that are about to expire + if self._startup_queries_sent >= STARTUP_QUERIES: + self._next_run = self._loop.call_at( + millis_to_seconds(now_millis + self._min_time_between_queries_millis), + self._process_ready_types, + ) + return + + self._next_run = self._loop.call_later(self._startup_queries_sent**2, self._process_startup_queries) - ready_types: List[str] = [] + def _process_ready_types(self) -> None: + """Generate a list of ready types that is due and schedule the next time.""" + if TYPE_CHECKING: + assert self._loop is not None + # This is a safety to ensure we stop sending queries if Zeroconf instance + # is stopped without the browser being cancelled + if self._zc.done: + return - for type_, due in self._next_time.items(): - if due > now: + now_millis = current_time_millis() + # Refresh records that are about to expire (aka + # _EXPIRE_REFRESH_TIME_PERCENT which is currently 75% of the TTL) and + # additional rescue queries if the 75% query failed to refresh the record + # with a minimum time between queries of _min_time_between_queries + # which defaults to 10s + + ready_types: Set[str] = set() + next_scheduled: Optional[_ScheduledPTRQuery] = None + end_time_millis = now_millis + self._clock_resolution_millis + schedule_rescue: List[_ScheduledPTRQuery] = [] + + while self._query_heap: + query = self._query_heap[0] + if query.cancelled: + heappop(self._query_heap) continue + if query.when_millis > end_time_millis: + next_scheduled = query + break + + ready_types.add(query.name) - ready_types.append(type_) - self._next_time[type_] = now + self._delay[type_] - self._delay[type_] = min(_BROWSER_BACKOFF_LIMIT * 1000, self._delay[type_] * 2) + heappop(self._query_heap) + del self._next_scheduled_for_alias[query.alias] + # If there is still more than 10% of the TTL remaining + # schedule a query again to try to rescue the record + # from expiring. If the record is refreshed before + # the query, the query will get cancelled. + schedule_rescue.append(query) - return ready_types + for query in schedule_rescue: + self.schedule_rescue_query(query, now_millis, RESCUE_RECORD_RETRY_TTL_PERCENTAGE) + + if ready_types: + self.async_send_ready_queries(False, now_millis, ready_types) + + next_time_millis = now_millis + self._min_time_between_queries_millis + + if next_scheduled is not None and next_scheduled.when_millis > next_time_millis: + next_when_millis = next_scheduled.when_millis + else: + next_when_millis = next_time_millis + + self._next_run = self._loop.call_at(millis_to_seconds(next_when_millis), self._process_ready_types) + + def async_send_ready_queries( + self, first_request: bool, now_millis: float_, ready_types: Set[str] + ) -> None: + """Send any ready queries.""" + # If they did not specify and this is the first request, ask QU questions + # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are + # just starting up and we know our cache is likely empty. This ensures + # the next outgoing will be sent with the known answers list. + question_type = QU_QUESTION if self._question_type is None and first_request else self._question_type + outs = generate_service_query(self._zc, now_millis, ready_types, self._multicast, question_type) + if outs: + for out in outs: + self._zc.async_send(out, self._addr, self._port) class _ServiceBrowserBase(RecordUpdateListener): @@ -297,16 +539,10 @@ class _ServiceBrowserBase(RecordUpdateListener): 'zc', '_cache', '_loop', - 'addr', - 'port', - 'multicast', - 'question_type', '_pending_handlers', '_service_state_changed', 'query_scheduler', 'done', - '_first_request', - '_next_send_timer', '_query_sender_task', ) @@ -347,16 +583,19 @@ def __init__( self._cache = zc.cache assert zc.loop is not None self._loop = zc.loop - self.addr = addr - self.port = port - self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - self.question_type = question_type self._pending_handlers: Dict[Tuple[str, str], ServiceStateChange] = {} self._service_state_changed = Signal() - self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL) + self.query_scheduler = QueryScheduler( + zc, + self.types, + addr, + port, + addr in (None, _MDNS_ADDR, _MDNS_ADDR6), + delay, + _FIRST_QUERY_DELAY_RANDOM_INTERVAL, + question_type, + ) self.done = False - self._first_request: bool = True - self._next_send_timer: Optional[asyncio.TimerHandle] = None self._query_sender_task: Optional[asyncio.Task] = None if hasattr(handlers, 'add_service'): @@ -377,7 +616,6 @@ def _async_start(self) -> None: Must be called by uses of this base class after they have finished setting their properties. """ - self.query_scheduler.start(current_time_millis()) self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) # Only start queries after the listener is installed self._query_sender_task = asyncio.ensure_future(self._async_start_query_sender()) @@ -432,11 +670,12 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record for type_ in self.types.intersection(cached_possible_types(pointer.name)): if old_record is None: self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias) + self.query_scheduler.schedule_ptr_first_refresh(pointer) elif pointer.is_expired(now): self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias) + self.query_scheduler.cancel_ptr_refresh(pointer) else: - expire_time = pointer.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - self.reschedule_type(type_, now, expire_time) + self.query_scheduler.reschedule_ptr_first_refresh(pointer) continue # If its expired or already exists in the cache it cannot be updated. @@ -487,67 +726,17 @@ def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], Servic def _async_cancel(self) -> None: """Cancel the browser.""" self.done = True - self._cancel_send_timer() + self.query_scheduler.stop() self.zc.async_remove_listener(self) assert self._query_sender_task is not None, "Attempted to cancel a browser that was not started" self._query_sender_task.cancel() - - def _generate_ready_queries(self, first_request: bool_, now: float_) -> List[DNSOutgoing]: - """Generate the service browser query for any type that is due.""" - ready_types = self.query_scheduler.process_ready_types(now) - if not ready_types: - return [] - - # If they did not specify and this is the first request, ask QU questions - # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are - # just starting up and we know our cache is likely empty. This ensures - # the next outgoing will be sent with the known answers list. - question_type = DNSQuestionType.QU if not self.question_type and first_request else self.question_type - return generate_service_query(self.zc, now, ready_types, self.multicast, question_type) + self._query_sender_task = None async def _async_start_query_sender(self) -> None: """Start scheduling queries.""" if not self.zc.started: await self.zc.async_wait_for_start() - self._async_send_ready_queries_schedule_next() - - def _cancel_send_timer(self) -> None: - """Cancel the next send.""" - if self._next_send_timer: - self._next_send_timer.cancel() - self._next_send_timer = None - - def reschedule_type(self, type_: str_, now: float_, next_time: float_) -> None: - """Reschedule a type to be refreshed in the future.""" - if self.query_scheduler.reschedule_type(type_, next_time): - # We need to send the queries before rescheduling the next one - # otherwise we may be scheduling a query to go out in the next - # iteration of the event loop which should be sent now. - if now >= next_time: - self._async_send_ready_queries(now) - self._cancel_send_timer() - self._async_schedule_next(now) - - def _async_send_ready_queries(self, now: float_) -> None: - """Send any ready queries.""" - outs = self._generate_ready_queries(self._first_request, now) - if outs: - self._first_request = False - for out in outs: - self.zc.async_send(out, addr=self.addr, port=self.port) - - def _async_send_ready_queries_schedule_next(self) -> None: - """Send ready queries and schedule next one checking for done first.""" - if self.done or self.zc.done: - return - now = current_time_millis() - self._async_send_ready_queries(now) - self._async_schedule_next(now) - - def _async_schedule_next(self, now: float_) -> None: - """Scheule the next time.""" - delay = millis_to_seconds(self.query_scheduler.millis_to_wait(now)) - self._next_send_timer = self._loop.call_later(delay, self._async_send_ready_queries_schedule_next) + self.query_scheduler.start(self._loop) class ServiceBrowser(_ServiceBrowserBase, threading.Thread): diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index ca199df5b..aa64306e1 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -29,10 +29,9 @@ _CHECK_TIME = 175 # ms _REGISTER_TIME = 225 # ms _LISTENER_TIME = 200 # ms -_BROWSER_TIME = 1000 # ms -_DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms -_DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 -_BROWSER_BACKOFF_LIMIT = 3600 # s +_BROWSER_TIME = 10000 # ms +_DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 # ms +_DUPLICATE_QUESTION_INTERVAL = 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL _CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s _STARTUP_TIMEOUT = 9 # s must be lower than _LOADED_SYSTEM_TIMEOUT diff --git a/tests/__init__.py b/tests/__init__.py index 98cd901c5..cbba60731 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -19,17 +19,20 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ - import asyncio import socket +import time from functools import lru_cache -from typing import List, Set +from typing import List, Optional, Set +from unittest import mock import ifaddr from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf from zeroconf._history import QuestionHistory +_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution + class QuestionHistoryWithoutSuppression(QuestionHistory): def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: @@ -84,3 +87,27 @@ def has_working_ipv6(): def _clear_cache(zc: Zeroconf) -> None: zc.cache.cache.clear() zc.question_history.clear() + + +def time_changed_millis(millis: Optional[float] = None) -> None: + """Call all scheduled events for a time.""" + loop = asyncio.get_running_loop() + loop_time = loop.time() + if millis is not None: + mock_seconds_into_future = millis / 1000 + else: + mock_seconds_into_future = loop_time + + with mock.patch("time.monotonic", return_value=mock_seconds_into_future): + + for task in list(loop._scheduled): # type: ignore[attr-defined] + if not isinstance(task, asyncio.TimerHandle): + continue + if task.cancelled(): + continue + + future_seconds = task.when() - (loop_time + _MONOTONIC_RESOLUTION) + + if mock_seconds_into_future >= future_seconds: + task._run() + task.cancel() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index a658ded98..6a3bd3989 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -10,7 +10,7 @@ import time import unittest from threading import Event -from typing import Iterable, Set, cast +from typing import Iterable, List, Set, cast from unittest.mock import patch import pytest @@ -27,15 +27,16 @@ millis_to_seconds, ) from zeroconf._services import ServiceStateChange -from zeroconf._services.browser import ServiceBrowser +from zeroconf._services.browser import ServiceBrowser, _ScheduledPTRQuery from zeroconf._services.info import ServiceInfo -from zeroconf.asyncio import AsyncZeroconf +from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf from .. import ( QuestionHistoryWithoutSuppression, _inject_response, _wait_for_start, has_working_ipv6, + time_changed_millis, ) log = logging.getLogger('zeroconf') @@ -53,6 +54,13 @@ def teardown_module(): log.setLevel(original_logging_level) +def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) + + def test_service_browser_cancel_multiple_times(): """Test we can cancel a ServiceBrowser multiple times before close.""" @@ -213,7 +221,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de assert service_info.server.lower() == service_server.lower() service_updated_event.set() - def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) assert generated.is_response() is True @@ -291,7 +299,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi wait_time = 3 # service added - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Added)) service_add_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 0 @@ -300,7 +308,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service SRV updated service_updated_event.clear() service_server = 'ash-2.local.' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 1 @@ -309,7 +317,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service TXT updated service_updated_event.clear() service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -318,7 +326,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi # service TXT updated - duplicate update should not trigger another service_updated service_updated_event.clear() service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -329,7 +337,7 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi service_address = '10.0.1.3' # Verify we match on uppercase service_server = service_server.upper() - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 3 @@ -340,14 +348,14 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi service_server = 'ash-3.local.' service_text = b'path=/~matt3/' service_address = '10.0.1.3' - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 assert service_removed_count == 0 # service removed - _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Removed)) service_removed_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 @@ -385,7 +393,7 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de if service_removed_count == 3: service_removed_event.set() - def mock_incoming_msg( + def mock_record_update_incoming_msg( service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int ) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -403,11 +411,15 @@ def mock_incoming_msg( # all three services added _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + mock_record_update_incoming_msg( + r.ServiceStateChange.Added, service_types[0], service_names[0], 120 + ), ) _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[1], service_names[1], 120), + mock_record_update_incoming_msg( + r.ServiceStateChange.Added, service_types[1], service_names[1], 120 + ), ) time.sleep(0.1) @@ -424,14 +436,18 @@ def _mock_get_expiration_time(self, percent): with patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[0], service_names[0], 120), + mock_record_update_incoming_msg( + r.ServiceStateChange.Added, service_types[0], service_names[0], 120 + ), ) # Add the last record after updating the first one # to ensure the service_add_event only gets set # after the update _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Added, service_types[2], service_names[2], 120), + mock_record_update_incoming_msg( + r.ServiceStateChange.Added, service_types[2], service_names[2], 120 + ), ) service_add_event.wait(wait_time) assert called_with_refresh_time_check is True @@ -440,21 +456,29 @@ def _mock_get_expiration_time(self, percent): _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Updated, service_types[0], service_names[0], 0), + mock_record_update_incoming_msg( + r.ServiceStateChange.Updated, service_types[0], service_names[0], 0 + ), ) # all three services removed _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[0], service_names[0], 0), + mock_record_update_incoming_msg( + r.ServiceStateChange.Removed, service_types[0], service_names[0], 0 + ), ) _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[1], service_names[1], 0), + mock_record_update_incoming_msg( + r.ServiceStateChange.Removed, service_types[1], service_names[1], 0 + ), ) _inject_response( zeroconf, - mock_incoming_msg(r.ServiceStateChange.Removed, service_types[2], service_names[2], 0), + mock_record_update_incoming_msg( + r.ServiceStateChange.Removed, service_types[2], service_names[2], 0 + ), ) service_removed_event.wait(wait_time) assert service_added_count == 3 @@ -472,93 +496,6 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() -def test_backoff(): - got_query = Event() - - type_ = "_http._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - _wait_for_start(zeroconf_browser) - zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() - - # we are going to patch the zeroconf send to check query transmission - old_send = zeroconf_browser.async_send - - time_offset = 0.0 - start_time = time.monotonic() * 1000 - initial_query_interval = _services_browser._BROWSER_TIME / 1000 - - def _current_time_millis(): - """Current system time in milliseconds""" - return start_time + time_offset * 1000 - - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): - """Sends an outgoing packet.""" - got_query.set() - old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) - - class ServiceBrowserWithPatchedTime(_services_browser.ServiceBrowser): - def _async_start(self) -> None: - """Generate the next time and setup listeners. - - Must be called by uses of this base class after they - have finished setting their properties. - """ - super()._async_start() - self.query_scheduler.start(_current_time_millis()) - - def _async_send_ready_queries_schedule_next(self): - if self.done or self.zc.done: - return - now = _current_time_millis() - self._async_send_ready_queries(now) - self._async_schedule_next(now) - - # patch the zeroconf send - # patch the zeroconf current_time_millis - # patch the backoff limit to prevent test running forever - with patch.object(zeroconf_browser, "async_send", send), patch.object( - _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 - ), patch.object(_services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0)): - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass - - browser = ServiceBrowserWithPatchedTime(zeroconf_browser, type_, [on_service_state_change]) - - try: - # Test that queries are sent at increasing intervals - sleep_count = 0 - next_query_interval = 0.0 - expected_query_time = 0.0 - while True: - sleep_count += 1 - got_query.wait(0.1) - if time_offset == expected_query_time: - assert got_query.is_set() - got_query.clear() - if next_query_interval == _services_browser._BROWSER_BACKOFF_LIMIT: - # Only need to test up to the point where we've seen a query - # after the backoff limit has been hit - break - elif next_query_interval == 0: - next_query_interval = initial_query_interval - expected_query_time = initial_query_interval - else: - next_query_interval = min( - 2 * next_query_interval, _services_browser._BROWSER_BACKOFF_LIMIT - ) - expected_query_time += next_query_interval - else: - assert not got_query.is_set() - time_offset += initial_query_interval - assert zeroconf_browser.loop is not None - zeroconf_browser.loop.call_soon_threadsafe(browser._async_send_ready_queries_schedule_next) - - finally: - browser.cancel() - zeroconf_browser.close() - - def test_first_query_delay(): """Verify the first query is delayed. @@ -598,48 +535,225 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() -def test_asking_default_is_asking_qm_questions_after_the_first_qu(): - """Verify the service browser's first question is QU and subsequent ones are QM questions.""" - type_ = "_quservice._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) +@pytest.mark.asyncio +async def test_asking_default_is_asking_qm_questions_after_the_first_qu(): + """Verify the service browser's first questions are QU and refresh queries are QM.""" + service_added = asyncio.Event() + service_removed = asyncio.Event() + unexpected_ttl = asyncio.Event() + got_query = asyncio.Event() - # we are going to patch the zeroconf send to check query transmission + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + elif state_change is ServiceStateChange.Removed: + service_removed.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() + await zeroconf_browser.async_wait_for_start() + + # we are going to patch the zeroconf send to check packet sizes old_send = zeroconf_browser.async_send - first_outgoing = None - second_outgoing = None + expected_ttl = const._DNS_OTHER_TTL + questions: List[List[DNSQuestion]] = [] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" - nonlocal first_outgoing - nonlocal second_outgoing - if first_outgoing is not None and second_outgoing is None: # type: ignore[unreachable] - second_outgoing = out # type: ignore[unreachable] - if first_outgoing is None: - first_outgoing = out - old_send(out, addr=addr, port=port) + pout = r.DNSIncoming(out.packets()[0]) + questions.append(pout.questions) + got_query.set() + old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) - # patch the zeroconf send + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + + assert len(zeroconf_registrar.engine.protocols) == 2 + # patch the zeroconf send so we can capture what is being sent with patch.object(zeroconf_browser, "async_send", send): - # dummy service callback - def on_service_state_change(zeroconf, service_type, state_change, name): - pass + service_added = asyncio.Event() + service_removed = asyncio.Event() + + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + loop = asyncio.get_running_loop() + try: + await asyncio.wait_for(service_added.wait(), 1) + assert service_added.is_set() + # Make sure the startup queries are sent + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + got_query.clear() + now_millis = original_now * 1000 + assert not unexpected_ttl.is_set() + # Move time forward past when the TTL is no longer + # fresh (AKA 75% of the TTL) + now_millis += (expected_ttl * 1000) * 0.80 + time_changed_millis(now_millis) + + await asyncio.wait_for(got_query.wait(), 1) + assert not unexpected_ttl.is_set() + + assert len(questions) == _services_browser.STARTUP_QUERIES + 1 + # The first question should be QU to try to + # populate the known answers and limit the impact + # of the QM questions that follow. We still + # have to ask QM questions for the startup queries + # because some devices will not respond to QU + assert questions[0][0].unicast is True + # The remaining questions should be QM questions + for question in questions[1:]: + assert question[0].unicast is False + # Don't remove service, allow close() to cleanup + finally: + await aio_zeroconf_registrar.async_close() + await asyncio.wait_for(service_removed.wait(), 1) + assert service_removed.is_set() + await browser.async_cancel() + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_ttl_refresh_cancelled_rescue_query(): + """Verify seeing a name again cancels the rescue query.""" + service_added = asyncio.Event() + service_removed = asyncio.Event() + unexpected_ttl = asyncio.Event() + got_query = asyncio.Event() - browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change], delay=5) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 120 + 50)) + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + elif state_change is ServiceStateChange.Removed: + service_removed.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() + await zeroconf_browser.async_wait_for_start() + + # we are going to patch the zeroconf send to check packet sizes + old_send = zeroconf_browser.async_send + + expected_ttl = const._DNS_OTHER_TTL + packets = [] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + packets.append(pout) + got_query.set() + old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope) + + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + + assert len(zeroconf_registrar.engine.protocols) == 2 + # patch the zeroconf send so we can capture what is being sent + with patch.object(zeroconf_browser, "async_send", send): + service_added = asyncio.Event() + service_removed = asyncio.Event() + + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + loop = asyncio.get_running_loop() try: - assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] - assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] + await asyncio.wait_for(service_added.wait(), 1) + assert service_added.is_set() + # Make sure the startup queries are sent + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + now_millis = original_now * 1000 + assert not unexpected_ttl.is_set() + await asyncio.wait_for(got_query.wait(), 1) + got_query.clear() + assert len(packets) == _services_browser.STARTUP_QUERIES + packets.clear() + + # Move time forward past when the TTL is no longer + # fresh (AKA 75% of the TTL) + now_millis += (expected_ttl * 1000) * 0.80 + # Inject a response that will reschedule + # the rescue query so it does not happen + with patch("time.monotonic", return_value=now_millis / 1000): + zeroconf_browser.record_manager.async_updates_from_response( + mock_incoming_msg([info.dns_pointer()]), + ) + + time_changed_millis(now_millis) + await asyncio.sleep(0) + + # Verify we did not send a rescue query + assert not packets + + # We should still get a rescue query once the rescheduled + # query time is reached + now_millis += (expected_ttl * 1000) * 0.76 + time_changed_millis(now_millis) + await asyncio.wait_for(got_query.wait(), 1) + assert len(packets) == 1 + # Don't remove service, allow close() to cleanup finally: - browser.cancel() - zeroconf_browser.close() + await aio_zeroconf_registrar.async_close() + await asyncio.wait_for(service_removed.wait(), 1) + assert service_removed.is_set() + await browser.async_cancel() + await aiozc.async_close() -def test_asking_qm_questions(): +@pytest.mark.asyncio +async def test_asking_qm_questions(): """Verify explictly asking QM questions.""" type_ = "_quservice._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) - + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + await zeroconf_browser.async_wait_for_start() # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.async_send @@ -658,21 +772,24 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): def on_service_state_change(zeroconf, service_type, state_change, name): pass - browser = ServiceBrowser( + browser = AsyncServiceBrowser( zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QM ) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] finally: - browser.cancel() - zeroconf_browser.close() + await browser.async_cancel() + await aiozc.async_close() -def test_asking_qu_questions(): +@pytest.mark.asyncio +async def test_asking_qu_questions(): """Verify the service browser can ask QU questions.""" type_ = "_quservice._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + await zeroconf_browser.async_wait_for_start() # we are going to patch the zeroconf send to check query transmission old_send = zeroconf_browser.async_send @@ -692,15 +809,15 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): def on_service_state_change(zeroconf, service_type, state_change, name): pass - browser = ServiceBrowser( + browser = AsyncServiceBrowser( zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QU ) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] finally: - browser.cancel() - zeroconf_browser.close() + await browser.async_cancel() + await aiozc.async_close() def test_legacy_record_update_listener(): @@ -788,12 +905,6 @@ def on_service_state_change(zeroconf, service_type, state_change, name): address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - _inject_response( zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), @@ -861,12 +972,6 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - _inject_response( zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), @@ -920,12 +1025,6 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de address = socket.inet_aton(address_parsed) info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - _inject_response( zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), @@ -948,7 +1047,7 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: zc.close() -def test_servicebrowser_uses_non_strict_names(): +def test_service_browser_uses_non_strict_names(): """Verify we can look for technically invalid names as we cannot change what others do.""" # dummy service callback @@ -1010,34 +1109,34 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert zc.question_history.suppresses(question, now, other_known_answers) # The known answer list is different, do not suppress - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert outs zc.cache.async_add_records([answer]) # The known answer list contains all the asked questions in the history # we should suppress - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert not outs # We do not suppress once the question history expires outs = _services_browser.generate_service_query( - zc, now + 1000, [name], multicast=True, question_type=None + zc, now + 1000, {name}, multicast=True, question_type=None ) assert outs # We do not suppress QU queries ever - outs = _services_browser.generate_service_query(zc, now, [name], multicast=False, question_type=None) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=False, question_type=None) assert outs zc.question_history.async_expire(now + 2000) # No suppression after clearing the history - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert outs # The previous query we just sent is still remembered and # the next one is suppressed - outs = _services_browser.generate_service_query(zc, now, [name], multicast=True, question_type=None) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert not outs await aiozc.async_close() @@ -1047,47 +1146,162 @@ async def test_generate_service_query_suppress_duplicate_questions(): async def test_query_scheduler(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} - query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0)) + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + zc = aiozc.zeroconf + sends: List[r.DNSIncoming] = [] - now = current_time_millis() - query_scheduler.start(now) + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + sends.append(pout) - # Test query interval is increasing - assert query_scheduler.millis_to_wait(now - 1) == 1 - assert query_scheduler.millis_to_wait(now) == 0 - assert query_scheduler.millis_to_wait(now + 1) == 0 + query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) + loop = asyncio.get_running_loop() - assert set(query_scheduler.process_ready_types(now)) == types_ - assert set(query_scheduler.process_ready_types(now)) == set() - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay, 0.00001) + # patch the zeroconf send so we can capture what is being sent + with patch.object(zc, "async_send", send): - assert set(query_scheduler.process_ready_types(now + delay)) == types_ - assert set(query_scheduler.process_ready_types(now + delay)) == set() - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 3, 0.00001) + query_scheduler.start(loop) - assert set(query_scheduler.process_ready_types(now + delay * 3)) == types_ - assert set(query_scheduler.process_ready_types(now + delay * 3)) == set() - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 7, 0.00001) + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) - assert set(query_scheduler.process_ready_types(now + delay * 7)) == types_ - assert set(query_scheduler.process_ready_types(now + delay * 7)) == set() - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 15, 0.00001) + ptr_record = r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "zoomer._hap._tcp.local.", + ) + ptr2_record = r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "disappear._hap._tcp.local.", + ) - assert set(query_scheduler.process_ready_types(now + delay * 15)) == types_ - assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() + query_scheduler.schedule_ptr_first_refresh(ptr_record) + expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_expire_time = ptr_record.get_expiration_time(100) + ptr_query = _ScheduledPTRQuery( + ptr_record.alias, ptr_record.name, int(ptr_record.ttl), expected_expire_time, expected_when_time + ) + assert query_scheduler._query_heap == [ptr_query] + + query_scheduler.schedule_ptr_first_refresh(ptr2_record) + expected_when_time = ptr2_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_expire_time = ptr2_record.get_expiration_time(100) + ptr2_query = _ScheduledPTRQuery( + ptr2_record.alias, + ptr2_record.name, + int(ptr2_record.ttl), + expected_expire_time, + expected_when_time, + ) + + assert query_scheduler._query_heap == [ptr_query, ptr2_query] + + # Simulate PTR one goodbye - # Test if we reschedule 1 second later, the millis_to_wait goes up by 1 - query_scheduler.reschedule_type("_hap._tcp.local.", now + delay * 16) - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 16, 0.00001) + query_scheduler.cancel_ptr_refresh(ptr_record) + ptr_query.cancelled = True - assert set(query_scheduler.process_ready_types(now + delay * 15)) == set() + assert query_scheduler._query_heap == [ptr_query, ptr2_query] + assert query_scheduler._query_heap[0].cancelled is True + assert query_scheduler._query_heap[1].cancelled is False - # Test if we reschedule 1 second later... and its ready for processing - assert set(query_scheduler.process_ready_types(now + delay * 16)) == {"_hap._tcp.local."} - assert query_scheduler.millis_to_wait(now) == pytest.approx(delay * 31, 0.00001) - assert set(query_scheduler.process_ready_types(now + delay * 20)) == set() + # Move time forward past when the TTL is no longer + # fresh (AKA 75% of the TTL) + now_millis += (ptr2_record.ttl * 1000) * 0.80 + time_changed_millis(now_millis) + assert len(query_scheduler._query_heap) == 1 + first_heap = query_scheduler._query_heap[0] + assert first_heap.cancelled is False + assert first_heap.alias == ptr2_record.alias + + # Move time forward past when the record expires + now_millis += (ptr2_record.ttl * 1000) * 0.20 + time_changed_millis(now_millis) + assert len(query_scheduler._query_heap) == 0 + + await aiozc.async_close() - assert set(query_scheduler.process_ready_types(now + delay * 31)) == {"_http._tcp.local."} + +@pytest.mark.asyncio +async def test_query_scheduler_rescue_records(): + delay = const._BROWSER_TIME + types_ = {"_hap._tcp.local.", "_http._tcp.local."} + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + await aiozc.zeroconf.async_wait_for_start() + zc = aiozc.zeroconf + sends: List[r.DNSIncoming] = [] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + sends.append(pout) + + query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) + loop = asyncio.get_running_loop() + + # patch the zeroconf send so we can capture what is being sent + with patch.object(zc, "async_send", send): + + query_scheduler.start(loop) + + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + ptr_record = r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "zoomer._hap._tcp.local.", + ) + + query_scheduler.schedule_ptr_first_refresh(ptr_record) + expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_expire_time = ptr_record.get_expiration_time(100) + ptr_query = _ScheduledPTRQuery( + ptr_record.alias, ptr_record.name, int(ptr_record.ttl), expected_expire_time, expected_when_time + ) + assert query_scheduler._query_heap == [ptr_query] + assert query_scheduler._query_heap[0].cancelled is False + + # Move time forward past when the TTL is no longer + # fresh (AKA 75% of the TTL) + now_millis += (ptr_record.ttl * 1000) * 0.76 + time_changed_millis(now_millis) + assert len(query_scheduler._query_heap) == 1 + new_when = query_scheduler._query_heap[0].when_millis + assert query_scheduler._query_heap[0].cancelled is False + assert new_when >= expected_when_time + + # Move time forward again, but not enough to expire the + # record to make sure we try to rescue it + now_millis += (ptr_record.ttl * 1000) * 0.11 + time_changed_millis(now_millis) + assert len(query_scheduler._query_heap) == 1 + second_new_when = query_scheduler._query_heap[0].when_millis + assert query_scheduler._query_heap[0].cancelled is False + assert second_new_when >= new_when + + # Move time forward again, enough that we will no longer + # try to rescue the record + now_millis += (ptr_record.ttl * 1000) * 0.11 + time_changed_millis(now_millis) + assert len(query_scheduler._query_heap) == 0 + + await aiozc.async_close() def test_service_browser_matching(): @@ -1130,12 +1344,6 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de not_match_type_, not_match_registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] ) - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - _inject_response( zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), @@ -1221,12 +1429,6 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de addresses=[address], ) - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - _inject_response( zc, mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), @@ -1269,3 +1471,181 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: browser.cancel() zc.close() + + +def test_scheduled_ptr_query_dunder_methods(): + query75 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 75) + query80 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 80) + query75_2 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 140, 75) + other = object() + stringified = str(query75) + assert "zoomy._hap._tcp.local." in stringified + assert "120" in stringified + assert "75" in stringified + assert "ScheduledPTRQuery" in stringified + + assert query75 == query75 + assert query75 != query80 + assert query75 == query75_2 + assert query75 < query80 + assert query75 <= query80 + assert query80 > query75 + assert query80 >= query75 + + assert query75 != other + with pytest.raises(TypeError): + query75 < other # type: ignore[operator] + with pytest.raises(TypeError): + query75 <= other # type: ignore[operator] + with pytest.raises(TypeError): + query75 > other # type: ignore[operator] + with pytest.raises(TypeError): + query75 >= other # type: ignore[operator] + + +@pytest.mark.asyncio +async def test_close_zeroconf_without_browser_before_start_up_queries(): + """Test that we stop sending startup queries if zeroconf is closed out from under the browser.""" + service_added = asyncio.Event() + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() + await zeroconf_browser.async_wait_for_start() + + sends: list[r.DNSIncoming] = [] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + sends.append(pout) + + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + + assert len(zeroconf_registrar.engine.protocols) == 2 + # patch the zeroconf send so we can capture what is being sent + with patch.object(zeroconf_browser, "async_send", send): + service_added = asyncio.Event() + + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + loop = asyncio.get_running_loop() + try: + await asyncio.wait_for(service_added.wait(), 1) + assert service_added.is_set() + await aiozc.async_close() + sends.clear() + # Make sure the startup queries are sent + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + # We should not send any queries after close + assert not sends + finally: + await aio_zeroconf_registrar.async_close() + await browser.async_cancel() + + +@pytest.mark.asyncio +async def test_close_zeroconf_without_browser_after_start_up_queries(): + """Test that we stop sending rescue queries if zeroconf is closed out from under the browser.""" + service_added = asyncio.Event() + + type_ = "_http._tcp.local." + registration_name = "xxxyyy.%s" % type_ + + def on_service_state_change(zeroconf, service_type, state_change, name): + if name == registration_name: + if state_change is ServiceStateChange.Added: + service_added.set() + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = aiozc.zeroconf + zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() + await zeroconf_browser.async_wait_for_start() + + sends: list[r.DNSIncoming] = [] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + pout = r.DNSIncoming(out.packets()[0]) + sends.append(pout) + + assert len(zeroconf_browser.engine.protocols) == 2 + + aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + zeroconf_registrar = aio_zeroconf_registrar.zeroconf + await aio_zeroconf_registrar.zeroconf.async_wait_for_start() + + assert len(zeroconf_registrar.engine.protocols) == 2 + # patch the zeroconf send so we can capture what is being sent + with patch.object(zeroconf_browser, "async_send", send): + service_added = asyncio.Event() + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + expected_ttl = const._DNS_OTHER_TTL + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + task = await aio_zeroconf_registrar.async_register_service(info) + await task + loop = asyncio.get_running_loop() + try: + await asyncio.wait_for(service_added.wait(), 1) + assert service_added.is_set() + sends.clear() + # Make sure the startup queries are sent + original_now = loop.time() + now_millis = original_now * 1000 + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + # We should not send any queries after close + assert sends + + await aiozc.async_close() + sends.clear() + + now_millis = original_now * 1000 + # Move time forward past when the TTL is no longer + # fresh (AKA 75% of the TTL) + now_millis += (expected_ttl * 1000) * 0.80 + time_changed_millis(now_millis) + + # We should not send the query after close + assert not sends + finally: + await aio_zeroconf_registrar.async_close() + await browser.async_cancel() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 53bce4b4c..d4594788b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -8,7 +8,6 @@ import os import socket import threading -import time from typing import cast from unittest.mock import ANY, call, patch @@ -44,7 +43,12 @@ ) from zeroconf.const import _LISTENER_TIME -from . import QuestionHistoryWithoutSuppression, _clear_cache, has_working_ipv6 +from . import ( + QuestionHistoryWithoutSuppression, + _clear_cache, + has_working_ipv6, + time_changed_millis, +) log = logging.getLogger('zeroconf') original_logging_level = logging.NOTSET @@ -991,20 +995,20 @@ def on_service_state_change(zeroconf, service_type, state_change, name): # we are going to patch the zeroconf send to check packet sizes old_send = zeroconf_browser.async_send - time_offset = 0.0 - - def _new_current_time_millis(): - """Current system time in milliseconds""" - return (time.monotonic() * 1000) + (time_offset * 1000) - - expected_ttl = const._DNS_HOST_TTL + expected_ttl = const._DNS_OTHER_TTL nbr_answers = 0 + answers = [] + packets = [] def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" pout = DNSIncoming(out.packets()[0]) + packets.append(pout) + last_answers = pout.answers() + answers.append(last_answers) + nonlocal nbr_answers - for answer in pout.answers(): + for answer in last_answers: nbr_answers += 1 if not answer.ttl > expected_ttl / 2: unexpected_ttl.set() @@ -1020,49 +1024,91 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aio_zeroconf_registrar.zeroconf.async_wait_for_start() assert len(zeroconf_registrar.engine.protocols) == 2 - # patch the zeroconf send - # patch the zeroconf current_time_millis - # patch the backoff limit to ensure we always get one query every 1/4 of the DNS TTL - # Disable duplicate question suppression and duplicate packet suppression for this test as it works - # by asking the same question over and over - with patch.object(zeroconf_browser, "async_send", send), patch( - "zeroconf._services.browser.current_time_millis", _new_current_time_millis - ), patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)): + # patch the zeroconf send so we can capture what is being sent + with patch.object(zeroconf_browser, "async_send", send): service_added = asyncio.Event() service_removed = asyncio.Event() browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - - desc = {'path': '/~paulsm/'} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + {'path': '/~paulsm/'}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) task = await aio_zeroconf_registrar.async_register_service(info) await task - + loop = asyncio.get_running_loop() try: await asyncio.wait_for(service_added.wait(), 1) assert service_added.is_set() + # Make sure the startup queries are sent + original_now = loop.time() + start_millis = original_now * 1000 - # Test that we receive queries containing answers only if the remaining TTL - # is greater than half the original TTL - sleep_count = 0 - test_iterations = 50 - - while nbr_answers < test_iterations: - # Increase simulated time shift by 1/4 of the TTL in seconds - time_offset += expected_ttl / 4 - now = _new_current_time_millis() - # Force the next query to be sent since we are testing - # to see if the query contains answers and not the scheduler - browser.query_scheduler._force_reschedule_type(type_, now + (1000 * expected_ttl)) - browser.reschedule_type(type_, now, now) - sleep_count += 1 - await asyncio.wait_for(got_query.wait(), 1) - got_query.clear() - # Prevent the test running indefinitely in an error condition - assert sleep_count < test_iterations * 4 + now_millis = start_millis + for query_count in range(_services_browser.STARTUP_QUERIES): + now_millis += (2**query_count) * 1000 + time_changed_millis(now_millis) + + got_query.clear() + assert not unexpected_ttl.is_set() + + assert len(packets) == _services_browser.STARTUP_QUERIES + packets.clear() + + # Wait for the first refresh query + # Move time forward past when the TTL is no longer + # fresh (AKA ~75% of the TTL) + now_millis = start_millis + ((expected_ttl * 1000) * 0.76) + time_changed_millis(now_millis) + + await asyncio.wait_for(got_query.wait(), 1) + assert not unexpected_ttl.is_set() + assert len(packets) == 1 + packets.clear() + + assert len(answers) == _services_browser.STARTUP_QUERIES + 1 + # The first question should have no known answers + assert len(answers[0]) == 0 + # The rest of the startup questions should have + # known answers + for answer_list in answers[1:-2]: + assert len(answer_list) == 1 + # Once the TTL is reached, the last question should have no known answers + assert len(answers[-1]) == 0 + + got_query.clear() + packets.clear() + # Move time forward past when the TTL is no longer + # fresh (AKA 85% of the TTL) to ensure we try + # to rescue the record + now_millis = start_millis + ((expected_ttl * 1000) * 0.87) + time_changed_millis(now_millis) + + await asyncio.wait_for(got_query.wait(), 1) + assert len(packets) == 1 assert not unexpected_ttl.is_set() + + packets.clear() + got_query.clear() + # Move time forward past when the TTL is no longer + # fresh (AKA 95% of the TTL). At this point + # nothing should get scheduled rescued because the rescue + # would exceed the TTL + now_millis = start_millis + ((expected_ttl * 1000) * 0.98) + + # Verify we don't send a query for a record that is + # past the TTL as we should not try to rescue it + # once its past the TTL + time_changed_millis(now_millis) + await asyncio.wait_for(got_query.wait(), 1) + assert len(packets) == 1 + # Don't remove service, allow close() to cleanup finally: await aio_zeroconf_registrar.async_close() @@ -1305,67 +1351,3 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de ('add', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), ('update', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), ] - - -@pytest.mark.asyncio -async def test_service_browser_does_not_try_to_send_if_not_ready(): - """Test that the service browser does not try to send if not ready when rescheduling a type.""" - service_added = asyncio.Event() - type_ = "_http._tcp.local." - registration_name = "nosend.%s" % type_ - - def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() - - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) - zeroconf_browser = aiozc.zeroconf - await zeroconf_browser.async_wait_for_start() - - expected_ttl = const._DNS_HOST_TTL - time_offset = 0.0 - - def _new_current_time_millis(): - """Current system time in milliseconds""" - return (time.monotonic() * 1000) + (time_offset * 1000) - - assert len(zeroconf_browser.engine.protocols) == 2 - - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) - zeroconf_registrar = aio_zeroconf_registrar.zeroconf - await aio_zeroconf_registrar.zeroconf.async_wait_for_start() - assert len(zeroconf_registrar.engine.protocols) == 2 - with patch("zeroconf._services.browser.current_time_millis", _new_current_time_millis): - service_added = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - desc = {'path': '/~paulsm/'} - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] - ) - task = await aio_zeroconf_registrar.async_register_service(info) - await task - - try: - await asyncio.wait_for(service_added.wait(), 1) - time_offset = 1000 * expected_ttl # set the time to the end of the ttl - now = _new_current_time_millis() - browser.query_scheduler._force_reschedule_type(type_, now + (1000 * expected_ttl)) - # Make sure the query schedule is to a time in the future - # so we will reschedule - with patch.object( - browser, "_async_send_ready_queries" - ) as _async_send_ready_queries, patch.object( - browser, "_async_send_ready_queries_schedule_next" - ) as _async_send_ready_queries_schedule_next: - # Reschedule the type to be sent in 1ms in the future - # to make sure the query is not sent - browser.reschedule_type(type_, now, now + 1) - assert not _async_send_ready_queries.called - await asyncio.sleep(0.01) - # Make sure it does happen after the sleep - assert _async_send_ready_queries_schedule_next.called - finally: - await aio_zeroconf_registrar.async_close() - await browser.async_cancel() - await aiozc.async_close() From b329d99917bb731b4c70bf20c7c010eeb85ad9fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 17:04:26 -1000 Subject: [PATCH 1065/1433] feat: small speed up to ServiceInfo construction (#1346) --- src/zeroconf/_services/info.pxd | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index c17723eb7..0178a1112 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -77,9 +77,9 @@ cdef class ServiceInfo(RecordUpdateListener): cdef void _generate_decoded_properties(self) @cython.locals(properties_contain_str=bint) - cpdef _set_properties(self, cython.dict properties) + cpdef void _set_properties(self, cython.dict properties) - cdef _set_text(self, cython.bytes text) + cdef void _set_text(self, cython.bytes text) @cython.locals(record=DNSAddress) cdef _get_ip_addresses_from_cache_lifo(self, object zc, double now, object type) @@ -94,9 +94,9 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(cache=DNSCache) cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) - cdef _set_ipv4_addresses_from_cache(self, object zc, double now) + cdef void _set_ipv4_addresses_from_cache(self, object zc, double now) - cdef _set_ipv6_addresses_from_cache(self, object zc, double now) + cdef void _set_ipv6_addresses_from_cache(self, object zc, double now) cdef cython.list _ip_addresses_by_version_value(self, object version_value) @@ -121,7 +121,7 @@ cdef class ServiceInfo(RecordUpdateListener): @cython.locals(cacheable=cython.bint) cdef cython.set _get_address_and_nsec_records(self, object override_ttl) - cpdef async_clear_cache(self) + cpdef void async_clear_cache(self) @cython.locals(cache=DNSCache) - cdef _generate_request_query(self, object zc, double now, object question_type) + cdef DNSOutgoing _generate_request_query(self, object zc, double now, object question_type) From cf40470b89f918d3c24d7889d3536f3ffa44846c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Dec 2023 17:39:51 -1000 Subject: [PATCH 1066/1433] fix: scheduling race with the QueryScheduler (#1347) --- src/zeroconf/_services/browser.pxd | 2 -- src/zeroconf/_services/browser.py | 13 +++---------- tests/services/test_browser.py | 6 +++--- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 88a5321d3..4649291c7 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -73,8 +73,6 @@ cdef class QueryScheduler: cdef double _clock_resolution_millis cdef object _question_type - cpdef void schedule_ptr_first_refresh(self, DNSPointer pointer) - cdef void _schedule_ptr_refresh(self, DNSPointer pointer, double expire_time_millis, double refresh_time_millis) cdef void _schedule_ptr_query(self, _ScheduledPTRQuery scheduled_query) diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 4d7646a2a..2ff660744 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -374,12 +374,6 @@ def stop(self) -> None: self._next_scheduled_for_alias.clear() self._query_heap.clear() - def schedule_ptr_first_refresh(self, pointer: DNSPointer) -> None: - """Schedule a query for a pointer.""" - expire_time_millis = pointer.get_expiration_time(100) - refresh_time_millis = pointer.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT) - self._schedule_ptr_refresh(pointer, expire_time_millis, refresh_time_millis) - def _schedule_ptr_refresh( self, pointer: DNSPointer, expire_time_millis: float_, refresh_time_millis: float_ ) -> None: @@ -415,6 +409,7 @@ def reschedule_ptr_first_refresh(self, pointer: DNSPointer) -> None: ): return current.cancelled = True + del self._next_scheduled_for_alias[pointer.alias] expire_time_millis = pointer.get_expiration_time(100) self._schedule_ptr_refresh(pointer, expire_time_millis, refresh_time_millis) @@ -490,10 +485,8 @@ def _process_ready_types(self) -> None: if query.when_millis > end_time_millis: next_scheduled = query break - + query = heappop(self._query_heap) ready_types.add(query.name) - - heappop(self._query_heap) del self._next_scheduled_for_alias[query.alias] # If there is still more than 10% of the TTL remaining # schedule a query again to try to rescue the record @@ -670,7 +663,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record for type_ in self.types.intersection(cached_possible_types(pointer.name)): if old_record is None: self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias) - self.query_scheduler.schedule_ptr_first_refresh(pointer) + self.query_scheduler.reschedule_ptr_first_refresh(pointer) elif pointer.is_expired(now): self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias) self.query_scheduler.cancel_ptr_refresh(pointer) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 6a3bd3989..37896ba1d 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1185,7 +1185,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): "disappear._hap._tcp.local.", ) - query_scheduler.schedule_ptr_first_refresh(ptr_record) + query_scheduler.reschedule_ptr_first_refresh(ptr_record) expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( @@ -1193,7 +1193,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ) assert query_scheduler._query_heap == [ptr_query] - query_scheduler.schedule_ptr_first_refresh(ptr2_record) + query_scheduler.reschedule_ptr_first_refresh(ptr2_record) expected_when_time = ptr2_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr2_record.get_expiration_time(100) ptr2_query = _ScheduledPTRQuery( @@ -1268,7 +1268,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): "zoomer._hap._tcp.local.", ) - query_scheduler.schedule_ptr_first_refresh(ptr_record) + query_scheduler.reschedule_ptr_first_refresh(ptr_record) expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( From b9aae1de07bf1491e873bc314f8a1d7996127ad3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Dec 2023 06:23:29 -1000 Subject: [PATCH 1067/1433] feat: make ServiceInfo aware of question history (#1348) --- src/zeroconf/_history.pxd | 4 +- src/zeroconf/_protocol/outgoing.pxd | 15 ++-- src/zeroconf/_protocol/outgoing.py | 24 ------ src/zeroconf/_services/info.pxd | 39 ++++++++- src/zeroconf/_services/info.py | 97 +++++++++++++++++----- tests/services/test_info.py | 121 ++++++++++++++++++++++++++-- 6 files changed, 234 insertions(+), 66 deletions(-) diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index 02a0fc9ec..d1bb7baf0 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -9,10 +9,10 @@ cdef class QuestionHistory: cdef cython.dict _history - cpdef add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers) + cpdef void add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers) @cython.locals(than=double, previous_question=cython.tuple, previous_known_answers=cython.set) cpdef bint suppresses(self, DNSQuestion question, double now, cython.set known_answers) @cython.locals(than=double, now_known_answers=cython.tuple) - cpdef async_expire(self, double now) + cpdef void async_expire(self, double now) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 4353757a0..2496a9886 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -1,7 +1,6 @@ import cython -from .._cache cimport DNSCache from .._dns cimport DNSEntry, DNSPointer, DNSQuestion, DNSRecord from .incoming cimport DNSIncoming @@ -127,20 +126,16 @@ cdef class DNSOutgoing: ) cpdef packets(self) - cpdef add_question_or_all_cache(self, DNSCache cache, double now, str name, object type_, object class_) + cpdef void add_question(self, DNSQuestion question) - cpdef add_question_or_one_cache(self, DNSCache cache, double now, str name, object type_, object class_) - - cpdef add_question(self, DNSQuestion question) - - cpdef add_answer(self, DNSIncoming inp, DNSRecord record) + cpdef void add_answer(self, DNSIncoming inp, DNSRecord record) @cython.locals(now_double=double) - cpdef add_answer_at_time(self, DNSRecord record, double now) + cpdef void add_answer_at_time(self, DNSRecord record, double now) - cpdef add_authorative_answer(self, DNSPointer record) + cpdef void add_authorative_answer(self, DNSPointer record) - cpdef add_additional_answer(self, DNSRecord record) + cpdef void add_additional_answer(self, DNSRecord record) cpdef bint is_query(self) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 57f981690..f45c39351 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -25,7 +25,6 @@ from struct import Struct from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union -from .._cache import DNSCache from .._dns import DNSPointer, DNSQuestion, DNSRecord from .._exceptions import NamePartTooLongException from .._logger import log @@ -198,29 +197,6 @@ def add_additional_answer(self, record: DNSRecord) -> None: """ self.additionals.append(record) - def add_question_or_one_cache( - self, cache: DNSCache, now: float_, name: str_, type_: int_, class_: int_ - ) -> None: - """Add a question if it is not already cached.""" - cached_entry = cache.get_by_details(name, type_, class_) - if not cached_entry: - self.add_question(DNSQuestion(name, type_, class_)) - else: - self.add_answer_at_time(cached_entry, now) - - def add_question_or_all_cache( - self, cache: DNSCache, now: float_, name: str_, type_: int_, class_: int_ - ) -> None: - """Add a question if it is not already cached. - This is currently only used for IPv6 addresses. - """ - cached_entries = cache.get_all_by_details(name, type_, class_) - if not cached_entries: - self.add_question(DNSQuestion(name, type_, class_)) - return - for cached_entry in cached_entries: - self.add_answer_at_time(cached_entry, now) - def _write_byte(self, value: int_) -> None: """Writes a single byte to the packet""" self.data.append(BYTE_TABLE[value]) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 0178a1112..6f1bef712 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -2,7 +2,16 @@ import cython from .._cache cimport DNSCache -from .._dns cimport DNSAddress, DNSNsec, DNSPointer, DNSRecord, DNSService, DNSText +from .._dns cimport ( + DNSAddress, + DNSNsec, + DNSPointer, + DNSQuestion, + DNSRecord, + DNSService, + DNSText, +) +from .._history cimport QuestionHistory from .._protocol.outgoing cimport DNSOutgoing from .._record_update cimport RecordUpdate from .._updates cimport RecordUpdateListener @@ -27,18 +36,22 @@ cdef object _FLAGS_QR_QUERY cdef object service_type_name -cdef object DNS_QUESTION_TYPE_QU -cdef object DNS_QUESTION_TYPE_QM +cdef object QU_QUESTION +cdef object QM_QUESTION cdef object _IPVersion_All_value cdef object _IPVersion_V4Only_value cdef cython.set _ADDRESS_RECORD_TYPES +cdef unsigned int _DUPLICATE_QUESTION_INTERVAL + cdef bint TYPE_CHECKING cdef bint IPADDRESS_SUPPORTS_SCOPE_ID cdef object cached_ip_addresses +cdef object randint + cdef class ServiceInfo(RecordUpdateListener): cdef public cython.bytes text @@ -123,5 +136,23 @@ cdef class ServiceInfo(RecordUpdateListener): cpdef void async_clear_cache(self) - @cython.locals(cache=DNSCache) + @cython.locals(cache=DNSCache, history=QuestionHistory, out=DNSOutgoing, qu_question=bint) cdef DNSOutgoing _generate_request_query(self, object zc, double now, object question_type) + + @cython.locals(question=DNSQuestion, answer=DNSRecord) + cdef void _add_question_with_known_answers( + self, + DNSOutgoing out, + bint qu_question, + QuestionHistory question_history, + DNSCache cache, + double now, + str name, + object type_, + object class_, + bint skip_if_known_answers + ) + + cdef double _get_initial_delay(self) + + cdef double _get_random_delay(self) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 3a27e10a0..48ad11405 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -26,16 +26,19 @@ from ipaddress import IPv4Address, IPv6Address, _BaseAddress from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast +from .._cache import DNSCache from .._dns import ( DNSAddress, DNSNsec, DNSPointer, + DNSQuestion, DNSQuestionType, DNSRecord, DNSService, DNSText, ) from .._exceptions import BadTypeInNameException +from .._history import QuestionHistory from .._logger import log from .._protocol.outgoing import DNSOutgoing from .._record_update import RecordUpdate @@ -61,6 +64,7 @@ _CLASS_IN_UNIQUE, _DNS_HOST_TTL, _DNS_OTHER_TTL, + _DUPLICATE_QUESTION_INTERVAL, _FLAGS_QR_QUERY, _LISTENER_TIME, _MDNS_PORT, @@ -89,10 +93,12 @@ bytes_ = bytes float_ = float int_ = int +str_ = str -DNS_QUESTION_TYPE_QU = DNSQuestionType.QU -DNS_QUESTION_TYPE_QM = DNSQuestionType.QM +QU_QUESTION = DNSQuestionType.QU +QM_QUESTION = DNSQuestionType.QM +randint = random.randint if TYPE_CHECKING: from .._core import Zeroconf @@ -774,6 +780,12 @@ def request( ) ) + def _get_initial_delay(self) -> float_: + return _LISTENER_TIME + + def _get_random_delay(self) -> int_: + return randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) + async def async_request( self, zc: 'Zeroconf', @@ -804,7 +816,7 @@ async def async_request( assert zc.loop is not None first_request = True - delay = _LISTENER_TIME + delay = self._get_initial_delay() next_ = now last = now + timeout try: @@ -813,18 +825,25 @@ async def async_request( if last <= now: return False if next_ <= now: - out = self._generate_request_query( - zc, - now, - question_type or DNS_QUESTION_TYPE_QU if first_request else DNS_QUESTION_TYPE_QM, - ) + this_question_type = question_type or QU_QUESTION if first_request else QM_QUESTION + out = self._generate_request_query(zc, now, this_question_type) first_request = False - if not out.questions: - return self._load_from_cache(zc, now) - zc.async_send(out, addr, port) + if out.questions: + # All questions may have been suppressed + # by the question history, so nothing to send, + # but keep waiting for answers in case another + # client on the network is asking the same + # question or they have not arrived yet. + zc.async_send(out, addr, port) next_ = now + delay - delay *= 2 - next_ += random.randint(*_AVOID_SYNC_DELAY_RANDOM_INTERVAL) + next_ += self._get_random_delay() + if this_question_type is QM_QUESTION and delay < _DUPLICATE_QUESTION_INTERVAL: + # If we just asked a QM question, we need to + # wait at least the duplicate question interval + # before asking another QM question otherwise + # its likely to be suppressed by the question + # history of the remote responder. + delay = _DUPLICATE_QUESTION_INTERVAL await self.async_wait(min(next_, last) - now, zc.loop) now = current_time_millis() @@ -833,21 +852,57 @@ async def async_request( return True + def _add_question_with_known_answers( + self, + out: DNSOutgoing, + qu_question: bool, + question_history: QuestionHistory, + cache: DNSCache, + now: float_, + name: str_, + type_: int_, + class_: int_, + skip_if_known_answers: bool, + ) -> None: + """Add a question with known answers if its not suppressed.""" + known_answers = { + answer for answer in cache.get_all_by_details(name, type_, class_) if not answer.is_stale(now) + } + if skip_if_known_answers and known_answers: + return + question = DNSQuestion(name, type_, class_) + if qu_question: + question.unicast = True + elif question_history.suppresses(question, now, known_answers): + return + else: + question_history.add_question_at_time(question, now, known_answers) + out.add_question(question) + for answer in known_answers: + out.add_answer_at_time(answer, now) + def _generate_request_query( self, zc: 'Zeroconf', now: float_, question_type: DNSQuestionType ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) name = self._name - server_or_name = self.server or name + server = self.server or name cache = zc.cache - out.add_question_or_one_cache(cache, now, name, _TYPE_SRV, _CLASS_IN) - out.add_question_or_one_cache(cache, now, name, _TYPE_TXT, _CLASS_IN) - out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_A, _CLASS_IN) - out.add_question_or_all_cache(cache, now, server_or_name, _TYPE_AAAA, _CLASS_IN) - if question_type is DNS_QUESTION_TYPE_QU: - for question in out.questions: - question.unicast = True + history = zc.question_history + qu_question = question_type is QU_QUESTION + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_SRV, _CLASS_IN, True + ) + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_TXT, _CLASS_IN, True + ) + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_A, _CLASS_IN, False + ) + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_AAAA, _CLASS_IN, False + ) return out def __repr__(self) -> str: diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 482b3b0ce..c02d5e055 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -247,7 +247,7 @@ def test_get_info_partial(self): send_event = Event() service_info_event = Event() - last_sent = None # type: Optional[r.DNSOutgoing] + last_sent: Optional[r.DNSOutgoing] = None def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -280,7 +280,7 @@ def get_service_info_helper(zc, type, name): helper_thread.start() wait_time = 1 - # Expext query for SRV, TXT, A, AAAA + # Expect query for SRV, TXT, A, AAAA send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 @@ -290,7 +290,7 @@ def get_service_info_helper(zc, type, name): assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None - # Expext query for SRV, A, AAAA + # Expect query for SRV, A, AAAA last_sent = None send_event.clear() _inject_response( @@ -315,7 +315,7 @@ def get_service_info_helper(zc, type, name): assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None - # Expext query for A, AAAA + # Expect query for A, AAAA last_sent = None send_event.clear() _inject_response( @@ -343,7 +343,7 @@ def get_service_info_helper(zc, type, name): last_sent = None assert service_info is None - # Expext no further queries + # Expect no further queries last_sent = None send_event.clear() _inject_response( @@ -377,6 +377,117 @@ def get_service_info_helper(zc, type, name): zc.remove_all_service_listeners() zc.close() + @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') + @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + def test_get_info_suppressed_by_question_history(self): + zc = r.Zeroconf(interfaces=['127.0.0.1']) + + service_name = 'name._type._tcp.local.' + service_type = '_type._tcp.local.' + + service_info = None + send_event = Event() + service_info_event = Event() + + last_sent: Optional[r.DNSOutgoing] = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Sends an outgoing packet.""" + nonlocal last_sent + + last_sent = out + send_event.set() + + # patch the zeroconf send + with patch.object(zc, "async_send", send): + + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + + for record in records: + generated.add_answer_at_time(record, 0) + + return r.DNSIncoming(generated.packets()[0]) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() + + try: + helper_thread = threading.Thread( + target=get_service_info_helper, args=(zc, service_type, service_name) + ) + helper_thread.start() + wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5) / 1000 + + # Expect query for SRV, TXT, A, AAAA + send_event.wait(wait_time) + assert last_sent is not None + assert len(last_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert service_info is None + + # Expect query for SRV only as A, AAAA, and TXT are suppressed + # by the question history + last_sent = None + send_event.clear() + for _ in range(3): + send_event.wait( + wait_time * 0.25 + ) # Wait long enough to be inside the question history window + now = r.current_time_millis() + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), now, set() + ) + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), now, set() + ) + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), now, set() + ) + send_event.wait(wait_time * 0.25) + assert last_sent is not None + assert len(last_sent.questions) == 1 # type: ignore[unreachable] + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert service_info is None + + wait_time = ( + const._DUPLICATE_QUESTION_INTERVAL + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 + ) / 1000 + # Expect no queries as all are suppressed by the question history + last_sent = None + send_event.clear() + for _ in range(3): + send_event.wait( + wait_time * 0.25 + ) # Wait long enough to be inside the question history window + now = r.current_time_millis() + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), now, set() + ) + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), now, set() + ) + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), now, set() + ) + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN), now, set() + ) + send_event.wait(wait_time * 0.25) + # All questions are suppressed so no query should be sent + assert last_sent is None + assert service_info is None + + finally: + helper_thread.join() + zc.remove_all_service_listeners() + zc.close() + def test_get_info_single(self): zc = r.Zeroconf(interfaces=['127.0.0.1']) From 7ffbed800e48c3f0b57596d5551b71c0363ede56 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 16 Dec 2023 16:33:06 +0000 Subject: [PATCH 1068/1433] 0.130.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e70bff7..d437baa7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ +## v0.130.0 (2023-12-16) + +### Feature + +* Make ServiceInfo aware of question history ([#1348](https://github.com/python-zeroconf/python-zeroconf/issues/1348)) ([`b9aae1d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9aae1de07bf1491e873bc314f8a1d7996127ad3)) +* Small speed up to ServiceInfo construction ([#1346](https://github.com/python-zeroconf/python-zeroconf/issues/1346)) ([`b329d99`](https://github.com/python-zeroconf/python-zeroconf/commit/b329d99917bb731b4c70bf20c7c010eeb85ad9fd)) +* Significantly improve efficiency of the ServiceBrowser scheduler ([#1335](https://github.com/python-zeroconf/python-zeroconf/issues/1335)) ([`c65d869`](https://github.com/python-zeroconf/python-zeroconf/commit/c65d869aec731b803484871e9d242a984f9f5848)) +* Small speed up to processing incoming records ([#1345](https://github.com/python-zeroconf/python-zeroconf/issues/1345)) ([`7de655b`](https://github.com/python-zeroconf/python-zeroconf/commit/7de655b6f05012f20a3671e0bcdd44a1913d7b52)) +* Small performance improvement for converting time ([#1342](https://github.com/python-zeroconf/python-zeroconf/issues/1342)) ([`73d3ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/73d3ab90dd3b59caab771235dd6dbedf05bfe0b3)) +* Small performance improvement for ServiceInfo asking questions ([#1341](https://github.com/python-zeroconf/python-zeroconf/issues/1341)) ([`810a309`](https://github.com/python-zeroconf/python-zeroconf/commit/810a3093c5a9411ee97740b468bd706bdf4a95de)) +* Small performance improvement constructing outgoing questions ([#1340](https://github.com/python-zeroconf/python-zeroconf/issues/1340)) ([`157185f`](https://github.com/python-zeroconf/python-zeroconf/commit/157185f28bf1e83e6811e2a5cd1fa9b38966f780)) + +### Fix + +* Scheduling race with the QueryScheduler ([#1347](https://github.com/python-zeroconf/python-zeroconf/issues/1347)) ([`cf40470`](https://github.com/python-zeroconf/python-zeroconf/commit/cf40470b89f918d3c24d7889d3536f3ffa44846c)) +* Ensure question history suppresses duplicates ([#1338](https://github.com/python-zeroconf/python-zeroconf/issues/1338)) ([`6f23656`](https://github.com/python-zeroconf/python-zeroconf/commit/6f23656576daa04e3de44e100f3ddd60ee4c560d)) +* Microsecond precision loss in the query handler ([#1339](https://github.com/python-zeroconf/python-zeroconf/issues/1339)) ([`6560fad`](https://github.com/python-zeroconf/python-zeroconf/commit/6560fad584e0d392962c9a9248759f17c416620e)) +* Ensure IPv6 scoped address construction uses the string cache ([#1336](https://github.com/python-zeroconf/python-zeroconf/issues/1336)) ([`f78a196`](https://github.com/python-zeroconf/python-zeroconf/commit/f78a196db632c4fe017a34f1af8a58903c15a575)) + ## v0.129.0 (2023-12-13) ### Feature diff --git a/pyproject.toml b/pyproject.toml index c30d5ba27..d1f58a140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.129.0" +version = "0.130.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b2f0da536..292c8a2ff 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.129.0' +__version__ = '0.130.0' __license__ = 'LGPL' From 9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Dec 2023 19:15:39 -1000 Subject: [PATCH 1069/1433] feat: speed up the query handler (#1350) --- src/zeroconf/_core.py | 49 +--------------- src/zeroconf/_handlers/query_handler.pxd | 25 +++++++- src/zeroconf/_handlers/query_handler.py | 71 +++++++++++++++++++---- src/zeroconf/_handlers/record_manager.pxd | 13 ++--- src/zeroconf/_handlers/record_manager.py | 5 +- src/zeroconf/_listener.pxd | 2 + src/zeroconf/_listener.py | 4 +- src/zeroconf/_protocol/incoming.pxd | 2 +- src/zeroconf/_transport.py | 4 +- tests/conftest.py | 5 +- 10 files changed, 107 insertions(+), 73 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 5827e2d5b..3a3381a91 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -31,10 +31,6 @@ from ._dns import DNSQuestion, DNSQuestionType from ._engine import AsyncEngine from ._exceptions import NonUniqueNameException, NotRunningException -from ._handlers.answers import ( - construct_outgoing_multicast_answers, - construct_outgoing_unicast_answers, -) from ._handlers.multicast_outgoing_queue import MulticastOutgoingQueue from ._handlers.query_handler import QueryHandler from ._handlers.record_manager import RecordManager @@ -187,15 +183,15 @@ def __init__( self.registry = ServiceRegistry() self.cache = DNSCache() self.question_history = QuestionHistory() - self.query_handler = QueryHandler(self.registry, self.cache, self.question_history) + self.query_handler = QueryHandler(self) self.record_manager = RecordManager(self) self._notify_futures: Set[asyncio.Future] = set() self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None - self._out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) - self._out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) + self.out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) + self.out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) self.start() @@ -567,45 +563,6 @@ def handle_response(self, msg: DNSIncoming) -> None: self.log_warning_once("handle_response is deprecated, use record_manager.async_updates_from_response") self.record_manager.async_updates_from_response(msg) - def handle_assembled_query( - self, - packets: List[DNSIncoming], - addr: str, - port: int, - transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]], - ) -> None: - """Respond to a (re)assembled query. - - If the protocol received packets with the TC bit set, it will - wait a bit for the rest of the packets and only call - handle_assembled_query once it has a complete set of packets - or the timer expires. If the TC bit is not set, a single - packet will be in packets. - """ - ucast_source = port != _MDNS_PORT - question_answers = self.query_handler.async_response(packets, ucast_source) - if not question_answers: - return - now = packets[0].now - if question_answers.ucast: - questions = packets[0].questions - id_ = packets[0].id - out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) - # When sending unicast, only send back the reply - # via the same socket that it was recieved from - # as we know its reachable from that socket - self.async_send(out, addr, port, v6_flow_scope, transport) - if question_answers.mcast_now: - self.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) - if question_answers.mcast_aggregate: - self._out_queue.async_add(now, question_answers.mcast_aggregate) - if question_answers.mcast_aggregate_last_second: - # https://datatracker.ietf.org/doc/html/rfc6762#section-14 - # If we broadcast it in the last second, we have to delay - # at least a second before we send it again - self._out_delay_queue.async_add(now, question_answers.mcast_aggregate_last_second) - def send( self, out: DNSOutgoing, diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index 3e726a533..bb7198be5 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -7,7 +7,12 @@ from .._history cimport QuestionHistory from .._protocol.incoming cimport DNSIncoming from .._services.info cimport ServiceInfo from .._services.registry cimport ServiceRegistry -from .answers cimport QuestionAnswers +from .answers cimport ( + QuestionAnswers, + construct_outgoing_multicast_answers, + construct_outgoing_unicast_answers, +) +from .multicast_outgoing_queue cimport MulticastOutgoingQueue cdef bint TYPE_CHECKING @@ -65,6 +70,7 @@ cdef class _QueryResponse: cdef class QueryHandler: + cdef object zc cdef ServiceRegistry registry cdef DNSCache cache cdef QuestionHistory question_history @@ -93,7 +99,22 @@ cdef class QueryHandler: is_probe=object, now=double ) - cpdef async_response(self, cython.list msgs, cython.bint unicast_source) + cpdef QuestionAnswers async_response(self, cython.list msgs, cython.bint unicast_source) @cython.locals(name=str, question_lower_name=str) cdef _get_answer_strategies(self, DNSQuestion question) + + @cython.locals( + first_packet=DNSIncoming, + ucast_source=bint, + out_queue=MulticastOutgoingQueue, + out_delay_queue=MulticastOutgoingQueue + ) + cpdef void handle_assembled_query( + self, + list packets, + object addr, + object port, + object transport, + tuple v6_flow_scope + ) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index c66d9c302..8349b584b 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -20,19 +20,19 @@ USA """ -from typing import TYPE_CHECKING, List, Optional, Set, cast +from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast from .._cache import DNSCache, _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet -from .._history import QuestionHistory from .._protocol.incoming import DNSIncoming from .._services.info import ServiceInfo -from .._services.registry import ServiceRegistry +from .._transport import _WrappedTransport from .._utils.net import IPVersion from ..const import ( _ADDRESS_RECORD_TYPES, _CLASS_IN, _DNS_OTHER_TTL, + _MDNS_PORT, _ONE_SECOND, _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_A, @@ -43,7 +43,12 @@ _TYPE_SRV, _TYPE_TXT, ) -from .answers import QuestionAnswers, _AnswerWithAdditionalsType +from .answers import ( + QuestionAnswers, + _AnswerWithAdditionalsType, + construct_outgoing_multicast_answers, + construct_outgoing_unicast_answers, +) _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} @@ -53,7 +58,7 @@ _IPVersion_ALL = IPVersion.All _int = int - +_str = str _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION = 0 _ANSWER_STRATEGY_POINTER = 1 @@ -61,6 +66,9 @@ _ANSWER_STRATEGY_SERVICE = 3 _ANSWER_STRATEGY_TEXT = 4 +if TYPE_CHECKING: + from .._core import Zeroconf + class _AnswerStrategy: @@ -183,13 +191,14 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" - __slots__ = ("registry", "cache", "question_history") + __slots__ = ("zc", "registry", "cache", "question_history") - def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: + def __init__(self, zc: 'Zeroconf') -> None: """Init the query handler.""" - self.registry = registry - self.cache = cache - self.question_history = question_history + self.zc = zc + self.registry = zc.registry + self.cache = zc.cache + self.question_history = zc.question_history def _add_service_type_enumeration_query_answers( self, types: List[str], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet @@ -385,3 +394,45 @@ def _get_answer_strategies( ) return strategies + + def handle_assembled_query( + self, + packets: List[DNSIncoming], + addr: _str, + port: _int, + transport: _WrappedTransport, + v6_flow_scope: Union[Tuple[()], Tuple[int, int]], + ) -> None: + """Respond to a (re)assembled query. + + If the protocol recieved packets with the TC bit set, it will + wait a bit for the rest of the packets and only call + handle_assembled_query once it has a complete set of packets + or the timer expires. If the TC bit is not set, a single + packet will be in packets. + """ + first_packet = packets[0] + now = first_packet.now + ucast_source = port != _MDNS_PORT + question_answers = self.async_response(packets, ucast_source) + if not question_answers: + return + if question_answers.ucast: + questions = first_packet.questions + id_ = first_packet.id + out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) + # When sending unicast, only send back the reply + # via the same socket that it was recieved from + # as we know its reachable from that socket + self.zc.async_send(out, addr, port, v6_flow_scope, transport) + if question_answers.mcast_now: + self.zc.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) + if question_answers.mcast_aggregate: + out_queue = self.zc.out_queue + out_queue.async_add(now, question_answers.mcast_aggregate) + if question_answers.mcast_aggregate_last_second: + # https://datatracker.ietf.org/doc/html/rfc6762#section-14 + # If we broadcast it in the last second, we have to delay + # at least a second before we send it again + out_delay_queue = self.zc.out_delay_queue + out_delay_queue.async_add(now, question_answers.mcast_aggregate_last_second) diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 0f543aff2..5be2c283b 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -22,22 +22,21 @@ cdef class RecordManager: cdef public DNSCache cache cdef public cython.set listeners - cpdef async_updates(self, object now, object records) + cpdef void async_updates(self, object now, object records) - cpdef async_updates_complete(self, object notify) + cpdef void async_updates_complete(self, bint notify) @cython.locals( cache=DNSCache, record=DNSRecord, answers=cython.list, maybe_entry=DNSRecord, - now_double=double ) - cpdef async_updates_from_response(self, DNSIncoming msg) + cpdef void async_updates_from_response(self, DNSIncoming msg) - cpdef async_add_listener(self, RecordUpdateListener listener, object question) + cpdef void async_add_listener(self, RecordUpdateListener listener, object question) - cpdef async_remove_listener(self, RecordUpdateListener listener) + cpdef void async_remove_listener(self, RecordUpdateListener listener) @cython.locals(question=DNSQuestion, record=DNSRecord) - cdef _async_update_matching_records(self, RecordUpdateListener listener, cython.list questions) + cdef void _async_update_matching_records(self, RecordUpdateListener listener, cython.list questions) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 129acd0b6..0a0f6c54b 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -84,7 +84,6 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: other_adds: List[DNSRecord] = [] removes: Set[DNSRecord] = set() now = msg.now - now_double = now unique_types: Set[Tuple[str, int, int]] = set() cache = self.cache answers = msg.answers() @@ -113,7 +112,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: record = cast(_UniqueRecordsType, record) maybe_entry = cache.async_get_unique(record) - if not record.is_expired(now_double): + if not record.is_expired(now): if maybe_entry is not None: maybe_entry.reset_ttl(record) else: @@ -129,7 +128,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now_double) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) if updates: self.async_updates(now, updates) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 8b144653e..96f52be02 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -1,6 +1,7 @@ import cython +from ._handlers.query_handler cimport QueryHandler from ._handlers.record_manager cimport RecordManager from ._protocol.incoming cimport DNSIncoming from ._services.registry cimport ServiceRegistry @@ -21,6 +22,7 @@ cdef class AsyncListener: cdef public object zc cdef ServiceRegistry _registry cdef RecordManager _record_manager + cdef QueryHandler _query_handler cdef public cython.bytes data cdef public double last_time cdef public DNSIncoming last_message diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 23d245785..0f8a8cac7 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -59,6 +59,7 @@ class AsyncListener: 'zc', '_registry', '_record_manager', + "_query_handler", 'data', 'last_time', 'last_message', @@ -72,6 +73,7 @@ def __init__(self, zc: 'Zeroconf') -> None: self.zc = zc self._registry = zc.registry self._record_manager = zc.record_manager + self._query_handler = zc.query_handler self.data: Optional[bytes] = None self.last_time: float = 0 self.last_message: Optional[DNSIncoming] = None @@ -228,7 +230,7 @@ def _respond_query( if msg: packets.append(msg) - self.zc.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) + self._query_handler.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index 07ae6e78e..a8c0dbdb8 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -56,7 +56,7 @@ cdef class DNSIncoming: cdef cython.uint _num_authorities cdef cython.uint _num_additionals cdef public bint valid - cdef public object now + cdef public double now cdef public object scope_id cdef public object source cdef bint _has_qu_question diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index 7f6d7ac8c..c37af2efd 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -22,7 +22,7 @@ import asyncio import socket -from typing import Any +from typing import Tuple class _WrappedTransport: @@ -42,7 +42,7 @@ def __init__( is_ipv6: bool, sock: socket.socket, fileno: int, - sock_name: Any, + sock_name: Tuple, ) -> None: """Initialize the wrapped transport. diff --git a/tests/conftest.py b/tests/conftest.py index c0e926a34..5525c4ee0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import pytest from zeroconf import _core, const +from zeroconf._handlers import query_handler @pytest.fixture(autouse=True) @@ -23,7 +24,9 @@ def verify_threads_ended(): @pytest.fixture def run_isolated(): """Change the mDNS port to run the test in isolation.""" - with patch.object(_core, "_MDNS_PORT", 5454), patch.object(const, "_MDNS_PORT", 5454): + with patch.object(query_handler, "_MDNS_PORT", 5454), patch.object( + _core, "_MDNS_PORT", 5454 + ), patch.object(const, "_MDNS_PORT", 5454): yield From a014c7caac50ad71085ddcaf010a702e972e83f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Dec 2023 21:50:28 -1000 Subject: [PATCH 1070/1433] chore: remove deprecated handle_response (#1353) --- src/zeroconf/_core.py | 7 ------- tests/test_asyncio.py | 1 - 2 files changed, 8 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 3a3381a91..156e0b1ae 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -36,7 +36,6 @@ from ._handlers.record_manager import RecordManager from ._history import QuestionHistory from ._logger import QuietLogger, log -from ._protocol.incoming import DNSIncoming from ._protocol.outgoing import DNSOutgoing from ._services import ServiceListener from ._services.browser import ServiceBrowser @@ -557,12 +556,6 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: """ self.record_manager.async_remove_listener(listener) - def handle_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - self.log_warning_once("handle_response is deprecated, use record_manager.async_updates_from_response") - self.record_manager.async_updates_from_response(msg) - def send( self, out: DNSOutgoing, diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d4594788b..63255158d 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1246,7 +1246,6 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de ) zc.record_manager.async_updates_from_response(DNSIncoming(generated.packets()[0])) - zc.handle_response(DNSIncoming(generated.packets()[0])) await browser.async_cancel() await asyncio.sleep(0) From 6c153258a995cf9459a6f23267b7e379b5e2550f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Dec 2023 21:50:45 -1000 Subject: [PATCH 1071/1433] feat: speed up processing incoming packets (#1352) --- src/zeroconf/_cache.pxd | 16 +++++++------- src/zeroconf/_core.py | 7 ++++--- src/zeroconf/_handlers/answers.pxd | 14 ++++++------- .../_handlers/multicast_outgoing_queue.pxd | 6 +++--- src/zeroconf/_handlers/query_handler.pxd | 18 ++++++++-------- src/zeroconf/_handlers/query_handler.py | 21 +++++++++---------- src/zeroconf/_protocol/incoming.pxd | 12 +++++------ 7 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 84107957a..af27a1d51 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -26,23 +26,23 @@ cdef class DNSCache: cpdef bint async_add_records(self, object entries) - cpdef async_remove_records(self, object entries) + cpdef void async_remove_records(self, object entries) @cython.locals( store=cython.dict, ) - cpdef async_get_unique(self, DNSRecord entry) + cpdef DNSRecord async_get_unique(self, DNSRecord entry) @cython.locals( record=DNSRecord, ) - cpdef async_expire(self, double now) + cpdef list async_expire(self, double now) @cython.locals( records=cython.dict, record=DNSRecord, ) - cpdef async_all_by_details(self, str name, object type_, object class_) + cpdef list async_all_by_details(self, str name, object type_, object class_) cpdef cython.dict async_entries_with_name(self, str name) @@ -51,7 +51,7 @@ cdef class DNSCache: @cython.locals( cached_entry=DNSRecord, ) - cpdef get_by_details(self, str name, object type_, object class_) + cpdef DNSRecord get_by_details(self, str name, object type_, object class_) @cython.locals( records=cython.dict, @@ -62,12 +62,12 @@ cdef class DNSCache: @cython.locals( store=cython.dict, ) - cdef _async_add(self, DNSRecord record) + cdef bint _async_add(self, DNSRecord record) - cdef _async_remove(self, DNSRecord record) + cdef void _async_remove(self, DNSRecord record) @cython.locals( record=DNSRecord, created_double=double, ) - cpdef async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) + cpdef void async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 156e0b1ae..4b29717a7 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -182,6 +182,10 @@ def __init__( self.registry = ServiceRegistry() self.cache = DNSCache() self.question_history = QuestionHistory() + + self.out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) + self.out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) + self.query_handler = QueryHandler(self) self.record_manager = RecordManager(self) @@ -189,9 +193,6 @@ def __init__( self.loop: Optional[asyncio.AbstractEventLoop] = None self._loop_thread: Optional[threading.Thread] = None - self.out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) - self.out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) - self.start() @property diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index 5a3010ad9..25b3c1a1e 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -7,10 +7,10 @@ from .._protocol.outgoing cimport DNSOutgoing cdef class QuestionAnswers: - cdef public object ucast - cdef public object mcast_now - cdef public object mcast_aggregate - cdef public object mcast_aggregate_last_second + cdef public dict ucast + cdef public dict mcast_now + cdef public dict mcast_aggregate + cdef public dict mcast_aggregate_last_second cdef class AnswerGroup: @@ -25,11 +25,11 @@ cdef class AnswerGroup: cdef object _FLAGS_QR_RESPONSE_AA cdef object NAME_GETTER -cpdef construct_outgoing_multicast_answers(cython.dict answers) +cpdef DNSOutgoing construct_outgoing_multicast_answers(cython.dict answers) -cpdef construct_outgoing_unicast_answers( +cpdef DNSOutgoing construct_outgoing_unicast_answers( cython.dict answers, bint ucast_source, cython.list questions, object id_ ) @cython.locals(answer=DNSRecord, additionals=cython.set, additional=DNSRecord) -cdef _add_answers_additionals(DNSOutgoing out, cython.dict answers) +cdef void _add_answers_additionals(DNSOutgoing out, cython.dict answers) diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd index 1a8d6741f..88cfdaa0e 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.pxd @@ -19,9 +19,9 @@ cdef class MulticastOutgoingQueue: cdef object _aggregation_delay @cython.locals(last_group=AnswerGroup, random_int=cython.uint) - cpdef async_add(self, double now, cython.dict answers) + cpdef void async_add(self, double now, cython.dict answers) @cython.locals(pending=AnswerGroup) - cdef _remove_answers_from_queue(self, cython.dict answers) + cdef void _remove_answers_from_queue(self, cython.dict answers) - cpdef async_ready(self) + cpdef void async_ready(self) diff --git a/src/zeroconf/_handlers/query_handler.pxd b/src/zeroconf/_handlers/query_handler.pxd index bb7198be5..89a1f2b25 100644 --- a/src/zeroconf/_handlers/query_handler.pxd +++ b/src/zeroconf/_handlers/query_handler.pxd @@ -53,12 +53,12 @@ cdef class _QueryResponse: cdef cython.set _mcast_aggregate_last_second @cython.locals(record=DNSRecord) - cdef add_qu_question_response(self, cython.dict answers) + cdef void add_qu_question_response(self, cython.dict answers) - cdef add_ucast_question_response(self, cython.dict answers) + cdef void add_ucast_question_response(self, cython.dict answers) @cython.locals(answer=DNSRecord, question=DNSQuestion) - cdef add_mcast_question_response(self, cython.dict answers) + cdef void add_mcast_question_response(self, cython.dict answers) @cython.locals(maybe_entry=DNSRecord) cdef bint _has_mcast_within_one_quarter_ttl(self, DNSRecord record) @@ -74,15 +74,17 @@ cdef class QueryHandler: cdef ServiceRegistry registry cdef DNSCache cache cdef QuestionHistory question_history + cdef MulticastOutgoingQueue out_queue + cdef MulticastOutgoingQueue out_delay_queue @cython.locals(service=ServiceInfo) - cdef _add_service_type_enumeration_query_answers(self, list types, cython.dict answer_set, DNSRRSet known_answers) + cdef void _add_service_type_enumeration_query_answers(self, list types, cython.dict answer_set, DNSRRSet known_answers) @cython.locals(service=ServiceInfo) - cdef _add_pointer_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers) + cdef void _add_pointer_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers) @cython.locals(service=ServiceInfo, dns_address=DNSAddress) - cdef _add_address_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) + cdef void _add_address_answers(self, list services, cython.dict answer_set, DNSRRSet known_answers, cython.uint type_) @cython.locals(question_lower_name=str, type_=cython.uint, service=ServiceInfo) cdef cython.dict _answer_question(self, DNSQuestion question, unsigned int strategy_type, list types, list services, DNSRRSet known_answers) @@ -102,13 +104,11 @@ cdef class QueryHandler: cpdef QuestionAnswers async_response(self, cython.list msgs, cython.bint unicast_source) @cython.locals(name=str, question_lower_name=str) - cdef _get_answer_strategies(self, DNSQuestion question) + cdef list _get_answer_strategies(self, DNSQuestion question) @cython.locals( first_packet=DNSIncoming, ucast_source=bint, - out_queue=MulticastOutgoingQueue, - out_delay_queue=MulticastOutgoingQueue ) cpdef void handle_assembled_query( self, diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 8349b584b..ba9c9e31c 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -191,7 +191,7 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" - __slots__ = ("zc", "registry", "cache", "question_history") + __slots__ = ("zc", "registry", "cache", "question_history", "out_queue", "out_delay_queue") def __init__(self, zc: 'Zeroconf') -> None: """Init the query handler.""" @@ -199,6 +199,8 @@ def __init__(self, zc: 'Zeroconf') -> None: self.registry = zc.registry self.cache = zc.cache self.question_history = zc.question_history + self.out_queue = zc.out_queue + self.out_delay_queue = zc.out_delay_queue def _add_service_type_enumeration_query_answers( self, types: List[str], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet @@ -301,7 +303,7 @@ def async_response( # pylint: disable=unused-argument """ strategies: List[_AnswerStrategy] = [] for msg in msgs: - for question in msg.questions: + for question in msg._questions: strategies.extend(self._get_answer_strategies(question)) if not strategies: @@ -311,7 +313,8 @@ def async_response( # pylint: disable=unused-argument return None is_probe = False - questions = msg.questions + msg = msgs[0] + questions = msg._questions # Only decode known answers if we are not a probe and we have # at least one answer strategy answers: List[DNSRecord] = [] @@ -321,7 +324,6 @@ def async_response( # pylint: disable=unused-argument else: answers.extend(msg.answers()) - msg = msgs[0] query_res = _QueryResponse(self.cache, questions, is_probe, msg.now) known_answers = DNSRRSet(answers) known_answers_set: Optional[Set[DNSRecord]] = None @@ -412,13 +414,12 @@ def handle_assembled_query( packet will be in packets. """ first_packet = packets[0] - now = first_packet.now ucast_source = port != _MDNS_PORT question_answers = self.async_response(packets, ucast_source) - if not question_answers: + if question_answers is None: return if question_answers.ucast: - questions = first_packet.questions + questions = first_packet._questions id_ = first_packet.id out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) # When sending unicast, only send back the reply @@ -428,11 +429,9 @@ def handle_assembled_query( if question_answers.mcast_now: self.zc.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) if question_answers.mcast_aggregate: - out_queue = self.zc.out_queue - out_queue.async_add(now, question_answers.mcast_aggregate) + self.out_queue.async_add(first_packet.now, question_answers.mcast_aggregate) if question_answers.mcast_aggregate_last_second: # https://datatracker.ietf.org/doc/html/rfc6762#section-14 # If we broadcast it in the last second, we have to delay # at least a second before we send it again - out_delay_queue = self.zc.out_delay_queue - out_delay_queue.async_add(now, question_answers.mcast_aggregate_last_second) + self.out_delay_queue.async_add(first_packet.now, question_answers.mcast_aggregate_last_second) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index a8c0dbdb8..bb4383036 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -70,7 +70,7 @@ cdef class DNSIncoming: cpdef bint is_probe(self) - cpdef answers(self) + cpdef list answers(self) cpdef bint is_response(self) @@ -86,16 +86,16 @@ cdef class DNSIncoming: cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) @cython.locals(offset="unsigned int") - cdef _read_header(self) + cdef void _read_header(self) - cdef _initial_parse(self) + cdef void _initial_parse(self) @cython.locals( end="unsigned int", length="unsigned int", offset="unsigned int" ) - cdef _read_others(self) + cdef void _read_others(self) @cython.locals(offset="unsigned int") cdef _read_questions(self) @@ -123,6 +123,6 @@ cdef class DNSIncoming: i="unsigned int", bitmap_length="unsigned int", ) - cdef _read_bitmap(self, unsigned int end) + cdef list _read_bitmap(self, unsigned int end) - cdef _read_name(self) + cdef str _read_name(self) From 517d7d00ca7738c770077738125aec0e4824c000 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Dec 2023 22:29:22 -1000 Subject: [PATCH 1072/1433] feat: small speed up to constructing outgoing packets (#1354) --- src/zeroconf/_protocol/outgoing.pxd | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index 2496a9886..fa1aeebcd 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -50,17 +50,17 @@ cdef class DNSOutgoing: cdef public cython.list authorities cdef public cython.list additionals - cpdef _reset_for_next_packet(self) + cpdef void _reset_for_next_packet(self) - cdef _write_byte(self, cython.uint value) + cdef void _write_byte(self, cython.uint value) cdef void _insert_short_at_start(self, unsigned int value) - cdef _replace_short(self, cython.uint index, cython.uint value) + cdef void _replace_short(self, cython.uint index, cython.uint value) cdef _get_short(self, cython.uint value) - cdef _write_int(self, object value) + cdef void _write_int(self, object value) cdef cython.bint _write_question(self, DNSQuestion question) @@ -73,7 +73,7 @@ cdef class DNSOutgoing: cdef cython.bint _write_record(self, DNSRecord record, double now) @cython.locals(class_=cython.uint) - cdef _write_record_class(self, DNSEntry record) + cdef void _write_record_class(self, DNSEntry record) @cython.locals( start_size_int=object @@ -91,7 +91,7 @@ cdef class DNSOutgoing: cdef bint _has_more_to_add(self, unsigned int questions_offset, unsigned int answer_offset, unsigned int authority_offset, unsigned int additional_offset) - cdef _write_ttl(self, DNSRecord record, double now) + cdef void _write_ttl(self, DNSRecord record, double now) @cython.locals( labels=cython.list, @@ -100,16 +100,16 @@ cdef class DNSOutgoing: start_size=cython.uint, name_length=cython.uint, ) - cpdef write_name(self, cython.str name) + cpdef void write_name(self, cython.str name) - cdef _write_link_to_name(self, unsigned int index) + cdef void _write_link_to_name(self, unsigned int index) - cpdef write_short(self, cython.uint value) + cpdef void write_short(self, cython.uint value) - cpdef write_string(self, cython.bytes value) + cpdef void write_string(self, cython.bytes value) @cython.locals(utfstr=bytes) - cpdef _write_utf(self, cython.str value) + cdef void _write_utf(self, cython.str value) @cython.locals( debug_enable=bint, From dfc9b8d7dec519ca713a811613122718cb2d733e Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 19 Dec 2023 08:38:02 +0000 Subject: [PATCH 1073/1433] 0.131.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d437baa7b..4e2fbc0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v0.131.0 (2023-12-19) + +### Feature + +* Small speed up to constructing outgoing packets ([#1354](https://github.com/python-zeroconf/python-zeroconf/issues/1354)) ([`517d7d0`](https://github.com/python-zeroconf/python-zeroconf/commit/517d7d00ca7738c770077738125aec0e4824c000)) +* Speed up processing incoming packets ([#1352](https://github.com/python-zeroconf/python-zeroconf/issues/1352)) ([`6c15325`](https://github.com/python-zeroconf/python-zeroconf/commit/6c153258a995cf9459a6f23267b7e379b5e2550f)) +* Speed up the query handler ([#1350](https://github.com/python-zeroconf/python-zeroconf/issues/1350)) ([`9eac0a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb)) + ## v0.130.0 (2023-12-16) ### Feature diff --git a/pyproject.toml b/pyproject.toml index d1f58a140..c711d9a63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.130.0" +version = "0.131.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 292c8a2ff..e6b8e481d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.130.0' +__version__ = '0.131.0' __license__ = 'LGPL' From 4877829e6442de5426db152d11827b1ba85dbf59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 16:59:31 -0600 Subject: [PATCH 1074/1433] feat: drop python 3.7 support (#1359) --- .github/workflows/ci.yml | 12 +- poetry.lock | 308 ++++++++++++++++----------------------- pyproject.toml | 4 +- src/zeroconf/const.py | 6 +- 4 files changed, 135 insertions(+), 195 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da9db349f..00d3fe9bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,13 @@ jobs: fail-fast: false matrix: python-version: - - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" - - "pypy-3.7" + - "pypy-3.8" + - "pypy-3.9" os: - ubuntu-latest - macos-latest @@ -56,7 +56,13 @@ jobs: - os: windows-latest extension: use_cython - os: windows-latest - python-version: "pypy-3.7" + python-version: "pypy-3.8" + - os: windows-latest + python-version: "pypy-3.9" + - os: macos-latest + python-version: "pypy-3.8" + - os: macos-latest + python-version: "pypy-3.9" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/poetry.lock b/poetry.lock index 71c5d27cc..a9a7c6c2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "async-timeout" @@ -11,9 +11,6 @@ files = [ {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] -[package.dependencies] -typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} - [[package]] name = "colorama" version = "0.4.6" @@ -27,71 +24,63 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.4.1" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, + {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, + {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, + {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, + {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, + {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, + {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, + {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, + {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, + {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, + {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, + {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, + {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, ] [package.dependencies] @@ -102,69 +91,69 @@ toml = ["tomli"] [[package]] name = "cython" -version = "3.0.6" +version = "3.0.8" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ - {file = "Cython-3.0.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcdfbf6fc7d0bd683d55e617c3d5a5f25b28ce8b405bc1e89054fc7c52a97e5"}, - {file = "Cython-3.0.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccbee314f8d15ee8ddbe270859dda427e1187123f2c7c41526d1f260eee6c8f7"}, - {file = "Cython-3.0.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14b992f36ffa1294921fca5f6488ea192fadd75770dc64fa25975379382551e9"}, - {file = "Cython-3.0.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2e90a75d405070f3c41e701bb8005892f14d42322f1d8fd00a61d660bbae7"}, - {file = "Cython-3.0.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4121c1160bc1bd8828546e8ce45906bd9ff27799d14747ce3fbbc9d67efbb1b8"}, - {file = "Cython-3.0.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:519814b8f80869ee5f9ee2cb2363e5c310067c0298cbea291c556b22da1ef6ae"}, - {file = "Cython-3.0.6-cp310-cp310-win32.whl", hash = "sha256:b029d8c754ef867ab4d67fc2477dde9782bf0409cb8e4024a7d29cf5aff37530"}, - {file = "Cython-3.0.6-cp310-cp310-win_amd64.whl", hash = "sha256:2262390f453eedf600e084b074144286576ed2a56bb7fbfe15ad8d9499eceb52"}, - {file = "Cython-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfe8c7ac60363769ed8d91fca26398aaa9640368ab999a79b0ccb5e788d3bcf8"}, - {file = "Cython-3.0.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e31a9b18ec6ce57eb3479df920e6093596fe4ba8010dcc372720040386b4bdb"}, - {file = "Cython-3.0.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca2542f1f34f0141475b13777df040c31f2073a055097734a0a793ac3a4fb72"}, - {file = "Cython-3.0.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c1c38dad4bd85e142ccbe2f88122807f8d5a75352321e1e4baf2b293df7c6"}, - {file = "Cython-3.0.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc4b4e76c1414584bb55465dfb6f41dd6bd27fd53fb41ddfcaca9edf00c1f80e"}, - {file = "Cython-3.0.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:805a2c532feee09aeed064eaeb7b6ee35cbab650569d0a3756975f3cc4f246cf"}, - {file = "Cython-3.0.6-cp311-cp311-win32.whl", hash = "sha256:dcdb9a177c7c385fe0c0709a9a6790b6508847d67dcac76bb65a2c7ea447efe5"}, - {file = "Cython-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:b8640b7f6503292c358cef925df5a69adf230045719893ffe20ad98024fdf7ae"}, - {file = "Cython-3.0.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:16b3b02cc7b3bc42ee1a0118b1465ca46b0f3fb32d003e6f1a3a352a819bb9a3"}, - {file = "Cython-3.0.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11e1d9b153573c425846b627bef52b3b99cb73d4fbfbb136e500a878d4b5e803"}, - {file = "Cython-3.0.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a7a406f78c2f297bf82136ff5deac3150288446005ed1e56552a9e3ac1469f"}, - {file = "Cython-3.0.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88be4fbc760de8f313df89ca8256098c0963c9ec72f3aa88538384b80ef1a6ef"}, - {file = "Cython-3.0.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea2e5a7c503b41618bfb10e4bc610f780ab1c729280531b5cabb24e05aa21cf2"}, - {file = "Cython-3.0.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d296b48e1410cab50220a28a834167f2d7ac6c0e7de12834d66e42248a1b0f6"}, - {file = "Cython-3.0.6-cp312-cp312-win32.whl", hash = "sha256:7f19e99c6e334e9e30dfa844c3ca4ac09931b94dbba406c646bde54687aed758"}, - {file = "Cython-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:9cae02e26967ffb6503c6e91b77010acbadfb7189a5a11d6158d634fb0f73679"}, - {file = "Cython-3.0.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cb6a54543869a5b0ad009d86eb0ebc0879fab838392bfd253ad6d4f5e0f17d84"}, - {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d2d9e53bf021cc7a5c7b6b537b5b5a7ba466ba7348d498aa17499d0ad12637e"}, - {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05d15854b2b363b35c755d22015c1c2fc590b8128202f8c9eb85578461101d9c"}, - {file = "Cython-3.0.6-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5548316497a3b8b2d9da575ea143476472db90dee73c67def061621940f78ae"}, - {file = "Cython-3.0.6-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9b853e0855e4b3d164c05b24718e5e2df369e5af54f47cb8d923c4f497dfc92c"}, - {file = "Cython-3.0.6-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2c77f97f462a40a319dda7e28c1669370cb26f9175f3e8f9bab99d2f8f3f2f09"}, - {file = "Cython-3.0.6-cp36-cp36m-win32.whl", hash = "sha256:3ac8b6734f2cad5640f2da21cd33cf88323547d07e445fb7453ab38ec5033b1f"}, - {file = "Cython-3.0.6-cp36-cp36m-win_amd64.whl", hash = "sha256:8dd5f5f3587909ff71f0562f50e00d4b836c948e56e8f74897b12f38a29e41b9"}, - {file = "Cython-3.0.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9c0472c6394750469062deb2c166125b10411636f63a0418b5c36a60d0c9a96a"}, - {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97081932c8810bb99cb26b4b0402202a1764b58ee287c8b306071d2848148c24"}, - {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e781b3880dfd0d4d37983c9d414bfd5f26c2141f6d763d20ef1964a0a4cb2405"}, - {file = "Cython-3.0.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef88c46e91e21772a5d3b6b1e70a6da5fe098154ad4768888129b1c05e93bba7"}, - {file = "Cython-3.0.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a38b9e7a252ec27dbc21ee8f00f09a896e88285eebb6ed99207b2ff1ea6af28e"}, - {file = "Cython-3.0.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4975cdaf720d29288ec225b76b4f4471ff03f4f8b51841ba85d6587699ab2ad5"}, - {file = "Cython-3.0.6-cp37-cp37m-win32.whl", hash = "sha256:9b89463ea330318461ca47d3e49b5f606e7e82446b6f37e5c19b60392439674c"}, - {file = "Cython-3.0.6-cp37-cp37m-win_amd64.whl", hash = "sha256:0ca8f379b47417bfad98faeb14bf8a3966fc92cf69f8aaf7635cf6885e50d001"}, - {file = "Cython-3.0.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b3dda1e80eb577b9563cee6cf31923a7b88836b9f9be0043ec545b138b95d8e8"}, - {file = "Cython-3.0.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e34e9a96f98c379100ef4192994a311678fb5c9af34c83ba5230223577581"}, - {file = "Cython-3.0.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:345d9112fde4ae0347d656f58591fd52017c61a19779c95423bb38735fe4a401"}, - {file = "Cython-3.0.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25da0e51331ac12ff16cd858d1d836e092c984e1dc45d338166081d3802297c0"}, - {file = "Cython-3.0.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:eebbf09089b4988b9f398ed46f168892e32fcfeec346b15954fdd818aa103456"}, - {file = "Cython-3.0.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e3ed0c125556324fa49b9e92bea13be7b158fcae6f72599d63c8733688257788"}, - {file = "Cython-3.0.6-cp38-cp38-win32.whl", hash = "sha256:86e1e5a5c9157a547d0a769de59c98a1fc5e46cfad976f32f60423cc6de11052"}, - {file = "Cython-3.0.6-cp38-cp38-win_amd64.whl", hash = "sha256:0d45a84a315bd84d1515cd3571415a0ee0709eb4e2cd4b13668ede928af344a7"}, - {file = "Cython-3.0.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a8e788e64b659bb8fe980bc37da3118e1f7285dec40c5fb293adabc74d4205f2"}, - {file = "Cython-3.0.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a77a174c7fb13d80754c8bf9912efd3f3696d13285b2f568eca17324263b3f7"}, - {file = "Cython-3.0.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1074e84752cd0daf3226823ddbc37cca8bc45f61c94a1db2a34e641f2b9b0797"}, - {file = "Cython-3.0.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49d5cae02d56e151e1481e614a1af9a0fe659358f2aa5eca7a18f05aa641db61"}, - {file = "Cython-3.0.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b94610fa49e36db068446cfd149a42e3246f38a4256bbe818512ac181446b4b"}, - {file = "Cython-3.0.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fabb2d14dd71add618a7892c40ffec584d1dae1e477caa193778e52e06821d83"}, - {file = "Cython-3.0.6-cp39-cp39-win32.whl", hash = "sha256:ce442c0be72ab014c305399d955b78c3d1e69d5a5ce24398122b605691b69078"}, - {file = "Cython-3.0.6-cp39-cp39-win_amd64.whl", hash = "sha256:8a05f79a0761fc76c42e945e5a9cb5d7986aa9e8e526fdf52bd9ca61a12d4567"}, - {file = "Cython-3.0.6-py2.py3-none-any.whl", hash = "sha256:5921a175ea20779d4443ef99276cfa9a1a47de0e32d593be7679be741c9ed93b"}, - {file = "Cython-3.0.6.tar.gz", hash = "sha256:399d185672c667b26eabbdca420c98564583798af3bc47670a8a09e9f19dd660"}, + {file = "Cython-3.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a846e0a38e2b24e9a5c5dc74b0e54c6e29420d88d1dafabc99e0fc0f3e338636"}, + {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45523fdc2b78d79b32834cc1cc12dc2ca8967af87e22a3ee1bff20e77c7f5520"}, + {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa0b7f3f841fe087410cab66778e2d3fb20ae2d2078a2be3dffe66c6574be39"}, + {file = "Cython-3.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e87294e33e40c289c77a135f491cd721bd089f193f956f7b8ed5aa2d0b8c558f"}, + {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a1df7a129344b1215c20096d33c00193437df1a8fcca25b71f17c23b1a44f782"}, + {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:13c2a5e57a0358da467d97667297bf820b62a1a87ae47c5f87938b9bb593acbd"}, + {file = "Cython-3.0.8-cp310-cp310-win32.whl", hash = "sha256:96b028f044f5880e3cb18ecdcfc6c8d3ce9d0af28418d5ab464509f26d8adf12"}, + {file = "Cython-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:8140597a8b5cc4f119a1190f5a2228a84f5ca6d8d9ec386cfce24663f48b2539"}, + {file = "Cython-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aae26f9663e50caf9657148403d9874eea41770ecdd6caf381d177c2b1bb82ba"}, + {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:547eb3cdb2f8c6f48e6865d5a741d9dd051c25b3ce076fbca571727977b28ac3"}, + {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a567d4b9ba70b26db89d75b243529de9e649a2f56384287533cf91512705bee"}, + {file = "Cython-3.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d1426263b0e82fb22bda8ea60dc77a428581cc19e97741011b938445d383f1"}, + {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c26daaeccda072459b48d211415fd1e5507c06bcd976fa0d5b8b9f1063467d7b"}, + {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:289ce7838208211cd166e975865fd73b0649bf118170b6cebaedfbdaf4a37795"}, + {file = "Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871"}, + {file = "Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02"}, + {file = "Cython-3.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d3fe31db55685d8cb97d43b0ec39ef614fcf660f83c77ed06aa670cb0e164f"}, + {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24791ddae2324e88e3c902a765595c738f19ae34ee66bfb1a6dac54b1833419"}, + {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f020fa1c0552052e0660790b8153b79e3fc9a15dbd8f1d0b841fe5d204a6ae6"}, + {file = "Cython-3.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18bfa387d7a7f77d7b2526af69a65dbd0b731b8d941aaff5becff8e21f6d7717"}, + {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fe81b339cffd87c0069c6049b4d33e28bdd1874625ee515785bf42c9fdff3658"}, + {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80fd94c076e1e1b1ee40a309be03080b75f413e8997cddcf401a118879863388"}, + {file = "Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c"}, + {file = "Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a"}, + {file = "Cython-3.0.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:870d2a0a7e3cbd5efa65aecdb38d715ea337a904ea7bb22324036e78fb7068e7"}, + {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8f2454128974905258d86534f4fd4f91d2f1343605657ecab779d80c9d6d5e"}, + {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1949d6aa7bc792554bee2b67a9fe41008acbfe22f4f8df7b6ec7b799613a4b3"}, + {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9f2c6e1b8f3bcd6cb230bac1843f85114780bb8be8614855b1628b36bb510e0"}, + {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:05d7eddc668ae7993643f32c7661f25544e791edb745758672ea5b1a82ecffa6"}, + {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bfabe115deef4ada5d23c87bddb11289123336dcc14347011832c07db616dd93"}, + {file = "Cython-3.0.8-cp36-cp36m-win32.whl", hash = "sha256:0c38c9f0bcce2df0c3347285863621be904ac6b64c5792d871130569d893efd7"}, + {file = "Cython-3.0.8-cp36-cp36m-win_amd64.whl", hash = "sha256:6c46939c3983217d140999de7c238c3141f56b1ea349e47ca49cae899969aa2c"}, + {file = "Cython-3.0.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:115f0a50f752da6c99941b103b5cb090da63eb206abbc7c2ad33856ffc73f064"}, + {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c0f29246734561c90f36e70ed0506b61aa3d044e4cc4cba559065a2a741fae"}, + {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab75242869ff71e5665fe5c96f3378e79e792fa3c11762641b6c5afbbbbe026"}, + {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6717c06e9cfc6c1df18543cd31a21f5d8e378a40f70c851fa2d34f0597037abc"}, + {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9d3f74388db378a3c6fd06e79a809ed98df3f56484d317b81ee762dbf3c263e0"}, + {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae7ac561fd8253a9ae96311e91d12af5f701383564edc11d6338a7b60b285a6f"}, + {file = "Cython-3.0.8-cp37-cp37m-win32.whl", hash = "sha256:97b2a45845b993304f1799664fa88da676ee19442b15fdcaa31f9da7e1acc434"}, + {file = "Cython-3.0.8-cp37-cp37m-win_amd64.whl", hash = "sha256:9e2be2b340fea46fb849d378f9b80d3c08ff2e81e2bfbcdb656e2e3cd8c6b2dc"}, + {file = "Cython-3.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2cde23c555470db3f149ede78b518e8274853745289c956a0e06ad8d982e4db9"}, + {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7990ca127e1f1beedaf8fc8bf66541d066ef4723ad7d8d47a7cbf842e0f47580"}, + {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b983c8e6803f016146c26854d9150ddad5662960c804ea7f0c752c9266752f0"}, + {file = "Cython-3.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a973268d7ca1a2bdf78575e459a94a78e1a0a9bb62a7db0c50041949a73b02ff"}, + {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:61a237bc9dd23c7faef0fcfce88c11c65d0c9bb73c74ccfa408b3a012073c20e"}, + {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3a3d67f079598af49e90ff9655bf85bd358f093d727eb21ca2708f467c489cae"}, + {file = "Cython-3.0.8-cp38-cp38-win32.whl", hash = "sha256:17a642bb01a693e34c914106566f59844b4461665066613913463a719e0dd15d"}, + {file = "Cython-3.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:2cdfc32252f3b6dc7c94032ab744dcedb45286733443c294d8f909a4854e7f83"}, + {file = "Cython-3.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa97893d99385386925d00074654aeae3a98867f298d1e12ceaf38a9054a9bae"}, + {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05c0bf9d085c031df8f583f0d506aa3be1692023de18c45d0aaf78685bbb944"}, + {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de892422582f5758bd8de187e98ac829330ec1007bc42c661f687792999988a7"}, + {file = "Cython-3.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:314f2355a1f1d06e3c431eaad4708cf10037b5e91e4b231d89c913989d0bdafd"}, + {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:78825a3774211e7d5089730f00cdf7f473042acc9ceb8b9eeebe13ed3a5541de"}, + {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:df8093deabc55f37028190cf5e575c26aad23fc673f34b85d5f45076bc37ce39"}, + {file = "Cython-3.0.8-cp39-cp39-win32.whl", hash = "sha256:1aca1b97e0095b3a9a6c33eada3f661a4ed0d499067d121239b193e5ba3bb4f0"}, + {file = "Cython-3.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:16873d78be63bd38ffb759da7ab82814b36f56c769ee02b1d5859560e4c3ac3c"}, + {file = "Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6"}, + {file = "Cython-3.0.8.tar.gz", hash = "sha256:8333423d8fd5765e7cceea3a9985dd1e0a5dfeb2734629e1a2ed2d6233d39de6"}, ] [[package]] @@ -192,26 +181,6 @@ files = [ {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, ] -[[package]] -name = "importlib-metadata" -version = "6.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, -] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -236,37 +205,33 @@ files = [ [[package]] name = "pluggy" -version = "1.2.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.4.3" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -288,7 +253,6 @@ files = [ [package.dependencies] pytest = ">=6.1.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -353,33 +317,7 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, -] - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] - [metadata] lock-version = "2.0" -python-versions = "^3.7" -content-hash = "5d7b707a062b320ee2930929c2b948e1e542f16eba9363175eaa09f09b111a02" +python-versions = "^3.8" +content-hash = "26c7f2ec91a34a0661a5511d2ade43511d80dd4f89e1aefbb59c9fafc2c92df2" diff --git a/pyproject.toml b/pyproject.toml index c711d9a63..2d8663275 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ build_command = "pip install poetry && poetry build" tag_format = "{version}" [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" async-timeout = {version = ">=3.0.0", python = "<3.11"} ifaddr = ">=0.1.7" @@ -151,7 +151,7 @@ ignore_errors = true [build-system] # 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505 -requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.5', "poetry-core>=1.5.2"] +requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.8', "poetry-core>=1.5.2"] build-backend = "poetry.core.masonry.api" [tool.codespell] diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index aa64306e1..73c60d3b6 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -156,8 +156,4 @@ # https://datatracker.ietf.org/doc/html/rfc6763#section-9 _SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local." -try: - _IPPROTO_IPV6 = socket.IPPROTO_IPV6 -except AttributeError: - # Sigh: https://bugs.python.org/issue29515 - _IPPROTO_IPV6 = 41 +_IPPROTO_IPV6 = socket.IPPROTO_IPV6 From 0108b5047bcbac0c49a5bdd801d2d4a59d488624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 13:14:46 -1000 Subject: [PATCH 1075/1433] chore: add test for parsing matter packet (#1364) --- tests/test_protocol.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index c830b6c3f..6990917a2 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1055,3 +1055,29 @@ def test_txt_after_invalid_nsec_name_still_usable(): b't=2\x0emdnssequence=0' ) assert len(parsed.answers()) == 5 + + +def test_parse_matter_packet(): + """Test our wire parser can handle a packet from matter.""" + packet_hex = ( + "000084000000000a00000000075f6d6174746572045f746370056c6f63" + "616c00000c000100001194002421413336303441463533314638364442" + "372d30303030303030303030303030303636c00cc00c000c0001000011" + "94002421333346353633363743453244333646302d3030303030303030" + "3444423341334541c00cc00c000c000100001194002421414531313941" + "304130374145304632302d34383742343631363639333638413332c00c" + "c00c000c00010000119400242141333630344146353331463836444237" + "2d30303030303030303030303030303237c00cc00c000c000100001194" + "002421413336303441463533314638364442372d303030303030303030" + "30303030303637c00cc00c000c00010000119400242133334635363336" + "3743453244333646302d30303030303030304243363637324136c00cc0" + "0c000c000100001194002421414531313941304130374145304632302d" + "39464534383646413645373730464433c00cc00c000c00010000119400" + "2421413336303441463533314638364442372d30303030303030303030" + "303030303434c00cc00c000c0001000011940024213935374431413839" + "44463239343033312d41423337393041444346434231423239c00cc00c" + "000c000100001194002421413336303441463533314638364442372d30" + "303030303030303030303030303638c00c" + ) + parsed = r.DNSIncoming(bytes.fromhex(packet_hex)) + assert len(parsed.answers()) == 10 From c4c2deeb05279ddbb0eba1330c7ae58795fea001 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Apr 2024 09:43:31 -1000 Subject: [PATCH 1076/1433] feat: make async_get_service_info available on the Zeroconf object (#1366) --- src/zeroconf/_core.py | 31 +++++++++++++++++++++++++++++-- src/zeroconf/_services/info.py | 16 ++++++++++++++++ src/zeroconf/asyncio.py | 19 +++++++++---------- tests/test_asyncio.py | 4 ++++ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 4b29717a7..cb488b4e8 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -39,7 +39,11 @@ from ._protocol.outgoing import DNSOutgoing from ._services import ServiceListener from ._services.browser import ServiceBrowser -from ._services.info import ServiceInfo, instance_name_from_service_info +from ._services.info import ( + AsyncServiceInfo, + ServiceInfo, + instance_name_from_service_info, +) from ._services.registry import ServiceRegistry from ._transport import _WrappedTransport from ._updates import RecordUpdateListener @@ -261,7 +265,13 @@ def get_service_info( ) -> Optional[ServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, - which defaults to 3 seconds.""" + which defaults to 3 seconds. + + :param type_: fully qualified service type name + :param name: the name of the service + :param timeout: milliseconds to wait for a response + :param question_type: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) + """ info = ServiceInfo(type_, name) if info.request(self, timeout, question_type): return info @@ -360,6 +370,23 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: self.registry.async_update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + async def async_get_service_info( + self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None + ) -> Optional[AsyncServiceInfo]: + """Returns network's service information for a particular + name and type, or None if no service matches by the timeout, + which defaults to 3 seconds. + + :param type_: fully qualified service type name + :param name: the name of the service + :param timeout: milliseconds to wait for a response + :param question_type: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) + """ + info = AsyncServiceInfo(type_, name) + if await info.async_request(self, timeout, question_type): + return info + return None + async def _async_broadcast_service( self, info: ServiceInfo, diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 48ad11405..6d68de838 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -770,6 +770,12 @@ def request( While it is not expected during normal operation, this function may raise EventLoopBlocked if the underlying call to `async_request` cannot be completed. + + :param zc: Zeroconf instance + :param timeout: time in milliseconds to wait for a response + :param question_type: question type to ask + :param addr: address to send the request to + :param port: port to send the request to """ assert zc.loop is not None and zc.loop.is_running() if zc.loop == get_running_loop(): @@ -803,6 +809,12 @@ async def async_request( mDNS multicast address and port. This is useful for directing requests to a specific host that may be able to respond across subnets. + + :param zc: Zeroconf instance + :param timeout: time in milliseconds to wait for a response + :param question_type: question type to ask + :param addr: address to send the request to + :param port: port to send the request to """ if not zc.started: await zc.async_wait_for_start() @@ -924,3 +936,7 @@ def __repr__(self) -> str: ) ), ) + + +class AsyncServiceInfo(ServiceInfo): + """An async version of ServiceInfo.""" diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index cfe3693e8..b2daeb10f 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -28,7 +28,7 @@ from ._dns import DNSQuestionType from ._services import ServiceListener from ._services.browser import _ServiceBrowserBase -from ._services.info import ServiceInfo +from ._services.info import AsyncServiceInfo, ServiceInfo from ._services.types import ZeroconfServiceTypes from ._utils.net import InterfaceChoice, InterfacesType, IPVersion from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME @@ -41,10 +41,6 @@ ] -class AsyncServiceInfo(ServiceInfo): - """An async version of ServiceInfo.""" - - class AsyncServiceBrowser(_ServiceBrowserBase): """Used to browse for a service for specific type(s). @@ -239,11 +235,14 @@ async def async_get_service_info( ) -> Optional[AsyncServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, - which defaults to 3 seconds.""" - info = AsyncServiceInfo(type_, name) - if await info.async_request(self.zeroconf, timeout, question_type): - return info - return None + which defaults to 3 seconds. + + :param type_: fully qualified service type name + :param name: the name of the service + :param timeout: milliseconds to wait for a response + :param question_type: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) + """ + return await self.zeroconf.async_get_service_info(type_, name, timeout, question_type) async def async_add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 63255158d..382b1a3d7 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -680,6 +680,10 @@ async def test_service_info_async_request() -> None: assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + aiosinfo = await aiozc.zeroconf.async_get_service_info(type_, registration_name) + assert aiosinfo is not None + assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] + aiosinfos = await asyncio.gather( aiozc.async_get_service_info(type_, registration_name), aiozc.async_get_service_info(type_, registration_name2), From edc4a556819956c238a11332052000dcbcb07e3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Apr 2024 09:43:36 -1000 Subject: [PATCH 1077/1433] fix: avoid including scope_id in IPv6Address object if its zero (#1367) --- src/zeroconf/_utils/ipaddress.py | 2 +- tests/utils/test_ipaddress.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index b0b551ff1..ba1379551 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -104,7 +104,7 @@ def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4 def get_ip_address_object_from_record(record: DNSAddress) -> Optional[Union[IPv4Address, IPv6Address]]: """Get the IP address object from the record.""" - if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id is not None: + if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id: return ip_bytes_and_scope_to_address(record.address, record.scope_id) return cached_ip_addresses_wrapper(record.address) diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 3ec1a9a77..73c5ab7e2 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -2,6 +2,12 @@ """Unit tests for zeroconf._utils.ipaddress.""" +import sys + +import pytest + +from zeroconf import const +from zeroconf._dns import DNSAddress from zeroconf._utils import ipaddress @@ -34,3 +40,34 @@ def test_cached_ip_addresses_wrapper(): assert ipv6 is not None assert ipv6.is_link_local is False assert ipv6.is_unspecified is True + + +@pytest.mark.skipif(sys.version_info < (3, 9, 0), reason='scope_id is not supported') +def test_get_ip_address_object_from_record(): + """Test the get_ip_address_object_from_record.""" + # not link local + packed = b'&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F' + record = DNSAddress( + 'domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=3 + ) + assert record.scope_id == 3 + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( + '2606:2800:220:1:248:1893:25c8:1946' + ) + + # link local + packed = b'\xfe\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01' + record = DNSAddress( + 'domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=3 + ) + assert record.scope_id == 3 + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1%3') + record = DNSAddress('domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) + assert record.scope_id is None + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1') + record = DNSAddress( + 'domain.local', const._TYPE_A, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=0 + ) + assert record.scope_id == 0 + # Ensure scope_id of 0 is not appended to the address + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1') From 0758c1e22e8686be85f214a46f482aa4b46da9e9 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 1 Apr 2024 19:55:41 +0000 Subject: [PATCH 1078/1433] 0.132.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2fbc0d6..905ab5e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ +## v0.132.0 (2024-04-01) + +### Feature + +* Make async_get_service_info available on the Zeroconf object ([#1366](https://github.com/python-zeroconf/python-zeroconf/issues/1366)) ([`c4c2dee`](https://github.com/python-zeroconf/python-zeroconf/commit/c4c2deeb05279ddbb0eba1330c7ae58795fea001)) +* Drop python 3.7 support ([#1359](https://github.com/python-zeroconf/python-zeroconf/issues/1359)) ([`4877829`](https://github.com/python-zeroconf/python-zeroconf/commit/4877829e6442de5426db152d11827b1ba85dbf59)) + +### Fix + +* Avoid including scope_id in IPv6Address object if its zero ([#1367](https://github.com/python-zeroconf/python-zeroconf/issues/1367)) ([`edc4a55`](https://github.com/python-zeroconf/python-zeroconf/commit/edc4a556819956c238a11332052000dcbcb07e3d)) + ## v0.131.0 (2023-12-19) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 2d8663275..67ed1d479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.131.0" +version = "0.132.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index e6b8e481d..ab80996f9 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.131.0' +__version__ = '0.132.0' __license__ = 'LGPL' From e9f8aa5741ae2d490c33a562b459f0af1014dbb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 11:47:46 -1000 Subject: [PATCH 1079/1433] fix: set change during iteration when dispatching listeners (#1370) --- src/zeroconf/_handlers/record_manager.py | 4 +- tests/test_handlers.py | 74 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 0a0f6c54b..70f2e5e11 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -56,7 +56,7 @@ def async_updates(self, now: _float, records: List[RecordUpdate]) -> None: This method will be run in the event loop. """ - for listener in self.listeners: + for listener in self.listeners.copy(): listener.async_update_records(self.zc, now, records) def async_updates_complete(self, notify: bool) -> None: @@ -67,7 +67,7 @@ def async_updates_complete(self, notify: bool) -> None: This method will be run in the event loop. """ - for listener in self.listeners: + for listener in self.listeners.copy(): listener.async_update_records_complete() if notify: self.zc.async_notify_all() diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 1a1066fa2..a13824e03 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1762,3 +1762,77 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor ) await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_updates_iteration_safe(): + """Ensure we can safely iterate over the async_updates.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc: Zeroconf = aiozc.zeroconf + updated = [] + good_bye_answer = r.DNSPointer( + "myservicelow_tcp._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 0, + 'goodbye.local.', + ) + + class OtherListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + """Update multiple records in one shot.""" + updated.extend(records) + + other = OtherListener() + + class ListenerThatAddsListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + """Update multiple records in one shot.""" + updated.extend(records) + zc.async_add_listener(other, None) + + zc.async_add_listener(ListenerThatAddsListener(), None) + await asyncio.sleep(0) # flush out any call soons + + # This should not raise RuntimeError: set changed size during iteration + zc.record_manager.async_updates( + now=current_time_millis(), records=[r.RecordUpdate(good_bye_answer, None)] + ) + + assert len(updated) == 1 + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_updates_complete_iteration_safe(): + """Ensure we can safely iterate over the async_updates_complete.""" + + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc: Zeroconf = aiozc.zeroconf + + class OtherListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records_complete(self) -> None: + """Update multiple records in one shot.""" + + other = OtherListener() + + class ListenerThatAddsListener(r.RecordUpdateListener): + """A RecordUpdateListener that does not implement update_records.""" + + def async_update_records_complete(self) -> None: + """Update multiple records in one shot.""" + zc.async_add_listener(other, None) + + zc.async_add_listener(ListenerThatAddsListener(), None) + await asyncio.sleep(0) # flush out any call soons + + # This should not raise RuntimeError: set changed size during iteration + zc.record_manager.async_updates_complete(False) + await aiozc.async_close() From 07742e68ef1c48e21f957f5f43cbcc11851c5216 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 12 Apr 2024 21:57:49 +0000 Subject: [PATCH 1080/1433] 0.132.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905ab5e2a..ca5f012b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.132.1 (2024-04-12) + +### Fix + +* Set change during iteration when dispatching listeners ([#1370](https://github.com/python-zeroconf/python-zeroconf/issues/1370)) ([`e9f8aa5`](https://github.com/python-zeroconf/python-zeroconf/commit/e9f8aa5741ae2d490c33a562b459f0af1014dbb0)) + ## v0.132.0 (2024-04-01) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 67ed1d479..04ad76bb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.132.0" +version = "0.132.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ab80996f9..0fcbdccd4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.132.0' +__version__ = '0.132.1' __license__ = 'LGPL' From 83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Apr 2024 14:29:39 -1000 Subject: [PATCH 1081/1433] fix: bump cibuildwheel to fix wheel builds (#1371) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00d3fe9bf..3ad892f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,7 @@ jobs: platforms: arm64 - name: Build wheels - uses: pypa/cibuildwheel@v2.16.2 + uses: pypa/cibuildwheel@v2.17.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 From 599524a5ce1e4c1731519dd89377c2a852e59935 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Fri, 12 Apr 2024 17:32:44 -0700 Subject: [PATCH 1082/1433] fix: update references to minimum-supported python version of 3.8 (#1369) --- README.rst | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 5cc5a91b7..eba4d7feb 100644 --- a/README.rst +++ b/README.rst @@ -45,8 +45,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.7+ -* PyPy3.7 7.3+ +* CPython 3.8+ +* PyPy3.8 7.3+ Versioning ---------- diff --git a/pyproject.toml b/pyproject.toml index 04ad76bb4..3acc77b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,11 @@ classifiers=[ 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] From 9d8dd27c75768663319c0ee610ba9d274799e32c Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 13 Apr 2024 00:41:48 +0000 Subject: [PATCH 1083/1433] 0.132.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5f012b2..a2026cbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ +## v0.132.2 (2024-04-13) + +### Fix + +* Update references to minimum-supported python version of 3.8 ([#1369](https://github.com/python-zeroconf/python-zeroconf/issues/1369)) ([`599524a`](https://github.com/python-zeroconf/python-zeroconf/commit/599524a5ce1e4c1731519dd89377c2a852e59935)) +* Bump cibuildwheel to fix wheel builds ([#1371](https://github.com/python-zeroconf/python-zeroconf/issues/1371)) ([`83e4ce3`](https://github.com/python-zeroconf/python-zeroconf/commit/83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210)) + ## v0.132.1 (2024-04-12) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 3acc77b93..1be7d81a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.132.1" +version = "0.132.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0fcbdccd4..4e6fb1574 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -85,7 +85,7 @@ __author__ = 'Paul Scott-Murphy, William McBrine' __maintainer__ = 'Jakub Stasiak ' -__version__ = '0.132.1' +__version__ = '0.132.2' __license__ = 'LGPL' From 0c68d711212a036e481332202bf46ae7cae69c3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jul 2024 17:29:38 -0500 Subject: [PATCH 1084/1433] chore: fix to ruff for lint and format (#1382) --- .pre-commit-config.yaml | 14 +- bench/create_destory.py | 5 +- bench/incoming.py | 5 +- bench/outgoing.py | 5 +- build_ext.py | 4 +- docs/conf.py | 36 +- examples/async_apple_scanner.py | 39 +- examples/async_browser.py | 31 +- examples/async_registration.py | 12 +- examples/async_service_info_request.py | 22 +- examples/browser.py | 30 +- examples/registration.py | 14 +- examples/resolver.py | 14 +- examples/self_test.py | 23 +- pyproject.toml | 2 +- src/zeroconf/__init__.py | 44 +- src/zeroconf/_cache.py | 74 +- src/zeroconf/_core.py | 155 +++-- src/zeroconf/_dns.py | 162 +++-- src/zeroconf/_engine.py | 87 ++- src/zeroconf/_exceptions.py | 40 +- src/zeroconf/_handlers/__init__.py | 32 +- src/zeroconf/_handlers/answers.py | 72 +- .../_handlers/multicast_outgoing_queue.py | 62 +- src/zeroconf/_handlers/query_handler.py | 150 +++-- src/zeroconf/_handlers/record_manager.py | 58 +- src/zeroconf/_history.py | 48 +- src/zeroconf/_listener.py | 93 +-- src/zeroconf/_logger.py | 48 +- src/zeroconf/_protocol/__init__.py | 32 +- src/zeroconf/_protocol/incoming.py | 147 ++-- src/zeroconf/_protocol/outgoing.py | 141 ++-- src/zeroconf/_record_update.py | 40 +- src/zeroconf/_services/__init__.py | 60 +- src/zeroconf/_services/browser.py | 272 +++++--- src/zeroconf/_services/info.py | 199 ++++-- src/zeroconf/_services/registry.py | 44 +- src/zeroconf/_services/types.py | 44 +- src/zeroconf/_transport.py | 44 +- src/zeroconf/_updates.py | 50 +- src/zeroconf/_utils/__init__.py | 32 +- src/zeroconf/_utils/asyncio.py | 60 +- src/zeroconf/_utils/ipaddress.py | 61 +- src/zeroconf/_utils/name.py | 89 +-- src/zeroconf/_utils/net.py | 199 ++++-- src/zeroconf/_utils/time.py | 41 +- src/zeroconf/asyncio.py | 76 ++- src/zeroconf/const.py | 62 +- tests/__init__.py | 48 +- tests/conftest.py | 2 +- tests/services/__init__.py | 32 +- tests/services/test_browser.py | 444 ++++++++---- tests/services/test_info.py | 626 +++++++++++------ tests/services/test_registry.py | 64 +- tests/services/test_types.py | 34 +- tests/test_asyncio.py | 251 ++++--- tests/test_cache.py | 202 ++++-- tests/test_core.py | 254 ++++--- tests/test_dns.py | 284 ++++++-- tests/test_engine.py | 46 +- tests/test_exceptions.py | 119 ++-- tests/test_handlers.py | 633 +++++++++++++----- tests/test_history.py | 14 +- tests/test_init.py | 39 +- tests/test_listener.py | 89 ++- tests/test_logger.py | 12 +- tests/test_protocol.py | 292 ++++---- tests/test_services.py | 78 ++- tests/test_updates.py | 38 +- tests/utils/__init__.py | 32 +- tests/utils/test_asyncio.py | 10 +- tests/utils/test_ipaddress.py | 71 +- tests/utils/test_name.py | 56 +- tests/utils/test_net.py | 100 ++- 74 files changed, 4442 insertions(+), 2502 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7ae9294c..e4a882037 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,20 +38,18 @@ repos: hooks: - id: pyupgrade args: [--py37-plus] - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 hooks: - - id: isort - - repo: https://github.com/psf/black - rev: 22.8.0 - hooks: - - id: black + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format # - repo: https://github.com/codespell-project/codespell # rev: v2.2.1 # hooks: # - id: codespell - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 7.1.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/bench/create_destory.py b/bench/create_destory.py index f1941423c..77d8af6f0 100644 --- a/bench/create_destory.py +++ b/bench/create_destory.py @@ -1,4 +1,5 @@ """Benchmark for AsyncZeroconf.""" + import asyncio import time @@ -17,7 +18,9 @@ async def _run() -> None: start = time.perf_counter() await _create_destroy(iterations) duration = time.perf_counter() - start - print(f"Creating and destroying {iterations} Zeroconf instances took {duration} seconds") + print( + f"Creating and destroying {iterations} Zeroconf instances took {duration} seconds" + ) asyncio.run(_run()) diff --git a/bench/incoming.py b/bench/incoming.py index 233f19e94..d0cc3588e 100644 --- a/bench/incoming.py +++ b/bench/incoming.py @@ -1,4 +1,5 @@ """Benchmark for DNSIncoming.""" + import socket import timeit from typing import List @@ -121,8 +122,8 @@ def generate_packets() -> List[bytes]: const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) diff --git a/bench/outgoing.py b/bench/outgoing.py index d832a05b4..8c8097cbf 100644 --- a/bench/outgoing.py +++ b/bench/outgoing.py @@ -1,4 +1,5 @@ """Benchmark for DNSOutgoing.""" + import socket import timeit @@ -113,8 +114,8 @@ def generate_packets() -> DNSOutgoing: const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) diff --git a/build_ext.py b/build_ext.py index 0f02f53a4..4fecbdf11 100644 --- a/build_ext.py +++ b/build_ext.py @@ -47,7 +47,9 @@ def build(setup_kwargs: Any) -> None: cmdclass=dict(build_ext=BuildExt), ) ) - setup_kwargs["exclude_package_data"] = {pkg: ["*.c"] for pkg in setup_kwargs["packages"]} + setup_kwargs["exclude_package_data"] = { + pkg: ["*.c"] for pkg in setup_kwargs["packages"] + } except Exception: if os.environ.get("REQUIRE_CYTHON"): raise diff --git a/docs/conf.py b/docs/conf.py index afaa510e0..b3ad57eaa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,23 +23,23 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'python-zeroconf' -copyright = 'python-zeroconf authors' +project = "python-zeroconf" +copyright = "python-zeroconf authors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -62,7 +62,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None @@ -79,7 +79,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -92,7 +92,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -121,7 +121,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -133,8 +133,8 @@ # Custom sidebar templates, maps document names to template names. html_sidebars = { - 'index': ('sidebar.html', 'sourcelink.html', 'searchbox.html'), - '**': ('localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'), + "index": ("sidebar.html", "sourcelink.html", "searchbox.html"), + "**": ("localtoc.html", "relations.html", "sourcelink.html", "searchbox.html"), } # Additional templates that should be rendered to pages, maps page names to @@ -168,7 +168,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'zeroconfdoc' +htmlhelp_basename = "zeroconfdoc" # -- Options for LaTeX output -------------------------------------------------- @@ -231,17 +231,17 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +intersphinx_mapping = {"http://docs.python.org/": None} def setup(app): # type: ignore[no-untyped-def] - app.connect('autodoc-skip-member', skip_member) + app.connect("autodoc-skip-member", skip_member) def skip_member(app, what, name, obj, skip, options): # type: ignore[no-untyped-def] return ( skip - or getattr(obj, '__doc__', None) is None - or getattr(obj, '__private__', False) is True - or getattr(getattr(obj, '__func__', None), '__private__', False) is True + or getattr(obj, "__doc__", None) is None + or getattr(obj, "__private__", False) is True + or getattr(getattr(obj, "__func__", None), "__private__", False) is True ) diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index ff558f82e..ed549e017 100644 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Scan for apple devices. """ +"""Scan for apple devices.""" import argparse import asyncio @@ -43,15 +43,21 @@ def async_on_service_state_change( device_name = f"{base_name}.{DEVICE_INFO_SERVICE}" asyncio.ensure_future(_async_show_service_info(zeroconf, service_type, name)) # Also probe for device info - asyncio.ensure_future(_async_show_service_info(zeroconf, DEVICE_INFO_SERVICE, device_name)) + asyncio.ensure_future( + _async_show_service_info(zeroconf, DEVICE_INFO_SERVICE, device_name) + ) -async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: +async def _async_show_service_info( + zeroconf: Zeroconf, service_type: str, name: str +) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + addresses = [ + "%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses() + ] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) @@ -64,7 +70,7 @@ async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: print(" No properties") else: print(" No info") - print('\n') + print("\n") class AsyncAppleScanner: @@ -77,10 +83,17 @@ async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=ip_version) await self.aiozc.zeroconf.async_wait_for_start() print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % ALL_SERVICES) - kwargs = {'handlers': [async_on_service_state_change], 'question_type': DNSQuestionType.QU} + kwargs = { + "handlers": [async_on_service_state_change], + "question_type": DNSQuestionType.QU, + } if self.args.target: kwargs["addr"] = self.args.target - self.aiobrowser = AsyncServiceBrowser(self.aiozc.zeroconf, ALL_SERVICES, **kwargs) # type: ignore + self.aiobrowser = AsyncServiceBrowser( + self.aiozc.zeroconf, + ALL_SERVICES, + **kwargs, # type: ignore[arg-type] + ) while True: await asyncio.sleep(1) @@ -91,19 +104,19 @@ async def async_close(self) -> None: await self.aiozc.async_close() -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') + parser.add_argument("--debug", action="store_true") version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--target', help='Unicast target') - version_group.add_argument('--v6', action='store_true') - version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument("--target", help="Unicast target") + version_group.add_argument("--v6", action="store_true") + version_group.add_argument("--v6-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6: ip_version = IPVersion.All elif args.v6_only: diff --git a/examples/async_browser.py b/examples/async_browser.py index f7fb71514..cd4c77860 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Example of browsing for a service. +"""Example of browsing for a service. The default is HTTP and HAP; use --find to search for all available services in the network """ @@ -28,12 +28,17 @@ def async_on_service_state_change( asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) -async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: +async def async_display_service_info( + zeroconf: Zeroconf, service_type: str, name: str +) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] + addresses = [ + "%s:%d" % (addr, cast(int, info.port)) + for addr in info.parsed_scoped_addresses() + ] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) @@ -46,7 +51,7 @@ async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name print(" No properties") else: print(" No info") - print('\n') + print("\n") class AsyncRunner: @@ -61,7 +66,9 @@ async def async_run(self) -> None: services = ["_http._tcp.local.", "_hap._tcp.local."] if self.args.find: services = list( - await AsyncZeroconfServiceTypes.async_find(aiozc=self.aiozc, ip_version=ip_version) + await AsyncZeroconfServiceTypes.async_find( + aiozc=self.aiozc, ip_version=ip_version + ) ) print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) @@ -78,19 +85,21 @@ async def async_close(self) -> None: await self.aiozc.async_close() -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') - parser.add_argument('--find', action='store_true', help='Browse all available services') + parser.add_argument("--debug", action="store_true") + parser.add_argument( + "--find", action="store_true", help="Browse all available services" + ) version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6', action='store_true') - version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument("--v6", action="store_true") + version_group.add_argument("--v6-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6: ip_version = IPVersion.All elif args.v6_only: diff --git a/examples/async_registration.py b/examples/async_registration.py index c3aab326a..a75b5566a 100644 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -33,18 +33,18 @@ async def unregister_services(self, infos: List[AsyncServiceInfo]) -> None: await self.aiozc.async_close() -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') + parser.add_argument("--debug", action="store_true") version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6', action='store_true') - version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument("--v6", action="store_true") + version_group.add_argument("--v6-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6: ip_version = IPVersion.All elif args.v6_only: @@ -60,7 +60,7 @@ async def unregister_services(self, infos: List[AsyncServiceInfo]) -> None: f"Paul's Test Web Site {i}._http._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=80, - properties={'path': '/~paulsm/'}, + properties={"path": "/~paulsm/"}, server=f"zcdemohost-{i}.local.", ) ) diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 5bb247618..fca58745e 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -31,7 +31,10 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: for info in infos: print("Info for %s" % (info.name)) if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] + addresses = [ + "%s:%d" % (addr, cast(int, info.port)) + for addr in info.parsed_addresses() + ] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(f" Server: {info.server}") @@ -43,7 +46,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: print(" No properties") else: print(" No info") - print('\n') + print("\n") class AsyncRunner: @@ -57,7 +60,10 @@ async def async_run(self) -> None: assert self.aiozc is not None def on_service_state_change( - zeroconf: Zeroconf, service_type: str, state_change: ServiceStateChange, name: str + zeroconf: Zeroconf, + service_type: str, + state_change: ServiceStateChange, + name: str, ) -> None: """Dummy handler.""" @@ -73,18 +79,18 @@ async def async_close(self) -> None: await self.aiozc.async_close() -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') + parser.add_argument("--debug", action="store_true") version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6', action='store_true') - version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument("--v6", action="store_true") + version_group.add_argument("--v6-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6: ip_version = IPVersion.All elif args.v6_only: diff --git a/examples/browser.py b/examples/browser.py index 237de013f..1a801a445 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Example of browsing for a service. +"""Example of browsing for a service. The default is HTTP and HAP; use --find to search for all available services in the network """ @@ -29,7 +29,10 @@ def on_service_state_change( print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] + addresses = [ + "%s:%d" % (addr, cast(int, info.port)) + for addr in info.parsed_scoped_addresses() + ] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(f" Server: {info.server}") @@ -41,22 +44,24 @@ def on_service_state_change( print(" No properties") else: print(" No info") - print('\n') + print("\n") -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') - parser.add_argument('--find', action='store_true', help='Browse all available services') + parser.add_argument("--debug", action="store_true") + parser.add_argument( + "--find", action="store_true", help="Browse all available services" + ) version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6-only', action='store_true') - version_group.add_argument('--v4-only', action='store_true') + version_group.add_argument("--v6-only", action="store_true") + version_group.add_argument("--v4-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6_only: ip_version = IPVersion.V6Only elif args.v4_only: @@ -66,7 +71,12 @@ def on_service_state_change( zeroconf = Zeroconf(ip_version=ip_version) - services = ["_http._tcp.local.", "_hap._tcp.local.", "_esphomelib._tcp.local.", "_airplay._tcp.local."] + services = [ + "_http._tcp.local.", + "_hap._tcp.local.", + "_esphomelib._tcp.local.", + "_airplay._tcp.local.", + ] if args.find: services = list(ZeroconfServiceTypes.find(zc=zeroconf)) diff --git a/examples/registration.py b/examples/registration.py index 65c221996..5be9f45d7 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -""" Example of announcing a service (in this case, a fake HTTP server) """ +"""Example of announcing a service (in this case, a fake HTTP server)""" import argparse import logging @@ -9,18 +9,18 @@ from zeroconf import IPVersion, ServiceInfo, Zeroconf -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) parser = argparse.ArgumentParser() - parser.add_argument('--debug', action='store_true') + parser.add_argument("--debug", action="store_true") version_group = parser.add_mutually_exclusive_group() - version_group.add_argument('--v6', action='store_true') - version_group.add_argument('--v6-only', action='store_true') + version_group.add_argument("--v6", action="store_true") + version_group.add_argument("--v6-only", action="store_true") args = parser.parse_args() if args.debug: - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) if args.v6: ip_version = IPVersion.All elif args.v6_only: @@ -28,7 +28,7 @@ else: ip_version = IPVersion.V4Only - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( "_http._tcp.local.", diff --git a/examples/resolver.py b/examples/resolver.py index 6a550fcb2..e7a11f820 100755 --- a/examples/resolver.py +++ b/examples/resolver.py @@ -1,24 +1,24 @@ #!/usr/bin/env python3 -""" Example of resolving a service with a known name """ +"""Example of resolving a service with a known name""" import logging import sys from zeroconf import Zeroconf -TYPE = '_test._tcp.local.' -NAME = 'My Service Name' +TYPE = "_test._tcp.local." +NAME = "My Service Name" -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) if len(sys.argv) > 1: - assert sys.argv[1:] == ['--debug'] - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + assert sys.argv[1:] == ["--debug"] + logging.getLogger("zeroconf").setLevel(logging.DEBUG) zeroconf = Zeroconf() try: - print(zeroconf.get_service_info(TYPE, NAME + '.' + TYPE)) + print(zeroconf.get_service_info(TYPE, NAME + "." + TYPE)) finally: zeroconf.close() diff --git a/examples/self_test.py b/examples/self_test.py index 2178629b5..63aca4f35 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -6,23 +6,23 @@ from zeroconf import ServiceInfo, Zeroconf, __version__ -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) if len(sys.argv) > 1: - assert sys.argv[1:] == ['--debug'] - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + assert sys.argv[1:] == ["--debug"] + logging.getLogger("zeroconf").setLevel(logging.DEBUG) # Test a few module features, including service registration, service # query (for Zoe), and service unregistration. print(f"Multicast DNS Service Discovery for Python, version {__version__}") r = Zeroconf() print("1. Testing registration of a service...") - desc = {'version': '0.10', 'a': 'test value', 'b': 'another value'} + desc = {"version": "0.10", "a": "test value", "b": "another value"} addresses = [socket.inet_aton("127.0.0.1")] - expected = {'127.0.0.1'} + expected = {"127.0.0.1"} if socket.has_ipv6: - addresses.append(socket.inet_pton(socket.AF_INET6, '::1')) - expected.add('::1') + addresses.append(socket.inet_pton(socket.AF_INET6, "::1")) + expected.add("::1") info = ServiceInfo( "_http._tcp.local.", "My Service Name._http._tcp.local.", @@ -34,10 +34,15 @@ r.register_service(info) print(" Registration done.") print("2. Testing query of service information...") - print(" Getting ZOE service: %s" % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local."))) + print( + " Getting ZOE service: %s" + % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local.")) + ) print(" Query done.") print("3. Testing query of own service...") - queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") + queried_info = r.get_service_info( + "_http._tcp.local.", "My Service Name._http._tcp.local." + ) assert queried_info assert set(queried_info.parsed_addresses()) == expected print(f" Getting self: {queried_info}") diff --git a/pyproject.toml b/pyproject.toml index 1be7d81a9..1d88efbde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,7 +130,7 @@ mypy_path = "src/" no_implicit_optional = true show_error_codes = true warn_unreachable = true -warn_unused_ignores = true +warn_unused_ignores = false exclude = [ 'docs/*', 'bench/*', diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 4e6fb1574..0c89a8816 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import sys @@ -83,10 +83,10 @@ millis_to_seconds, ) -__author__ = 'Paul Scott-Murphy, William McBrine' -__maintainer__ = 'Jakub Stasiak ' -__version__ = '0.132.2' -__license__ = 'LGPL' +__author__ = "Paul Scott-Murphy, William McBrine" +__maintainer__ = "Jakub Stasiak " +__version__ = "0.132.2" +__license__ = "LGPL" __all__ = [ @@ -117,9 +117,9 @@ if sys.version_info <= (3, 6): # pragma: no cover raise ImportError( # pragma: no cover - ''' + """ Python version > 3.6 required for python-zeroconf. If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 If you need support for Python 3.5 please use version 0.28.0 - ''' + """ ) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 35a13cf64..809be9c1b 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast @@ -119,7 +119,12 @@ def async_expire(self, now: _float) -> List[DNSRecord]: This function must be run in from event loop. """ - expired = [record for records in self.cache.values() for record in records if record.is_expired(now)] + expired = [ + record + for records in self.cache.values() + for record in records + if record.is_expired(now) + ] self.async_remove_records(expired) return expired @@ -135,7 +140,9 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: + def async_all_by_details( + self, name: _str, type_: _int, class_: _int + ) -> List[DNSRecord]: """Gets all matching entries by details. This function is not thread-safe and must be called from @@ -181,7 +188,9 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: return cached_entry return None - def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRecord]: + def get_by_details( + self, name: str, type_: _int, class_: _int + ) -> Optional[DNSRecord]: """Gets the first matching entry by details. Returns None if no entries match. Calling this function is not recommended as it will only @@ -202,13 +211,19 @@ def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRe return cached_entry return None - def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRecord]: + def get_all_by_details( + self, name: str, type_: _int, class_: _int + ) -> List[DNSRecord]: """Gets all matching entries by details.""" key = name.lower() records = self.cache.get(key) if records is None: return [] - return [entry for entry in list(records) if type_ == entry.type and class_ == entry.class_] + return [ + entry + for entry in list(records) + if type_ == entry.type and class_ == entry.class_ + ] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" @@ -218,7 +233,9 @@ def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" return list(self.cache.get(name.lower(), [])) - def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: + def current_entry_with_name_and_alias( + self, name: str, alias: str + ) -> Optional[DNSRecord]: now = current_time_millis() for record in reversed(self.entries_with_name(name)): if ( @@ -234,7 +251,10 @@ def names(self) -> List[str]: return list(self.cache) def async_mark_unique_records_older_than_1s_to_expire( - self, unique_types: Set[Tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float + self, + unique_types: Set[Tuple[_str, _int, _int]], + answers: Iterable[DNSRecord], + now: _float, ) -> None: # rfc6762#section-10.2 para 2 # Since unique is set, all old records with that name, rrtype, diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index cb488b4e8..5386df634 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -121,7 +121,7 @@ def async_send_with_transport( return if log_debug: log.debug( - 'Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...', + "Sending to (%s, %d) via [socket %s (%s)] (%d bytes #%d) %r as %r...", real_addr, port or _MDNS_PORT, transport.fileno, @@ -140,7 +140,6 @@ def async_send_with_transport( class Zeroconf(QuietLogger): - """Implementation of Zeroconf Multicast DNS Service Discovery Supports registration, unregistration, queries and browsing. @@ -173,12 +172,18 @@ def __init__( self.done = False - if apple_p2p and sys.platform != 'darwin': - raise RuntimeError('Option `apple_p2p` is not supported on non-Apple platforms.') + if apple_p2p and sys.platform != "darwin": + raise RuntimeError( + "Option `apple_p2p` is not supported on non-Apple platforms." + ) self.unicast = unicast - listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) - log.debug('Listen socket %s, respond sockets %s', listen_socket, respond_sockets) + listen_socket, respond_sockets = create_sockets( + interfaces, unicast, ip_version, apple_p2p=apple_p2p + ) + log.debug( + "Listen socket %s, respond sockets %s", listen_socket, respond_sockets + ) self.engine = AsyncEngine(self, listen_socket, respond_sockets) @@ -188,7 +193,9 @@ def __init__( self.question_history = QuestionHistory() self.out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) - self.out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) + self.out_delay_queue = MulticastOutgoingQueue( + self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY + ) self.query_handler = QueryHandler(self) self.record_manager = RecordManager(self) @@ -202,7 +209,11 @@ def __init__( @property def started(self) -> bool: """Check if the instance has started.""" - return bool(not self.done and self.engine.running_event and self.engine.running_event.is_set()) + return bool( + not self.done + and self.engine.running_event + and self.engine.running_event.is_set() + ) def start(self) -> None: """Start Zeroconf.""" @@ -261,7 +272,11 @@ def async_notify_all(self) -> None: _resolve_all_futures_to_none(notify_futures) def get_service_info( - self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None + self, + type_: str, + name: str, + timeout: int = 3000, + question_type: Optional[DNSQuestionType] = None, ) -> Optional[ServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, @@ -317,7 +332,9 @@ def register_service( assert self.loop is not None run_coro_with_timeout( await_awaitable( - self.async_register_service(info, ttl, allow_name_change, cooperating_responders, strict) + self.async_register_service( + info, ttl, allow_name_change, cooperating_responders, strict + ) ), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS, @@ -345,9 +362,13 @@ async def async_register_service( info.set_server_if_missing() await self.async_wait_for_start() - await self.async_check_service(info, allow_name_change, cooperating_responders, strict) + await self.async_check_service( + info, allow_name_change, cooperating_responders, strict + ) self.registry.async_add(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return asyncio.ensure_future( + self._async_broadcast_service(info, _REGISTER_TIME, None) + ) def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. @@ -360,7 +381,9 @@ def update_service(self, info: ServiceInfo) -> None: """ assert self.loop is not None run_coro_with_timeout( - await_awaitable(self.async_update_service(info)), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS + await_awaitable(self.async_update_service(info)), + self.loop, + _REGISTER_TIME * _REGISTER_BROADCASTS, ) async def async_update_service(self, info: ServiceInfo) -> Awaitable: @@ -368,10 +391,16 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: Zeroconf will then respond to requests for information for that service.""" self.registry.async_update(info) - return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + return asyncio.ensure_future( + self._async_broadcast_service(info, _REGISTER_TIME, None) + ) async def async_get_service_info( - self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None + self, + type_: str, + name: str, + timeout: int = 3000, + question_type: Optional[DNSQuestionType] = None, ) -> Optional[AsyncServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, @@ -398,7 +427,9 @@ async def _async_broadcast_service( for i in range(_REGISTER_BROADCASTS): if i != 0: await asyncio.sleep(millis_to_seconds(interval)) - self.async_send(self.generate_service_broadcast(info, ttl, broadcast_addresses)) + self.async_send( + self.generate_service_broadcast(info, ttl, broadcast_addresses) + ) def generate_service_broadcast( self, @@ -453,7 +484,9 @@ def unregister_service(self, info: ServiceInfo) -> None: """ assert self.loop is not None run_coro_with_timeout( - self.async_unregister_service(info), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS + self.async_unregister_service(info), + self.loop, + _UNREGISTER_TIME * _REGISTER_BROADCASTS, ) async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: @@ -467,7 +500,9 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: entries = self.registry.async_get_infos_server(info.server_key) broadcast_addresses = not bool(entries) return asyncio.ensure_future( - self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) + self._async_broadcast_service( + info, _UNREGISTER_TIME, 0, broadcast_addresses + ) ) def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: @@ -506,7 +541,9 @@ def unregister_all_services(self) -> None: """ assert self.loop is not None run_coro_with_timeout( - self.async_unregister_all_services(), self.loop, _UNREGISTER_TIME * _REGISTER_BROADCASTS + self.async_unregister_all_services(), + self.loop, + _UNREGISTER_TIME * _REGISTER_BROADCASTS, ) async def async_check_service( @@ -531,7 +568,7 @@ async def async_check_service( raise NonUniqueNameException # change the name and look for a conflict - info.name = f'{instance_name}-{next_instance_number}.{info.type}' + info.name = f"{instance_name}-{next_instance_number}.{info.type}" next_instance_number += 1 service_type_name(info.name, strict=strict) next_time = now @@ -547,7 +584,9 @@ async def async_check_service( next_time += _CHECK_TIME def add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + self, + listener: RecordUpdateListener, + question: Optional[Union[DNSQuestion, List[DNSQuestion]]], ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -556,7 +595,9 @@ def add_listener( This function is threadsafe """ assert self.loop is not None - self.loop.call_soon_threadsafe(self.record_manager.async_add_listener, listener, question) + self.loop.call_soon_threadsafe( + self.record_manager.async_add_listener, listener, question + ) def remove_listener(self, listener: RecordUpdateListener) -> None: """Removes a listener. @@ -564,10 +605,14 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: This function is threadsafe """ assert self.loop is not None - self.loop.call_soon_threadsafe(self.record_manager.async_remove_listener, listener) + self.loop.call_soon_threadsafe( + self.record_manager.async_remove_listener, listener + ) def async_add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + self, + listener: RecordUpdateListener, + question: Optional[Union[DNSQuestion, List[DNSQuestion]]], ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -594,7 +639,9 @@ def send( ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None - self.loop.call_soon_threadsafe(self.async_send, out, addr, port, v6_flow_scope, transport) + self.loop.call_soon_threadsafe( + self.async_send, out, addr, port, v6_flow_scope, transport + ) def async_send( self, @@ -615,11 +662,23 @@ def async_send( for packet_num, packet in enumerate(out.packets()): if len(packet) > _MAX_MSG_ABSOLUTE: - self.log_warning_once("Dropping %r over-sized packet (%d bytes) %r", out, len(packet), packet) + self.log_warning_once( + "Dropping %r over-sized packet (%d bytes) %r", + out, + len(packet), + packet, + ) return for send_transport in transports: async_send_with_transport( - log_debug, send_transport, packet, packet_num, out, addr, port, v6_flow_scope + log_debug, + send_transport, + packet, + packet_num, + out, + addr, + port, + v6_flow_scope, ) def _close(self) -> None: @@ -672,7 +731,7 @@ async def _async_close(self) -> None: await self.engine._async_close() # pylint: disable=protected-access self._shutdown_threads() - def __enter__(self) -> 'Zeroconf': + def __enter__(self) -> "Zeroconf": return self def __exit__( # pylint: disable=useless-return diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 66fb5b86d..f85969a91 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import enum @@ -33,7 +33,9 @@ _LEN_SHORT = 2 _LEN_INT = 4 -_BASE_MAX_SIZE = _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT # type # class # ttl # length +_BASE_MAX_SIZE = ( + _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT +) # type # class # ttl # length _NAME_COMPRESSION_MIN_SIZE = _LEN_BYTE * 2 _EXPIRE_FULL_TIME_MS = 1000 @@ -62,10 +64,9 @@ class DNSQuestionType(enum.Enum): class DNSEntry: - """A DNS entry""" - __slots__ = ('key', 'name', 'type', 'class_', 'unique') + __slots__ = ("key", "name", "type", "class_", "unique") def __init__(self, name: str, type_: int, class_: int) -> None: self.name = name @@ -78,7 +79,11 @@ def _set_class(self, class_: _int) -> None: self.unique = (class_ & _CLASS_UNIQUE) != 0 def _dns_entry_matches(self, other) -> bool: # type: ignore[no-untyped-def] - return self.key == other.key and self.type == other.type and self.class_ == other.class_ + return ( + self.key == other.key + and self.type == other.type + and self.class_ == other.class_ + ) def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" @@ -107,18 +112,21 @@ def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: class DNSQuestion(DNSEntry): - """A DNS question entry""" - __slots__ = ('_hash',) + __slots__ = ("_hash",) def __init__(self, name: str, type_: int, class_: int) -> None: super().__init__(name, type_, class_) self._hash = hash((self.key, type_, self.class_)) - def answered_by(self, rec: 'DNSRecord') -> bool: + def answered_by(self, rec: "DNSRecord") -> bool: """Returns true if the question is answered by the record""" - return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name + return ( + self.class_ == rec.class_ + and self.type in (rec.type, _TYPE_ANY) + and self.name == rec.name + ) def __hash__(self) -> int: return self._hash @@ -130,7 +138,9 @@ def __eq__(self, other: Any) -> bool: @property def max_size(self) -> int: """Maximum size of the question in the packet.""" - return len(self.name.encode('utf-8')) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT # type # class + return ( + len(self.name.encode("utf-8")) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT + ) # type # class @property def unicast(self) -> bool: @@ -157,14 +167,18 @@ def __repr__(self) -> str: class DNSRecord(DNSEntry): - """A DNS record - like a DNS entry, but has a TTL""" - __slots__ = ('ttl', 'created') + __slots__ = ("ttl", "created") # TODO: Switch to just int ttl def __init__( - self, name: str, type_: int, class_: int, ttl: Union[float, int], created: Optional[float] = None + self, + name: str, + type_: int, + class_: int, + ttl: Union[float, int], + created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_) self.ttl = ttl @@ -174,7 +188,7 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException - def suppressed_by(self, msg: 'DNSIncoming') -> bool: + def suppressed_by(self, msg: "DNSIncoming") -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" answers = msg.answers() @@ -221,7 +235,7 @@ def set_created_ttl(self, created: _float, ttl: Union[float, int]) -> None: self.created = created self.ttl = ttl - def write(self, out: 'DNSOutgoing') -> None: # pylint: disable=no-self-use + def write(self, out: "DNSOutgoing") -> None: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException @@ -232,10 +246,9 @@ def to_string(self, other: Union[bytes, str]) -> str: class DNSAddress(DNSRecord): - """A DNS address record""" - __slots__ = ('_hash', 'address', 'scope_id') + __slots__ = ("_hash", "address", "scope_id") def __init__( self, @@ -252,7 +265,7 @@ def __init__( self.scope_id = scope_id self._hash = hash((self.key, type_, self.class_, address, scope_id)) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" out.write_string(self.address) @@ -276,7 +289,8 @@ def __repr__(self) -> str: try: return self.to_string( socket.inet_ntop( - socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address + socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, + self.address, ) ) except (ValueError, OSError): @@ -284,23 +298,29 @@ def __repr__(self) -> str: class DNSHinfo(DNSRecord): - """A DNS host information record""" - __slots__ = ('_hash', 'cpu', 'os') + __slots__ = ("_hash", "cpu", "os") def __init__( - self, name: str, type_: int, class_: int, ttl: int, cpu: str, os: str, created: Optional[float] = None + self, + name: str, + type_: int, + class_: int, + ttl: int, + cpu: str, + os: str, + created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_, ttl, created) self.cpu = cpu self.os = os self._hash = hash((self.key, type_, self.class_, cpu, os)) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" - out.write_character_string(self.cpu.encode('utf-8')) - out.write_character_string(self.os.encode('utf-8')) + out.write_character_string(self.cpu.encode("utf-8")) + out.write_character_string(self.os.encode("utf-8")) def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os.""" @@ -308,7 +328,11 @@ def __eq__(self, other: Any) -> bool: def _eq(self, other) -> bool: # type: ignore[no-untyped-def] """Tests equality on cpu and os.""" - return self.cpu == other.cpu and self.os == other.os and self._dns_entry_matches(other) + return ( + self.cpu == other.cpu + and self.os == other.os + and self._dns_entry_matches(other) + ) def __hash__(self) -> int: """Hash to compare like DNSHinfo.""" @@ -320,13 +344,18 @@ def __repr__(self) -> str: class DNSPointer(DNSRecord): - """A DNS pointer record""" - __slots__ = ('_hash', 'alias', 'alias_key') + __slots__ = ("_hash", "alias", "alias_key") def __init__( - self, name: str, type_: int, class_: int, ttl: int, alias: str, created: Optional[float] = None + self, + name: str, + type_: int, + class_: int, + ttl: int, + alias: str, + created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_, ttl, created) self.alias = alias @@ -343,7 +372,7 @@ def max_size_compressed(self) -> int: + _NAME_COMPRESSION_MIN_SIZE ) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" out.write_name(self.alias) @@ -365,19 +394,24 @@ def __repr__(self) -> str: class DNSText(DNSRecord): - """A DNS text record""" - __slots__ = ('_hash', 'text') + __slots__ = ("_hash", "text") def __init__( - self, name: str, type_: int, class_: int, ttl: int, text: bytes, created: Optional[float] = None + self, + name: str, + type_: int, + class_: int, + ttl: int, + text: bytes, + created: Optional[float] = None, ) -> None: super().__init__(name, type_, class_, ttl, created) self.text = text self._hash = hash((self.key, type_, self.class_, text)) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" out.write_string(self.text) @@ -401,10 +435,9 @@ def __repr__(self) -> str: class DNSService(DNSRecord): - """A DNS service record""" - __slots__ = ('_hash', 'priority', 'weight', 'port', 'server', 'server_key') + __slots__ = ("_hash", "priority", "weight", "port", "server", "server_key") def __init__( self, @@ -424,9 +457,11 @@ def __init__( self.port = port self.server = server self.server_key = server.lower() - self._hash = hash((self.key, type_, self.class_, priority, weight, port, self.server_key)) + self._hash = hash( + (self.key, type_, self.class_, priority, weight, port, self.server_key) + ) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" out.write_short(self.priority) out.write_short(self.weight) @@ -457,10 +492,9 @@ def __repr__(self) -> str: class DNSNsec(DNSRecord): - """A DNS NSEC record""" - __slots__ = ('_hash', 'next_name', 'rdtypes') + __slots__ = ("_hash", "next_name", "rdtypes") def __init__( self, @@ -477,9 +511,9 @@ def __init__( self.rdtypes = sorted(rdtypes) self._hash = hash((self.key, type_, self.class_, next_name, *self.rdtypes)) - def write(self, out: 'DNSOutgoing') -> None: + def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet.""" - bitmap = bytearray(b'\0' * 32) + bitmap = bytearray(b"\0" * 32) total_octets = 0 for rdtype in self.rdtypes: if rdtype > 255: # mDNS only supports window 0 @@ -516,7 +550,9 @@ def __hash__(self) -> int: def __repr__(self) -> str: """String representation""" return self.to_string( - self.next_name + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes]) + self.next_name + + "," + + "|".join([self.get_type(type_) for type_ in self.rdtypes]) ) @@ -526,7 +562,7 @@ def __repr__(self) -> str: class DNSRRSet: """A set of dns records with a lookup to get the ttl.""" - __slots__ = ('_records', '_lookup') + __slots__ = ("_records", "_lookup") def __init__(self, records: List[DNSRecord]) -> None: """Create an RRset from records sets.""" diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 9e4550030..6083c19a3 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -45,20 +45,20 @@ class AsyncEngine: """An engine wraps sockets in the event loop.""" __slots__ = ( - 'loop', - 'zc', - 'protocols', - 'readers', - 'senders', - 'running_event', - '_listen_socket', - '_respond_sockets', - '_cleanup_timer', + "loop", + "zc", + "protocols", + "readers", + "senders", + "running_event", + "_listen_socket", + "_respond_sockets", + "_cleanup_timer", ) def __init__( self, - zeroconf: 'Zeroconf', + zeroconf: "Zeroconf", listen_socket: Optional[socket.socket], respond_sockets: List[socket.socket], ) -> None: @@ -72,7 +72,11 @@ def __init__( self._respond_sockets = respond_sockets self._cleanup_timer: Optional[asyncio.TimerHandle] = None - def setup(self, loop: asyncio.AbstractEventLoop, loop_thread_ready: Optional[threading.Event]) -> None: + def setup( + self, + loop: asyncio.AbstractEventLoop, + loop_thread_ready: Optional[threading.Event], + ) -> None: """Set up the instance.""" self.loop = loop self.running_event = asyncio.Event() @@ -102,19 +106,28 @@ async def _async_create_endpoints(self) -> None: for s in reader_sockets: transport, protocol = await loop.create_datagram_endpoint( - lambda: AsyncListener(self.zc), sock=s # type: ignore[arg-type, return-value] + lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value] + sock=s, ) self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + self.readers.append( + make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + ) if s in sender_sockets: - self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + self.senders.append( + make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + ) def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" now = current_time_millis() self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates( - now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)] + now, + [ + RecordUpdate(record, record) + for record in self.zc.cache.async_expire(now) + ], ) self.zc.record_manager.async_updates_complete(False) self._async_schedule_next_cache_cleanup() @@ -123,7 +136,9 @@ def _async_schedule_next_cache_cleanup(self) -> None: """Schedule the next cache cleanup.""" loop = self.loop assert loop is not None - self._cleanup_timer = loop.call_at(loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) + self._cleanup_timer = loop.call_at( + loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup + ) async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" diff --git a/src/zeroconf/_exceptions.py b/src/zeroconf/_exceptions.py index f4fcbd551..5eb58f793 100644 --- a/src/zeroconf/_exceptions.py +++ b/src/zeroconf/_exceptions.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/src/zeroconf/_handlers/__init__.py b/src/zeroconf/_handlers/__init__.py index 2ef4b15b1..30920c6aa 100644 --- a/src/zeroconf/_handlers/__init__.py +++ b/src/zeroconf/_handlers/__init__.py @@ -1,21 +1,21 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index a2dbd66aa..74efee2c2 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from operator import attrgetter @@ -34,7 +34,7 @@ MULTICAST_DELAY_RANDOM_INTERVAL = (20, 120) -NAME_GETTER = attrgetter('name') +NAME_GETTER = attrgetter("name") _FLAGS_QR_RESPONSE_AA = _FLAGS_QR_RESPONSE | _FLAGS_AA @@ -44,7 +44,7 @@ class QuestionAnswers: """A group of answers to a question.""" - __slots__ = ('ucast', 'mcast_now', 'mcast_aggregate', 'mcast_aggregate_last_second') + __slots__ = ("ucast", "mcast_now", "mcast_aggregate", "mcast_aggregate_last_second") def __init__( self, @@ -62,24 +62,31 @@ def __init__( def __repr__(self) -> str: """Return a string representation of this QuestionAnswers.""" return ( - f'QuestionAnswers(ucast={self.ucast}, mcast_now={self.mcast_now}, ' - f'mcast_aggregate={self.mcast_aggregate}, ' - f'mcast_aggregate_last_second={self.mcast_aggregate_last_second})' + f"QuestionAnswers(ucast={self.ucast}, mcast_now={self.mcast_now}, " + f"mcast_aggregate={self.mcast_aggregate}, " + f"mcast_aggregate_last_second={self.mcast_aggregate_last_second})" ) class AnswerGroup: """A group of answers scheduled to be sent at the same time.""" - __slots__ = ('send_after', 'send_before', 'answers') + __slots__ = ("send_after", "send_before", "answers") - def __init__(self, send_after: float_, send_before: float_, answers: _AnswerWithAdditionalsType) -> None: + def __init__( + self, + send_after: float_, + send_before: float_, + answers: _AnswerWithAdditionalsType, + ) -> None: self.send_after = send_after # Must be sent after this time self.send_before = send_before # Must be sent before this time self.answers = answers -def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> DNSOutgoing: +def construct_outgoing_multicast_answers( + answers: _AnswerWithAdditionalsType, +) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE_AA, True) _add_answers_additionals(out, answers) @@ -87,7 +94,10 @@ def construct_outgoing_multicast_answers(answers: _AnswerWithAdditionalsType) -> def construct_outgoing_unicast_answers( - answers: _AnswerWithAdditionalsType, ucast_source: bool, questions: List[DNSQuestion], id_: int_ + answers: _AnswerWithAdditionalsType, + ucast_source: bool, + questions: List[DNSQuestion], + id_: int_, ) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" out = DNSOutgoing(_FLAGS_QR_RESPONSE_AA, False, id_) @@ -99,7 +109,9 @@ def construct_outgoing_unicast_answers( return out -def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: +def _add_answers_additionals( + out: DNSOutgoing, answers: _AnswerWithAdditionalsType +) -> None: # Find additionals and suppress any additionals that are already in answers sending: Set[DNSRecord] = set(answers) # Answers are sorted to group names together to increase the chance diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index 23288d18d..492425403 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import random @@ -53,7 +53,9 @@ class MulticastOutgoingQueue: "_aggregation_delay", ) - def __init__(self, zeroconf: 'Zeroconf', additional_delay: _int, max_aggregation_delay: _int) -> None: + def __init__( + self, zeroconf: "Zeroconf", additional_delay: _int, max_aggregation_delay: _int + ) -> None: self.zc = zeroconf self.queue: deque[AnswerGroup] = deque() # Additional delay is used to implement @@ -69,7 +71,9 @@ def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: loop = self.zc.loop if TYPE_CHECKING: assert loop is not None - random_int = RAND_INT(self._multicast_delay_random_min, self._multicast_delay_random_max) + random_int = RAND_INT( + self._multicast_delay_random_min, self._multicast_delay_random_max + ) random_delay = random_int + self._additional_delay send_after = now + random_delay send_before = now + self._aggregation_delay + self._additional_delay @@ -83,7 +87,9 @@ def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: last_group.answers.update(answers) return else: - loop.call_at(loop.time() + millis_to_seconds(random_delay), self.async_ready) + loop.call_at( + loop.time() + millis_to_seconds(random_delay), self.async_ready + ) self.queue.append(AnswerGroup(send_after, send_before, answers)) def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: @@ -103,7 +109,10 @@ def async_ready(self) -> None: if len(self.queue) > 1 and self.queue[0].send_before > now: # There is more than one answer in the queue, # delay until we have to send it (first answer group reaches send_before) - loop.call_at(loop.time() + millis_to_seconds(self.queue[0].send_before - now), self.async_ready) + loop.call_at( + loop.time() + millis_to_seconds(self.queue[0].send_before - now), + self.async_ready, + ) return answers: _AnswerWithAdditionalsType = {} @@ -114,7 +123,10 @@ def async_ready(self) -> None: if len(self.queue): # If there are still groups in the queue that are not ready to send # be sure we schedule them to go out later - loop.call_at(loop.time() + millis_to_seconds(self.queue[0].send_after - now), self.async_ready) + loop.call_at( + loop.time() + millis_to_seconds(self.queue[0].send_after - now), + self.async_ready, + ) if answers: # pragma: no branch # If we have the same answer scheduled to go out, remove them diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index ba9c9e31c..a2f5e9f52 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast @@ -71,7 +71,6 @@ class _AnswerStrategy: - __slots__ = ("question", "strategy_type", "types", "services") def __init__( @@ -103,7 +102,9 @@ class _QueryResponse: "_mcast_aggregate_last_second", ) - def __init__(self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float) -> None: + def __init__( + self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float + ) -> None: """Build a query response.""" self._is_probe = is_probe self._questions = questions @@ -158,8 +159,12 @@ def answers( ucast = {r: self._additionals[r] for r in self._ucast} mcast_now = {r: self._additionals[r] for r in self._mcast_now} mcast_aggregate = {r: self._additionals[r] for r in self._mcast_aggregate} - mcast_aggregate_last_second = {r: self._additionals[r] for r in self._mcast_aggregate_last_second} - return QuestionAnswers(ucast, mcast_now, mcast_aggregate, mcast_aggregate_last_second) + mcast_aggregate_last_second = { + r: self._additionals[r] for r in self._mcast_aggregate_last_second + } + return QuestionAnswers( + ucast, mcast_now, mcast_aggregate, mcast_aggregate_last_second + ) def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -185,15 +190,24 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = self._cache.async_get_unique(record) - return bool(maybe_entry is not None and self._now - maybe_entry.created < _ONE_SECOND) + return bool( + maybe_entry is not None and self._now - maybe_entry.created < _ONE_SECOND + ) class QueryHandler: """Query the ServiceRegistry.""" - __slots__ = ("zc", "registry", "cache", "question_history", "out_queue", "out_delay_queue") + __slots__ = ( + "zc", + "registry", + "cache", + "question_history", + "out_queue", + "out_delay_queue", + ) - def __init__(self, zc: 'Zeroconf') -> None: + def __init__(self, zc: "Zeroconf") -> None: """Init the query handler.""" self.zc = zc self.registry = zc.registry @@ -203,7 +217,10 @@ def __init__(self, zc: 'Zeroconf') -> None: self.out_delay_queue = zc.out_delay_queue def _add_service_type_enumeration_query_answers( - self, types: List[str], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, + types: List[str], + answer_set: _AnswerWithAdditionalsType, + known_answers: DNSRRSet, ) -> None: """Provide an answer to a service type enumeration query. @@ -211,13 +228,21 @@ def _add_service_type_enumeration_query_answers( """ for stype in types: dns_pointer = DNSPointer( - _SERVICE_TYPE_ENUMERATION_NAME, _TYPE_PTR, _CLASS_IN, _DNS_OTHER_TTL, stype, 0.0 + _SERVICE_TYPE_ENUMERATION_NAME, + _TYPE_PTR, + _CLASS_IN, + _DNS_OTHER_TTL, + stype, + 0.0, ) if not known_answers.suppresses(dns_pointer): answer_set[dns_pointer] = set() def _add_pointer_answers( - self, services: List[ServiceInfo], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet + self, + services: List[ServiceInfo], + answer_set: _AnswerWithAdditionalsType, + known_answers: DNSRRSet, ) -> None: """Answer PTR/ANY question.""" for service in services: @@ -253,12 +278,16 @@ def _add_address_answers( missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: if missing_types: - assert service.server is not None, "Service server must be set for NSEC record." + assert ( + service.server is not None + ), "Service server must be set for NSEC record." additionals.add(service._dns_nsec(list(missing_types), None)) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: - assert service.server is not None, "Service server must be set for NSEC record." + assert ( + service.server is not None + ), "Service server must be set for NSEC record." answer_set[service._dns_nsec(list(missing_types), None)] = set() def _answer_question( @@ -273,11 +302,15 @@ def _answer_question( answer_set: _AnswerWithAdditionalsType = {} if strategy_type == _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION: - self._add_service_type_enumeration_query_answers(types, answer_set, known_answers) + self._add_service_type_enumeration_query_answers( + types, answer_set, known_answers + ) elif strategy_type == _ANSWER_STRATEGY_POINTER: self._add_pointer_answers(services, answer_set, known_answers) elif strategy_type == _ANSWER_STRATEGY_ADDRESS: - self._add_address_answers(services, answer_set, known_answers, question.type) + self._add_address_answers( + services, answer_set, known_answers, question.type + ) elif strategy_type == _ANSWER_STRATEGY_SERVICE: # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. @@ -334,9 +367,15 @@ def async_response( # pylint: disable=unused-argument if not is_unicast: if known_answers_set is None: # pragma: no branch known_answers_set = known_answers.lookup_set() - self.question_history.add_question_at_time(question, now, known_answers_set) + self.question_history.add_question_at_time( + question, now, known_answers_set + ) answer_set = self._answer_question( - question, strategy.strategy_type, strategy.types, strategy.services, known_answers + question, + strategy.strategy_type, + strategy.types, + strategy.services, + known_answers, ) if not ucast_source and is_unicast: query_res.add_qu_question_response(answer_set) @@ -364,7 +403,10 @@ def _get_answer_strategies( if types: strategies.append( _AnswerStrategy( - question, _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION, types, _EMPTY_SERVICES_LIST + question, + _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION, + types, + _EMPTY_SERVICES_LIST, ) ) return strategies @@ -373,14 +415,18 @@ def _get_answer_strategies( services = self.registry.async_get_infos_type(question_lower_name) if services: strategies.append( - _AnswerStrategy(question, _ANSWER_STRATEGY_POINTER, _EMPTY_TYPES_LIST, services) + _AnswerStrategy( + question, _ANSWER_STRATEGY_POINTER, _EMPTY_TYPES_LIST, services + ) ) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): services = self.registry.async_get_infos_server(question_lower_name) if services: strategies.append( - _AnswerStrategy(question, _ANSWER_STRATEGY_ADDRESS, _EMPTY_TYPES_LIST, services) + _AnswerStrategy( + question, _ANSWER_STRATEGY_ADDRESS, _EMPTY_TYPES_LIST, services + ) ) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): @@ -388,11 +434,21 @@ def _get_answer_strategies( if service is not None: if type_ in (_TYPE_SRV, _TYPE_ANY): strategies.append( - _AnswerStrategy(question, _ANSWER_STRATEGY_SERVICE, _EMPTY_TYPES_LIST, [service]) + _AnswerStrategy( + question, + _ANSWER_STRATEGY_SERVICE, + _EMPTY_TYPES_LIST, + [service], + ) ) if type_ in (_TYPE_TXT, _TYPE_ANY): strategies.append( - _AnswerStrategy(question, _ANSWER_STRATEGY_TEXT, _EMPTY_TYPES_LIST, [service]) + _AnswerStrategy( + question, + _ANSWER_STRATEGY_TEXT, + _EMPTY_TYPES_LIST, + [service], + ) ) return strategies @@ -421,17 +477,23 @@ def handle_assembled_query( if question_answers.ucast: questions = first_packet._questions id_ = first_packet.id - out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) + out = construct_outgoing_unicast_answers( + question_answers.ucast, ucast_source, questions, id_ + ) # When sending unicast, only send back the reply # via the same socket that it was recieved from # as we know its reachable from that socket self.zc.async_send(out, addr, port, v6_flow_scope, transport) if question_answers.mcast_now: - self.zc.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) + self.zc.async_send( + construct_outgoing_multicast_answers(question_answers.mcast_now) + ) if question_answers.mcast_aggregate: self.out_queue.async_add(first_packet.now, question_answers.mcast_aggregate) if question_answers.mcast_aggregate_last_second: # https://datatracker.ietf.org/doc/html/rfc6762#section-14 # If we broadcast it in the last second, we have to delay # at least a second before we send it again - self.out_delay_queue.async_add(first_packet.now, question_answers.mcast_aggregate_last_second) + self.out_delay_queue.async_add( + first_packet.now, question_answers.mcast_aggregate_last_second + ) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 70f2e5e11..86286deca 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast @@ -42,7 +42,7 @@ class RecordManager: __slots__ = ("zc", "cache", "listeners") - def __init__(self, zeroconf: 'Zeroconf') -> None: + def __init__(self, zeroconf: "Zeroconf") -> None: """Init the record manager.""" self.zc = zeroconf self.cache = zeroconf.cache @@ -97,7 +97,11 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # level of rate limit and safe guards so we use 1/4 of the recommended value. record_type = record.type record_ttl = record.ttl - if record_ttl and record_type == _TYPE_PTR and record_ttl < _DNS_PTR_MIN_TTL: + if ( + record_ttl + and record_type == _TYPE_PTR + and record_ttl < _DNS_PTR_MIN_TTL + ): log.debug( "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", record, @@ -128,7 +132,9 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) + cache.async_mark_unique_records_older_than_1s_to_expire( + unique_types, answers, now + ) if updates: self.async_updates(now, updates) @@ -161,7 +167,9 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: self.async_updates_complete(new) def async_add_listener( - self, listener: RecordUpdateListener, question: Optional[Union[DNSQuestion, List[DNSQuestion]]] + self, + listener: RecordUpdateListener, + question: Optional[Union[DNSQuestion, List[DNSQuestion]]], ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -212,4 +220,4 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: self.listeners.remove(listener) self.zc.async_notify_all() except ValueError as e: - log.exception('Failed to remove listener: %r', e) + log.exception("Failed to remove listener: %r", e) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index db6a394d7..2e58b14e3 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import Dict, List, Set, Tuple @@ -38,11 +38,15 @@ def __init__(self) -> None: """Init a new QuestionHistory.""" self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} - def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> None: + def add_question_at_time( + self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord] + ) -> None: """Remember a question with known answers.""" self._history[question] = (now, known_answers) - def suppresses(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> bool: + def suppresses( + self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord] + ) -> bool: """Check to see if a question should be suppressed. https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 0f8a8cac7..2956ad528 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -47,7 +47,6 @@ class AsyncListener: - """A Listener is used by this module to listen on the multicast group to which DNS messages are sent, allowing the implementation to cache information as it arrives. @@ -56,20 +55,20 @@ class AsyncListener: the read() method called when a socket is available for reading.""" __slots__ = ( - 'zc', - '_registry', - '_record_manager', + "zc", + "_registry", + "_record_manager", "_query_handler", - 'data', - 'last_time', - 'last_message', - 'transport', - 'sock_description', - '_deferred', - '_timers', + "data", + "last_time", + "last_message", + "transport", + "sock_description", + "_deferred", + "_timers", ) - def __init__(self, zc: 'Zeroconf') -> None: + def __init__(self, zc: "Zeroconf") -> None: self.zc = zc self._registry = zc.registry self._record_manager = zc.record_manager @@ -120,7 +119,7 @@ def _process_datagram_at_time( # Guard against duplicate packets if debug: log.debug( - 'Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]', + "Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]", addrs, self.sock_description, data_len, @@ -140,7 +139,9 @@ def _process_datagram_at_time( # https://github.com/python/mypy/issues/1178 addr, port, flow, scope = addrs # type: ignore if debug: # pragma: no branch - log.debug('IPv6 scope_id %d associated to the receiving interface', scope) + log.debug( + "IPv6 scope_id %d associated to the receiving interface", scope + ) v6_flow_scope = (flow, scope) addr_port = (addr, port) @@ -151,7 +152,7 @@ def _process_datagram_at_time( if msg.valid is True: if debug: log.debug( - 'Received from %r:%r [socket %s]: %r (%d bytes) as [%r]', + "Received from %r:%r [socket %s]: %r (%d bytes) as [%r]", addr, port, self.sock_description, @@ -162,7 +163,7 @@ def _process_datagram_at_time( else: if debug: log.debug( - 'Received from %r:%r [socket %s]: (%d bytes) [%r]', + "Received from %r:%r [socket %s]: (%d bytes) [%r]", addr, port, self.sock_description, @@ -208,7 +209,13 @@ def handle_query_or_defer( assert loop is not None self._cancel_any_timers_for_addr(addr) self._timers[addr] = loop.call_at( - loop.time() + delay, self._respond_query, None, addr, port, transport, v6_flow_scope + loop.time() + delay, + self._respond_query, + None, + addr, + port, + transport, + v6_flow_scope, ) def _cancel_any_timers_for_addr(self, addr: _str) -> None: @@ -230,7 +237,9 @@ def _respond_query( if msg: packets.append(msg) - self._query_handler.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) + self._query_handler.handle_assembled_query( + packets, addr, port, transport, v6_flow_scope + ) def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" @@ -242,9 +251,13 @@ def error_received(self, exc: Exception) -> None: QuietLogger.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: - wrapped_transport = make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) + wrapped_transport = make_wrapped_transport( + cast(asyncio.DatagramTransport, transport) + ) self.transport = wrapped_transport - self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" + self.sock_description = ( + f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" + ) def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" diff --git a/src/zeroconf/_logger.py b/src/zeroconf/_logger.py index b0e66bc90..9e7261070 100644 --- a/src/zeroconf/_logger.py +++ b/src/zeroconf/_logger.py @@ -1,31 +1,31 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - ) - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine + ) +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import logging import sys from typing import Any, Dict, Union, cast -log = logging.getLogger(__name__.split('.', maxsplit=1)[0]) +log = logging.getLogger(__name__.split(".", maxsplit=1)[0]) log.addHandler(logging.NullHandler()) @@ -50,7 +50,7 @@ def log_exception_warning(cls, *logger_data: Any) -> None: logger = log.warning else: logger = log.debug - logger(*(logger_data or ['Exception occurred']), exc_info=True) + logger(*(logger_data or ["Exception occurred"]), exc_info=True) @classmethod def log_exception_debug(cls, *logger_data: Any) -> None: @@ -61,7 +61,7 @@ def log_exception_debug(cls, *logger_data: Any) -> None: # log the trace only on the first time cls._seen_logs[exc_str] = exc_info log_exc_info = True - log.debug(*(logger_data or ['Exception occurred']), exc_info=log_exc_info) + log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info) @classmethod def log_warning_once(cls, *args: Any) -> None: diff --git a/src/zeroconf/_protocol/__init__.py b/src/zeroconf/_protocol/__init__.py index 2ef4b15b1..30920c6aa 100644 --- a/src/zeroconf/_protocol/__init__.py +++ b/src/zeroconf/_protocol/__init__.py @@ -1,21 +1,21 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 9e208b639..0ad6efce4 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import struct @@ -71,24 +71,24 @@ class DNSIncoming: __slots__ = ( "_did_read_others", - 'flags', - 'offset', - 'data', - 'view', - '_data_len', - '_name_cache', - '_questions', - '_answers', - 'id', - '_num_questions', - '_num_answers', - '_num_authorities', - '_num_additionals', - 'valid', - 'now', - 'scope_id', - 'source', - '_has_qu_question', + "flags", + "offset", + "data", + "view", + "_data_len", + "_name_cache", + "_questions", + "_answers", + "id", + "_num_questions", + "_num_answers", + "_num_authorities", + "_num_additionals", + "valid", + "now", + "scope_id", + "source", + "_has_qu_question", ) def __init__( @@ -122,7 +122,7 @@ def __init__( self._initial_parse() except DECODE_EXCEPTIONS: self._log_exception_debug( - 'Received invalid packet from %s at offset %d while unpacking %r', + "Received invalid packet from %s at offset %d while unpacking %r", self.source, self.offset, self.data, @@ -187,7 +187,7 @@ def _log_exception_debug(cls, *logger_data: Any) -> None: # log the trace only on the first time _seen_logs[exc_str] = exc_info log_exc_info = True - log.debug(*(logger_data or ['Exception occurred']), exc_info=log_exc_info) + log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info) def answers(self) -> List[DNSRecord]: """Answers in the packet.""" @@ -196,7 +196,7 @@ def answers(self) -> List[DNSRecord]: self._read_others() except DECODE_EXCEPTIONS: self._log_exception_debug( - 'Received invalid packet from %s at offset %d while unpacking %r', + "Received invalid packet from %s at offset %d while unpacking %r", self.source, self.offset, self.data, @@ -208,17 +208,17 @@ def is_probe(self) -> bool: return self._num_authorities > 0 def __repr__(self) -> str: - return '' % ', '.join( + return "" % ", ".join( [ - 'id=%s' % self.id, - 'flags=%s' % self.flags, - 'truncated=%s' % self.truncated, - 'n_q=%s' % self._num_questions, - 'n_ans=%s' % self._num_answers, - 'n_auth=%s' % self._num_authorities, - 'n_add=%s' % self._num_additionals, - 'questions=%s' % self._questions, - 'answers=%s' % self.answers(), + "id=%s" % self.id, + "flags=%s" % self.flags, + "truncated=%s" % self.truncated, + "n_q=%s" % self._num_questions, + "n_ans=%s" % self._num_answers, + "n_auth=%s" % self._num_authorities, + "n_add=%s" % self._num_additionals, + "questions=%s" % self._questions, + "answers=%s" % self.answers(), ] ) @@ -255,7 +255,7 @@ def _read_character_string(self) -> str: """Reads a character string from the packet""" length = self.view[self.offset] self.offset += 1 - info = self.data[self.offset : self.offset + length].decode('utf-8', 'replace') + info = self.data[self.offset : self.offset + length].decode("utf-8", "replace") self.offset += length return info @@ -279,7 +279,12 @@ def _read_others(self) -> None: # ttl is an unsigned long in network order https://www.rfc-editor.org/errata/eid2130 type_ = view[offset] << 8 | view[offset + 1] class_ = view[offset + 2] << 8 | view[offset + 3] - ttl = view[offset + 4] << 24 | view[offset + 5] << 16 | view[offset + 6] << 8 | view[offset + 7] + ttl = ( + view[offset + 4] << 24 + | view[offset + 5] << 16 + | view[offset + 6] << 8 + | view[offset + 7] + ) length = view[offset + 8] << 8 | view[offset + 9] end = self.offset + length rec = None @@ -291,7 +296,7 @@ def _read_others(self) -> None: # above would fail and hit the exception catch in read_others self.offset = end log.debug( - 'Unable to parse; skipping record for %s with type %s at offset %d while unpacking %r', + "Unable to parse; skipping record for %s with type %s at offset %d while unpacking %r", domain, _TYPES.get(type_, type_), self.offset, @@ -306,11 +311,15 @@ def _read_record( ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - return DNSAddress(domain, type_, class_, ttl, self._read_string(4), None, self.now) + return DNSAddress( + domain, type_, class_, ttl, self._read_string(4), None, self.now + ) if type_ in (_TYPE_CNAME, _TYPE_PTR): return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) if type_ == _TYPE_TXT: - return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) + return DNSText( + domain, type_, class_, ttl, self._read_string(length), self.now + ) if type_ == _TYPE_SRV: view = self.view offset = self.offset @@ -341,7 +350,15 @@ def _read_record( self.now, ) if type_ == _TYPE_AAAA: - return DNSAddress(domain, type_, class_, ttl, self._read_string(16), self.scope_id, self.now) + return DNSAddress( + domain, + type_, + class_, + ttl, + self._read_string(16), + self.scope_id, + self.now, + ) if type_ == _TYPE_NSEC: name_start = self.offset return DNSNsec( @@ -382,7 +399,9 @@ def _read_name(self) -> str: labels: List[str] = [] seen_pointers: Set[int] = set() original_offset = self.offset - self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) + self.offset = self._decode_labels_at_offset( + original_offset, labels, seen_pointers + ) self._name_cache[original_offset] = labels name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: @@ -391,7 +410,9 @@ def _read_name(self) -> str: ) return name - def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: + def _decode_labels_at_offset( + self, off: _int, labels: List[str], seen_pointers: Set[int] + ) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. view = self.view while off < self._data_len: @@ -401,7 +422,9 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: if length < 0x40: label_idx = off + DNS_COMPRESSION_HEADER_LEN - labels.append(self.data[label_idx : label_idx + length].decode('utf-8', 'replace')) + labels.append( + self.data[label_idx : label_idx + length].decode("utf-8", "replace") + ) off += DNS_COMPRESSION_HEADER_LEN + length continue @@ -439,4 +462,6 @@ def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: ) return off + DNS_COMPRESSION_POINTER_LEN - raise IncomingDecodeError(f"Corrupt packet received while decoding name from {self.source}") + raise IncomingDecodeError( + f"Corrupt packet received while decoding name from {self.source}" + ) diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index f45c39351..66b526cca 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import enum @@ -50,9 +50,9 @@ DNSRecord_ = DNSRecord -PACK_BYTE = Struct('>B').pack -PACK_SHORT = Struct('>H').pack -PACK_LONG = Struct('>L').pack +PACK_BYTE = Struct(">B").pack +PACK_SHORT = Struct(">H").pack +PACK_LONG = Struct(">L").pack SHORT_CACHE_MAX = 128 @@ -74,24 +74,23 @@ class State(enum.Enum): class DNSOutgoing: - """Object representation of an outgoing packet""" __slots__ = ( - 'flags', - 'finished', - 'id', - 'multicast', - 'packets_data', - 'names', - 'data', - 'size', - 'allow_long', - 'state', - 'questions', - 'answers', - 'authorities', - 'additionals', + "flags", + "finished", + "id", + "multicast", + "packets_data", + "names", + "data", + "size", + "allow_long", + "state", + "questions", + "answers", + "authorities", + "additionals", ) def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: @@ -129,14 +128,14 @@ def _reset_for_next_packet(self) -> None: self.allow_long = True def __repr__(self) -> str: - return '' % ', '.join( + return "" % ", ".join( [ - 'multicast=%s' % self.multicast, - 'flags=%s' % self.flags, - 'questions=%s' % self.questions, - 'answers=%s' % self.answers, - 'authorities=%s' % self.authorities, - 'additionals=%s' % self.additionals, + "multicast=%s" % self.multicast, + "flags=%s" % self.flags, + "questions=%s" % self.questions, + "answers=%s" % self.answers, + "authorities=%s" % self.authorities, + "additionals=%s" % self.additionals, ] ) @@ -152,7 +151,9 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: float_) -> None: """Adds an answer if it does not expire by a certain time""" now_double = now - if record is not None and (now_double == 0 or not record.is_expired(now_double)): + if record is not None and ( + now_double == 0 or not record.is_expired(now_double) + ): self.answers.append((record, now)) def add_authorative_answer(self, record: DNSPointer) -> None: @@ -238,7 +239,7 @@ def write_string(self, value: bytes_) -> None: def _write_utf(self, s: str_) -> None: """Writes a UTF-8 string of a given length to the packet""" - utfstr = s.encode('utf-8') + utfstr = s.encode("utf-8") length = len(utfstr) if length > 64: raise NamePartTooLongException @@ -268,7 +269,7 @@ def write_name(self, name: str_) -> None: """ # split name into each label - if name.endswith('.'): + if name.endswith("."): name = name[:-1] index = self.names.get(name, 0) @@ -277,21 +278,23 @@ def write_name(self, name: str_) -> None: return start_size = self.size - labels = name.split('.') + labels = name.split(".") # Write each new label or a pointer to the existing one in the packet self.names[name] = start_size self._write_utf(labels[0]) name_length = 0 for count in range(1, len(labels)): - partial_name = '.'.join(labels[count:]) + partial_name = ".".join(labels[count:]) index = self.names.get(partial_name, 0) if index: self._write_link_to_name(index) return if name_length == 0: - name_length = len(name.encode('utf-8')) - self.names[partial_name] = start_size + name_length - len(partial_name.encode('utf-8')) + name_length = len(name.encode("utf-8")) + self.names[partial_name] = ( + start_size + name_length - len(partial_name.encode("utf-8")) + ) self._write_utf(labels[count]) # this is the end of a name @@ -346,7 +349,9 @@ def _write_record(self, record: DNSRecord_, now: float_) -> bool: self._replace_short(index, length) return self._check_data_limit_or_rollback(start_data_length, start_size) - def _check_data_limit_or_rollback(self, start_data_length: int_, start_size: int_) -> bool: + def _check_data_limit_or_rollback( + self, start_data_length: int_, start_size: int_ + ) -> bool: """Check data limit, if we go over, then rollback and return False.""" len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL self.allow_long = False @@ -355,12 +360,18 @@ def _check_data_limit_or_rollback(self, start_data_length: int_, start_size: int return True if LOGGING_IS_ENABLED_FOR(LOGGING_DEBUG): # pragma: no branch - log.debug("Reached data limit (size=%d) > (limit=%d) - rolling back", self.size, len_limit) + log.debug( + "Reached data limit (size=%d) > (limit=%d) - rolling back", + self.size, + len_limit, + ) del self.data[start_data_length:] self.size = start_size start_size_int = start_size - rollback_names = [name for name, idx in self.names.items() if idx >= start_size_int] + rollback_names = [ + name for name, idx in self.names.items() if idx >= start_size_int + ] for name in rollback_names: del self.names[name] return False @@ -381,7 +392,9 @@ def _write_answers_from_offset(self, answer_offset: int_) -> int: answers_written += 1 return answers_written - def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int_) -> int: + def _write_records_from_offset( + self, records: Sequence[DNSRecord], offset: int_ + ) -> int: records_written = 0 for record in records[offset:]: if not self._write_record(record, 0): @@ -390,7 +403,11 @@ def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int_) return records_written def _has_more_to_add( - self, questions_offset: int_, answer_offset: int_, authority_offset: int_, additional_offset: int_ + self, + questions_offset: int_, + answer_offset: int_, + authority_offset: int_, + additional_offset: int_, ) -> bool: """Check if all questions, answers, authority, and additionals have been written to the packet.""" return ( @@ -441,8 +458,12 @@ def packets(self) -> List[bytes]: questions_written = self._write_questions_from_offset(questions_offset) answers_written = self._write_answers_from_offset(answer_offset) - authorities_written = self._write_records_from_offset(self.authorities, authority_offset) - additionals_written = self._write_records_from_offset(self.additionals, additional_offset) + authorities_written = self._write_records_from_offset( + self.authorities, authority_offset + ) + additionals_written = self._write_records_from_offset( + self.additionals, additional_offset + ) made_progress = bool(self.data) @@ -481,7 +502,7 @@ def packets(self) -> List[bytes]: else: self._insert_short_at_start(self.id) - packets_data.append(b''.join(self.data)) + packets_data.append(b"".join(self.data)) if not made_progress: # Generating an empty packet is not a desirable outcome, but currently diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index 8e0e4bdb0..880b7a1b2 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import Optional diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index cf54d7f07..9812c6f36 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import enum @@ -35,18 +35,18 @@ class ServiceStateChange(enum.Enum): class ServiceListener: - def add_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + def add_service(self, zc: "Zeroconf", type_: str, name: str) -> None: raise NotImplementedError() - def remove_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + def remove_service(self, zc: "Zeroconf", type_: str, name: str) -> None: raise NotImplementedError() - def update_service(self, zc: 'Zeroconf', type_: str, name: str) -> None: + def update_service(self, zc: "Zeroconf", type_: str, name: str) -> None: raise NotImplementedError() class Signal: - __slots__ = ('_handlers',) + __slots__ = ("_handlers",) def __init__(self) -> None: self._handlers: List[Callable[..., None]] = [] @@ -56,20 +56,24 @@ def fire(self, **kwargs: Any) -> None: h(**kwargs) @property - def registration_interface(self) -> 'SignalRegistrationInterface': + def registration_interface(self) -> "SignalRegistrationInterface": return SignalRegistrationInterface(self._handlers) class SignalRegistrationInterface: - __slots__ = ('_handlers',) + __slots__ = ("_handlers",) def __init__(self, handlers: List[Callable[..., None]]) -> None: self._handlers = handlers - def register_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + def register_handler( + self, handler: Callable[..., None] + ) -> "SignalRegistrationInterface": self._handlers.append(handler) return self - def unregister_handler(self, handler: Callable[..., None]) -> 'SignalRegistrationInterface': + def unregister_handler( + self, handler: Callable[..., None] + ) -> "SignalRegistrationInterface": self._handlers.remove(handler) return self diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 2ff660744..1f0524f39 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -105,11 +105,22 @@ class _ScheduledPTRQuery: - - __slots__ = ('alias', 'name', 'ttl', 'cancelled', 'expire_time_millis', 'when_millis') + __slots__ = ( + "alias", + "name", + "ttl", + "cancelled", + "expire_time_millis", + "when_millis", + ) def __init__( - self, alias: str, name: str, ttl: int, expire_time_millis: float, when_millis: float + self, + alias: str, + name: str, + ttl: int, + expire_time_millis: float, + when_millis: float, ) -> None: """Create a scheduled query.""" self.alias = alias @@ -144,13 +155,13 @@ def __repr__(self) -> str: ">" ) - def __lt__(self, other: '_ScheduledPTRQuery') -> bool: + def __lt__(self, other: "_ScheduledPTRQuery") -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis < other.when_millis return NotImplemented - def __le__(self, other: '_ScheduledPTRQuery') -> bool: + def __le__(self, other: "_ScheduledPTRQuery") -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis < other.when_millis or self.__eq__(other) @@ -162,13 +173,13 @@ def __eq__(self, other: Any) -> bool: return self.when_millis == other.when_millis return NotImplemented - def __ge__(self, other: '_ScheduledPTRQuery') -> bool: + def __ge__(self, other: "_ScheduledPTRQuery") -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis > other.when_millis or self.__eq__(other) return NotImplemented - def __gt__(self, other: '_ScheduledPTRQuery') -> bool: + def __gt__(self, other: "_ScheduledPTRQuery") -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis > other.when_millis @@ -178,7 +189,7 @@ def __gt__(self, other: '_ScheduledPTRQuery') -> bool: class _DNSPointerOutgoingBucket: """A DNSOutgoing bucket.""" - __slots__ = ('now_millis', 'out', 'bytes') + __slots__ = ("now_millis", "out", "bytes") def __init__(self, now_millis: float, multicast: bool) -> None: """Create a bucket to wrap a DNSOutgoing.""" @@ -186,7 +197,9 @@ def __init__(self, now_millis: float, multicast: bool) -> None: self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast) self.bytes = 0 - def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer]) -> None: + def add( + self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer] + ) -> None: """Add a new set of questions and known answers to the outgoing.""" self.out.add_question(question) for answer in answers: @@ -195,7 +208,9 @@ def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNS def group_ptr_queries_with_known_answers( - now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers + now: float_, + multicast: bool_, + question_with_known_answers: _QuestionWithKnownAnswers, ) -> List[DNSOutgoing]: """Aggregate queries so that as many known answers as possible fit in the same packet without having known answers spill over into the next packet unless the @@ -205,11 +220,15 @@ def group_ptr_queries_with_known_answers( so we try to keep all the known answers in the same packet as the questions. """ - return _group_ptr_queries_with_known_answers(now, multicast, question_with_known_answers) + return _group_ptr_queries_with_known_answers( + now, multicast, question_with_known_answers + ) def _group_ptr_queries_with_known_answers( - now_millis: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers + now_millis: float_, + multicast: bool_, + question_with_known_answers: _QuestionWithKnownAnswers, ) -> List[DNSOutgoing]: """Inner wrapper for group_ptr_queries_with_known_answers.""" # This is the maximum size the query + known answers can be with name compression. @@ -218,7 +237,10 @@ def _group_ptr_queries_with_known_answers( # goal of this algorithm is to quickly bucket the query + known answers without # the overhead of actually constructing the packets. query_by_size: Dict[DNSQuestion, int] = { - question: (question.max_size + sum(answer.max_size_compressed for answer in known_answers)) + question: ( + question.max_size + + sum(answer.max_size_compressed for answer in known_answers) + ) for question, known_answers in question_with_known_answers.items() } max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN @@ -246,7 +268,7 @@ def _group_ptr_queries_with_known_answers( def generate_service_query( - zc: 'Zeroconf', + zc: "Zeroconf", now_millis: float_, types_: Set[str], multicast: bool, @@ -254,7 +276,9 @@ def generate_service_query( ) -> List[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} - qu_question = not multicast if question_type is None else question_type is QU_QUESTION + qu_question = ( + not multicast if question_type is None else question_type is QU_QUESTION + ) question_history = zc.question_history cache = zc.cache for type_ in types_: @@ -265,7 +289,9 @@ def generate_service_query( for record in cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now_millis) } - if not qu_question and question_history.suppresses(question, now_millis, known_answers): + if not qu_question and question_history.suppresses( + question, now_millis, known_answers + ): log.debug("Asking %s was suppressed by the question history", question) continue if TYPE_CHECKING: @@ -276,12 +302,14 @@ def generate_service_query( if not qu_question: question_history.add_question_at_time(question, now_millis, known_answers) - return _group_ptr_queries_with_known_answers(now_millis, multicast, questions_with_known_answers) + return _group_ptr_queries_with_known_answers( + now_millis, multicast, questions_with_known_answers + ) def _on_change_dispatcher( listener: ServiceListener, - zeroconf: 'Zeroconf', + zeroconf: "Zeroconf", service_type: str, name: str, state_change: ServiceStateChange, @@ -290,10 +318,12 @@ def _on_change_dispatcher( getattr(listener, _ON_CHANGE_DISPATCH[state_change])(zeroconf, service_type, name) -def _service_state_changed_from_listener(listener: ServiceListener) -> Callable[..., None]: +def _service_state_changed_from_listener( + listener: ServiceListener, +) -> Callable[..., None]: """Generate a service_state_changed handlers from a listener.""" assert listener is not None - if not hasattr(listener, 'update_service'): + if not hasattr(listener, "update_service"): warnings.warn( "%r has no update_service method. Provide one (it can be empty if you " "don't care about the updates), it'll become mandatory." % (listener,), @@ -310,20 +340,20 @@ class QueryScheduler: """ __slots__ = ( - '_zc', - '_types', - '_addr', - '_port', - '_multicast', - '_first_random_delay_interval', - '_min_time_between_queries_millis', - '_loop', - '_startup_queries_sent', - '_next_scheduled_for_alias', - '_query_heap', - '_next_run', - '_clock_resolution_millis', - '_question_type', + "_zc", + "_types", + "_addr", + "_port", + "_multicast", + "_first_random_delay_interval", + "_min_time_between_queries_millis", + "_loop", + "_startup_queries_sent", + "_next_scheduled_for_alias", + "_query_heap", + "_next_run", + "_clock_resolution_millis", + "_question_type", ) def __init__( @@ -349,7 +379,9 @@ def __init__( self._next_scheduled_for_alias: Dict[str, _ScheduledPTRQuery] = {} self._query_heap: list[_ScheduledPTRQuery] = [] self._next_run: Optional[asyncio.TimerHandle] = None - self._clock_resolution_millis = time.get_clock_info('monotonic').resolution * 1000 + self._clock_resolution_millis = ( + time.get_clock_info("monotonic").resolution * 1000 + ) self._question_type = question_type def start(self, loop: asyncio.AbstractEventLoop) -> None: @@ -362,7 +394,9 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: also delay the first query of the series by a randomly chosen amount in the range 20-120 ms. """ - start_delay = millis_to_seconds(random.randint(*self._first_random_delay_interval)) + start_delay = millis_to_seconds( + random.randint(*self._first_random_delay_interval) + ) self._loop = loop self._next_run = loop.call_later(start_delay, self._process_startup_queries) @@ -375,7 +409,10 @@ def stop(self) -> None: self._query_heap.clear() def _schedule_ptr_refresh( - self, pointer: DNSPointer, expire_time_millis: float_, refresh_time_millis: float_ + self, + pointer: DNSPointer, + expire_time_millis: float_, + refresh_time_millis: float_, ) -> None: """Schedule a query for a pointer.""" ttl = int(pointer.ttl) if isinstance(pointer.ttl, float) else pointer.ttl @@ -414,7 +451,10 @@ def reschedule_ptr_first_refresh(self, pointer: DNSPointer) -> None: self._schedule_ptr_refresh(pointer, expire_time_millis, refresh_time_millis) def schedule_rescue_query( - self, query: _ScheduledPTRQuery, now_millis: float_, additional_percentage: float_ + self, + query: _ScheduledPTRQuery, + now_millis: float_, + additional_percentage: float_, ) -> None: """Reschedule a query for a pointer at an additional percentage of expiration.""" ttl_millis = query.ttl * 1000 @@ -426,7 +466,11 @@ def schedule_rescue_query( # tried to rescue the record and failed return scheduled_ptr_query = _ScheduledPTRQuery( - query.alias, query.name, query.ttl, query.expire_time_millis, next_query_time + query.alias, + query.name, + query.ttl, + query.expire_time_millis, + next_query_time, ) self._schedule_ptr_query(scheduled_ptr_query) @@ -441,7 +485,9 @@ def _process_startup_queries(self) -> None: now_millis = current_time_millis() # At first we will send STARTUP_QUERIES queries to get the cache populated - self.async_send_ready_queries(self._startup_queries_sent == 0, now_millis, self._types) + self.async_send_ready_queries( + self._startup_queries_sent == 0, now_millis, self._types + ) self._startup_queries_sent += 1 # Once we finish sending the initial queries we will @@ -454,7 +500,9 @@ def _process_startup_queries(self) -> None: ) return - self._next_run = self._loop.call_later(self._startup_queries_sent**2, self._process_startup_queries) + self._next_run = self._loop.call_later( + self._startup_queries_sent**2, self._process_startup_queries + ) def _process_ready_types(self) -> None: """Generate a list of ready types that is due and schedule the next time.""" @@ -495,7 +543,9 @@ def _process_ready_types(self) -> None: schedule_rescue.append(query) for query in schedule_rescue: - self.schedule_rescue_query(query, now_millis, RESCUE_RECORD_RETRY_TTL_PERCENTAGE) + self.schedule_rescue_query( + query, now_millis, RESCUE_RECORD_RETRY_TTL_PERCENTAGE + ) if ready_types: self.async_send_ready_queries(False, now_millis, ready_types) @@ -507,7 +557,9 @@ def _process_ready_types(self) -> None: else: next_when_millis = next_time_millis - self._next_run = self._loop.call_at(millis_to_seconds(next_when_millis), self._process_ready_types) + self._next_run = self._loop.call_at( + millis_to_seconds(next_when_millis), self._process_ready_types + ) def async_send_ready_queries( self, first_request: bool, now_millis: float_, ready_types: Set[str] @@ -517,8 +569,14 @@ def async_send_ready_queries( # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are # just starting up and we know our cache is likely empty. This ensures # the next outgoing will be sent with the known answers list. - question_type = QU_QUESTION if self._question_type is None and first_request else self._question_type - outs = generate_service_query(self._zc, now_millis, ready_types, self._multicast, question_type) + question_type = ( + QU_QUESTION + if self._question_type is None and first_request + else self._question_type + ) + outs = generate_service_query( + self._zc, now_millis, ready_types, self._multicast, question_type + ) if outs: for out in outs: self._zc.async_send(out, self._addr, self._port) @@ -528,20 +586,20 @@ class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" __slots__ = ( - 'types', - 'zc', - '_cache', - '_loop', - '_pending_handlers', - '_service_state_changed', - 'query_scheduler', - 'done', - '_query_sender_task', + "types", + "zc", + "_cache", + "_loop", + "_pending_handlers", + "_service_state_changed", + "query_scheduler", + "done", + "_query_sender_task", ) def __init__( self, - zc: 'Zeroconf', + zc: "Zeroconf", type_: Union[str, list], handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, listener: Optional[ServiceListener] = None, @@ -567,7 +625,7 @@ def __init__( remove_service() methods called when this browser discovers changes in the services availability. """ - assert handlers or listener, 'You need to specify at least one handler' + assert handlers or listener, "You need to specify at least one handler" self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) for check_type_ in self.types: # Will generate BadTypeInNameException on a bad name @@ -591,8 +649,8 @@ def __init__( self.done = False self._query_sender_task: Optional[asyncio.Task] = None - if hasattr(handlers, 'add_service'): - listener = cast('ServiceListener', handlers) + if hasattr(handlers, "add_service"): + listener = cast("ServiceListener", handlers) handlers = None handlers = cast(List[Callable[..., None]], handlers or []) @@ -609,9 +667,13 @@ def _async_start(self) -> None: Must be called by uses of this base class after they have finished setting their properties. """ - self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + self.zc.async_add_listener( + self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] + ) # Only start queries after the listener is installed - self._query_sender_task = asyncio.ensure_future(self._async_start_query_sender()) + self._query_sender_task = asyncio.ensure_future( + self._async_start_query_sender() + ) @property def service_state_changed(self) -> SignalRegistrationInterface: @@ -620,7 +682,9 @@ def service_state_changed(self) -> SignalRegistrationInterface: def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: """Return the type and name for records matching the types we are browsing.""" return [ - (type_, name) for name in names for type_ in self.types.intersection(cached_possible_types(name)) + (type_, name) + for name in names + for type_ in self.types.intersection(cached_possible_types(name)) ] def _enqueue_callback( @@ -638,11 +702,16 @@ def _enqueue_callback( state_change is SERVICE_STATE_CHANGE_REMOVED and self._pending_handlers.get(key) is not SERVICE_STATE_CHANGE_ADDED ) - or (state_change is SERVICE_STATE_CHANGE_UPDATED and key not in self._pending_handlers) + or ( + state_change is SERVICE_STATE_CHANGE_UPDATED + and key not in self._pending_handlers + ) ): self._pending_handlers[key] = state_change - def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] + ) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. @@ -660,12 +729,18 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record if TYPE_CHECKING: record = cast(DNSPointer, record) pointer = record - for type_ in self.types.intersection(cached_possible_types(pointer.name)): + for type_ in self.types.intersection( + cached_possible_types(pointer.name) + ): if old_record is None: - self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias) + self._enqueue_callback( + SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias + ) self.query_scheduler.reschedule_ptr_first_refresh(pointer) elif pointer.is_expired(now): - self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias) + self._enqueue_callback( + SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias + ) self.query_scheduler.cancel_ptr_refresh(pointer) else: self.query_scheduler.reschedule_ptr_first_refresh(pointer) @@ -677,7 +752,10 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record if record_type in _ADDRESS_RECORD_TYPES: cache = self._cache - names = {service.name for service in cache.async_entries_with_server(record.name)} + names = { + service.name + for service in cache.async_entries_with_server(record.name) + } # Iterate through the DNSCache and callback any services that use this address for type_, name in self._names_matching_types(names): self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) @@ -699,7 +777,9 @@ def async_update_records_complete(self) -> None: self._fire_service_state_changed_event(pending) self._pending_handlers.clear() - def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: + def _fire_service_state_changed_event( + self, event: Tuple[Tuple[str, str], ServiceStateChange] + ) -> None: """Fire a service state changed event. When running with ServiceBrowser, this will happen in the dedicated @@ -721,7 +801,9 @@ def _async_cancel(self) -> None: self.done = True self.query_scheduler.stop() self.zc.async_remove_listener(self) - assert self._query_sender_task is not None, "Attempted to cancel a browser that was not started" + assert ( + self._query_sender_task is not None + ), "Attempted to cancel a browser that was not started" self._query_sender_task.cancel() self._query_sender_task = None @@ -741,7 +823,7 @@ class ServiceBrowser(_ServiceBrowserBase, threading.Thread): def __init__( self, - zc: 'Zeroconf', + zc: "Zeroconf", type_: Union[str, list], handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, listener: Optional[ServiceListener] = None, @@ -754,7 +836,9 @@ def __init__( if not zc.loop.is_running(): raise RuntimeError("The event loop is not running") threading.Thread.__init__(self) - super().__init__(zc, type_, handlers, listener, addr, port, delay, question_type) + super().__init__( + zc, type_, handlers, listener, addr, port, delay, question_type + ) # Add the queue before the listener is installed in _setup # to ensure that events run in the dedicated thread and do # not block the event loop @@ -763,8 +847,8 @@ def __init__( self.start() zc.loop.call_soon_threadsafe(self._async_start) self.name = "zeroconf-ServiceBrowser-{}-{}".format( - '-'.join([type_[:-7] for type_ in self.types]), - getattr(self, 'native_id', self.ident), + "-".join([type_[:-7] for type_ in self.types]), + getattr(self, "native_id", self.ident), ) def cancel(self) -> None: @@ -793,7 +877,7 @@ def async_update_records_complete(self) -> None: self.queue.put(pending) self._pending_handlers.clear() - def __enter__(self) -> 'ServiceBrowser': + def __enter__(self) -> "ServiceBrowser": return self def __exit__( # pylint: disable=useless-return diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 6d68de838..66313afc8 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -168,7 +168,7 @@ def __init__( port: Optional[int] = None, weight: int = 0, priority: int = 0, - properties: Union[bytes, Dict] = b'', + properties: Union[bytes, Dict] = b"", server: Optional[str] = None, host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL, @@ -179,11 +179,13 @@ def __init__( ) -> None: # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: - raise TypeError("addresses and parsed_addresses cannot be provided together") + raise TypeError( + "addresses and parsed_addresses cannot be provided together" + ) if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException self.interface_index = interface_index - self.text = b'' + self.text = b"" self.type = type_ self._name = name self.key = name.lower() @@ -249,7 +251,11 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - if IPADDRESS_SUPPORTS_SCOPE_ID and len(address) == 16 and self.interface_index is not None: + if ( + IPADDRESS_SUPPORTS_SCOPE_ID + and len(address) == 16 + and self.interface_index is not None + ): addr = ip_bytes_and_scope_to_address(address, self.interface_index) else: addr = cached_ip_addresses(address) @@ -293,7 +299,9 @@ def async_clear_cache(self) -> None: self._dns_text_cache = None self._get_address_and_nsec_records_cache = None - async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + async def async_wait( + self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: """Calling task waits for a given number of milliseconds or until notified.""" if not self._new_records_futures: self._new_records_futures = set() @@ -351,7 +359,10 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: This means the first address will always be the most recently added address of the given IP version. """ - return [str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] + return [ + str_without_scope_id(addr) + for addr in self._ip_addresses_by_version_value(version.value) + ] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local @@ -363,27 +374,31 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st This means the first address will always be the most recently added address of the given IP version. """ - return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] + return [ + str(addr) for addr in self._ip_addresses_by_version_value(version.value) + ] - def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: + def _set_properties( + self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] + ) -> None: """Sets properties and text of this info from a dictionary""" list_: List[bytes] = [] properties_contain_str = False - result = b'' + result = b"" for key, value in properties.items(): if isinstance(key, str): - key = key.encode('utf-8') + key = key.encode("utf-8") properties_contain_str = True record = key if value is not None: if not isinstance(value, bytes): - value = str(value).encode('utf-8') + value = str(value).encode("utf-8") properties_contain_str = True - record += b'=' + value + record += b"=" + value list_.append(record) for item in list_: - result = b''.join((result, bytes((len(item),)), item)) + result = b"".join((result, bytes((len(item),)), item)) if not properties_contain_str: # If there are no str keys or values, we can use the properties # as-is, without decoding them, otherwise calling @@ -406,7 +421,9 @@ def _set_text(self, text: bytes) -> None: def _generate_decoded_properties(self) -> None: """Generates decoded properties from the properties""" self._decoded_properties = { - k.decode("ascii", "replace"): None if v is None else v.decode("utf-8", "replace") + k.decode("ascii", "replace"): None + if v is None + else v.decode("utf-8", "replace") for k, v in self.properties.items() } @@ -426,7 +443,7 @@ def _unpack_text_into_properties(self) -> None: length = text[index] index += 1 key_value = text[index : index + length] - key_sep_value = key_value.partition(b'=') + key_sep_value = key_value.partition(b"=") key = key_sep_value[0] if key not in properties: properties[key] = key_sep_value[2] or None @@ -439,7 +456,7 @@ def get_name(self) -> str: return self._name[: len(self._name) - len(self.type) - 1] def _get_ip_addresses_from_cache_lifo( - self, zc: 'Zeroconf', now: float_, type: int_ + self, zc: "Zeroconf", now: float_, type: int_ ) -> List[Union[IPv4Address, IPv6Address]]: """Set IPv6 addresses from the cache.""" address_list: List[Union[IPv4Address, IPv6Address]] = [] @@ -452,25 +469,33 @@ def _get_ip_addresses_from_cache_lifo( address_list.reverse() # Reverse to get LIFO order return address_list - def _set_ipv6_addresses_from_cache(self, zc: 'Zeroconf', now: float_) -> None: + def _set_ipv6_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: self._ipv6_addresses = cast( - "List[IPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) + "List[IPv6Address]", + self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA), ) else: - self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) + self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo( + zc, now, _TYPE_AAAA + ) - def _set_ipv4_addresses_from_cache(self, zc: 'Zeroconf', now: float_) -> None: + def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: """Set IPv4 addresses from the cache.""" if TYPE_CHECKING: self._ipv4_addresses = cast( - "List[IPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) + "List[IPv4Address]", + self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A), ) else: - self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) + self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo( + zc, now, _TYPE_A + ) - def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] + ) -> None: """Updates service information from a DNS record. This method will be run in the event loop. @@ -482,7 +507,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[Record if updated and new_records_futures: _resolve_all_futures_to_none(new_records_futures) - def _process_record_threadsafe(self, zc: 'Zeroconf', record: DNSRecord, now: float_) -> bool: + def _process_record_threadsafe( + self, zc: "Zeroconf", record: DNSRecord, now: float_ + ) -> bool: """Thread safe record updating. Returns True if a new record was added. @@ -664,11 +691,15 @@ def _dns_text(self, override_ttl: Optional[int]) -> DNSText: self._dns_text_cache = record return record - def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) -> DNSNsec: + def dns_nsec( + self, missing_types: List[int], override_ttl: Optional[int] = None + ) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return self._dns_nsec(missing_types, override_ttl) - def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DNSNsec: + def _dns_nsec( + self, missing_types: List[int], override_ttl: Optional[int] + ) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -680,11 +711,15 @@ def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DN 0.0, ) - def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Set[DNSRecord]: + def get_address_and_nsec_records( + self, override_ttl: Optional[int] = None + ) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" return self._get_address_and_nsec_records(override_ttl) - def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSRecord]: + def _get_address_and_nsec_records( + self, override_ttl: Optional[int] + ) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" cacheable = override_ttl is None if self._get_address_and_nsec_records_cache is not None and cacheable: @@ -695,19 +730,26 @@ def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSR missing_types.discard(dns_address.type) records.add(dns_address) if missing_types: - assert self.server is not None, "Service server must be set for NSEC record." + assert ( + self.server is not None + ), "Service server must be set for NSEC record." records.add(self._dns_nsec(list(missing_types), override_ttl)) if cacheable: self._get_address_and_nsec_records_cache = records return records - def _get_address_records_from_cache_by_type(self, zc: 'Zeroconf', _type: int_) -> List[DNSAddress]: + def _get_address_records_from_cache_by_type( + self, zc: "Zeroconf", _type: int_ + ) -> List[DNSAddress]: """Get the addresses from the cache.""" if self.server_key is None: return [] cache = zc.cache if TYPE_CHECKING: - records = cast("List[DNSAddress]", cache.get_all_by_details(self.server_key, _type, _CLASS_IN)) + records = cast( + "List[DNSAddress]", + cache.get_all_by_details(self.server_key, _type, _CLASS_IN), + ) else: records = cache.get_all_by_details(self.server_key, _type, _CLASS_IN) return records @@ -721,14 +763,14 @@ def set_server_if_missing(self) -> None: self.server = self._name self.server_key = self.key - def load_from_cache(self, zc: 'Zeroconf', now: Optional[float_] = None) -> bool: + def load_from_cache(self, zc: "Zeroconf", now: Optional[float_] = None) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. """ return self._load_from_cache(zc, now or current_time_millis()) - def _load_from_cache(self, zc: 'Zeroconf', now: float_) -> bool: + def _load_from_cache(self, zc: "Zeroconf", now: float_) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. @@ -754,11 +796,13 @@ def _load_from_cache(self, zc: 'Zeroconf', now: float_) -> bool: @property def _is_complete(self) -> bool: """The ServiceInfo has all expected properties.""" - return bool(self.text is not None and (self._ipv4_addresses or self._ipv6_addresses)) + return bool( + self.text is not None and (self._ipv4_addresses or self._ipv6_addresses) + ) def request( self, - zc: 'Zeroconf', + zc: "Zeroconf", timeout: float, question_type: Optional[DNSQuestionType] = None, addr: Optional[str] = None, @@ -782,7 +826,9 @@ def request( raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") return bool( run_coro_with_timeout( - self.async_request(zc, timeout, question_type, addr, port), zc.loop, timeout + self.async_request(zc, timeout, question_type, addr, port), + zc.loop, + timeout, ) ) @@ -794,7 +840,7 @@ def _get_random_delay(self) -> int_: async def async_request( self, - zc: 'Zeroconf', + zc: "Zeroconf", timeout: float, question_type: Optional[DNSQuestionType] = None, addr: Optional[str] = None, @@ -837,7 +883,9 @@ async def async_request( if last <= now: return False if next_ <= now: - this_question_type = question_type or QU_QUESTION if first_request else QM_QUESTION + this_question_type = ( + question_type or QU_QUESTION if first_request else QM_QUESTION + ) out = self._generate_request_query(zc, now, this_question_type) first_request = False if out.questions: @@ -849,7 +897,10 @@ async def async_request( zc.async_send(out, addr, port) next_ = now + delay next_ += self._get_random_delay() - if this_question_type is QM_QUESTION and delay < _DUPLICATE_QUESTION_INTERVAL: + if ( + this_question_type is QM_QUESTION + and delay < _DUPLICATE_QUESTION_INTERVAL + ): # If we just asked a QM question, we need to # wait at least the duplicate question interval # before asking another QM question otherwise @@ -878,7 +929,9 @@ def _add_question_with_known_answers( ) -> None: """Add a question with known answers if its not suppressed.""" known_answers = { - answer for answer in cache.get_all_by_details(name, type_, class_) if not answer.is_stale(now) + answer + for answer in cache.get_all_by_details(name, type_, class_) + if not answer.is_stale(now) } if skip_if_known_answers and known_answers: return @@ -894,7 +947,7 @@ def _add_question_with_known_answers( out.add_answer_at_time(answer, now) def _generate_request_query( - self, zc: 'Zeroconf', now: float_, question_type: DNSQuestionType + self, zc: "Zeroconf", now: float_, question_type: DNSQuestionType ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) @@ -919,20 +972,20 @@ def _generate_request_query( def __repr__(self) -> str: """String representation""" - return '{}({})'.format( + return "{}({})".format( type(self).__name__, - ', '.join( - f'{name}={getattr(self, name)!r}' + ", ".join( + f"{name}={getattr(self, name)!r}" for name in ( - 'type', - 'name', - 'addresses', - 'port', - 'weight', - 'priority', - 'server', - 'properties', - 'interface_index', + "type", + "name", + "addresses", + "port", + "weight", + "priority", + "server", + "properties", + "interface_index", ) ), ) diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 261e8e9cd..2d4f3f8ec 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import Dict, List, Optional, Union @@ -79,7 +79,9 @@ def async_get_infos_server(self, server: str) -> List[ServiceInfo]: """Return all ServiceInfo matching server.""" return self._async_get_by_index(self.servers, server) - def _async_get_by_index(self, records: Dict[str, List], key: _str) -> List[ServiceInfo]: + def _async_get_by_index( + self, records: Dict[str, List], key: _str + ) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" record_list = records.get(key) if record_list is None: diff --git a/src/zeroconf/_services/types.py b/src/zeroconf/_services/types.py index 70db2d609..9793ae481 100644 --- a/src/zeroconf/_services/types.py +++ b/src/zeroconf/_services/types.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import time @@ -69,7 +69,9 @@ def find( """ local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) listener = cls() - browser = ServiceBrowser(local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener) + browser = ServiceBrowser( + local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener + ) # wait for responses time.sleep(timeout) diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index c37af2efd..f28c0029e 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -29,11 +29,11 @@ class _WrappedTransport: """A wrapper for transports.""" __slots__ = ( - 'transport', - 'is_ipv6', - 'sock', - 'fileno', - 'sock_name', + "transport", + "is_ipv6", + "sock", + "fileno", + "sock_name", ) def __init__( @@ -57,7 +57,7 @@ def __init__( def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: """Make a wrapped transport.""" - sock: socket.socket = transport.get_extra_info('socket') + sock: socket.socket = transport.get_extra_info("socket") return _WrappedTransport( transport=transport, is_ipv6=sock.family == socket.AF_INET6, diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index 42fa82850..eda89df4f 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from typing import TYPE_CHECKING, List @@ -40,16 +40,20 @@ class RecordUpdateListener: """ def update_record( # pylint: disable=no-self-use - self, zc: 'Zeroconf', now: float, record: DNSRecord + self, zc: "Zeroconf", now: float, record: DNSRecord ) -> None: """Update a single record. This method is deprecated and will be removed in a future version. update_records should be implemented instead. """ - raise RuntimeError("update_record is deprecated and will be removed in a future version.") + raise RuntimeError( + "update_record is deprecated and will be removed in a future version." + ) - def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] + ) -> None: """Update multiple records in one shot. All records that are received in a single packet are passed diff --git a/src/zeroconf/_utils/__init__.py b/src/zeroconf/_utils/__init__.py index 2ef4b15b1..30920c6aa 100644 --- a/src/zeroconf/_utils/__init__.py +++ b/src/zeroconf/_utils/__init__.py @@ -1,21 +1,21 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index 358ef37ea..c2e66277c 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import asyncio @@ -29,7 +29,7 @@ if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout else: - from asyncio import timeout as asyncio_timeout + from asyncio import timeout as asyncio_timeout # type: ignore[attr-defined] from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT @@ -60,7 +60,9 @@ async def wait_for_future_set_or_timeout( """Wait for a future or timeout (in milliseconds).""" future = loop.create_future() future_set.add(future) - handle = loop.call_later(millis_to_seconds(timeout), _set_future_none_if_not_done, future) + handle = loop.call_later( + millis_to_seconds(timeout), _set_future_none_if_not_done, future + ) try: await future finally: @@ -98,7 +100,9 @@ async def await_awaitable(aw: Awaitable) -> None: await task -def run_coro_with_timeout(aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float) -> Any: +def run_coro_with_timeout( + aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float +) -> Any: """Run a coroutine with a timeout. The timeout should only be used as a safeguard to prevent @@ -120,13 +124,15 @@ def run_coro_with_timeout(aw: Coroutine, loop: asyncio.AbstractEventLoop, timeou def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" pending_tasks = set( - asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result(_GET_ALL_TASKS_TIMEOUT) + asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result( + _GET_ALL_TASKS_TIMEOUT + ) ) pending_tasks -= {task for task in pending_tasks if task.done()} if pending_tasks: - asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result( - _WAIT_FOR_LOOP_TASKS_TIMEOUT - ) + asyncio.run_coroutine_threadsafe( + _wait_for_loop_tasks(pending_tasks), loop + ).result(_WAIT_FOR_LOOP_TASKS_TIMEOUT) loop.call_soon_threadsafe(loop.stop) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index ba1379551..d4ba708e2 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -1,24 +1,25 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ + import sys from functools import lru_cache from ipaddress import AddressValueError, IPv4Address, IPv6Address, NetmaskValueError @@ -33,7 +34,6 @@ class ZeroconfIPv4Address(IPv4Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -59,7 +59,6 @@ def is_unspecified(self) -> bool: class ZeroconfIPv6Address(IPv6Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -85,7 +84,9 @@ def is_unspecified(self) -> bool: @lru_cache(maxsize=512) -def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4Address, IPv6Address]]: +def _cached_ip_addresses( + address: Union[str, bytes, int], +) -> Optional[Union[IPv4Address, IPv6Address]]: """Cache IP addresses.""" try: return ZeroconfIPv4Address(address) @@ -102,19 +103,25 @@ def _cached_ip_addresses(address: Union[str, bytes, int]) -> Optional[Union[IPv4 cached_ip_addresses = cached_ip_addresses_wrapper -def get_ip_address_object_from_record(record: DNSAddress) -> Optional[Union[IPv4Address, IPv6Address]]: +def get_ip_address_object_from_record( + record: DNSAddress, +) -> Optional[Union[IPv4Address, IPv6Address]]: """Get the IP address object from the record.""" if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id: return ip_bytes_and_scope_to_address(record.address, record.scope_id) return cached_ip_addresses_wrapper(record.address) -def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: +def ip_bytes_and_scope_to_address( + address: bytes_, scope: int_ +) -> Optional[Union[IPv4Address, IPv6Address]]: """Convert the bytes and scope to an IP address object.""" base_address = cached_ip_addresses_wrapper(address) if base_address is not None and base_address.is_link_local: # Avoid expensive __format__ call by using PyUnicode_Join - return cached_ip_addresses_wrapper("".join((str(base_address), "%", str(scope)))) + return cached_ip_addresses_wrapper( + "".join((str(base_address), "%", str(scope))) + ) return base_address @@ -122,7 +129,7 @@ def str_without_scope_id(addr: Union[IPv4Address, IPv6Address]) -> str: """Return the string representation of the address without the scope id.""" if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: address_str = str(addr) - return address_str.partition('%')[0] + return address_str.partition("%")[0] return str(addr) diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index adccb3e5e..3f923cfde 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ from functools import lru_cache @@ -83,7 +83,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis raise BadTypeInNameException("Full name (%s) must be > 256 bytes" % type_) if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): - remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split('.') + remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split(".") trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] has_protocol = True elif strict: @@ -92,7 +92,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis % (type_, _TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER) ) elif type_.endswith(_LOCAL_TRAILER): - remaining = type_[: -len(_LOCAL_TRAILER)].split('.') + remaining = type_[: -len(_LOCAL_TRAILER)].split(".") trailer = type_[-len(_LOCAL_TRAILER) + 1 :] has_protocol = False else: @@ -106,56 +106,67 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis if len(remaining) == 1 and len(remaining[0]) == 0: raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) - if service_name[0] != '_': - raise BadTypeInNameException("Service name (%s) must start with '_'" % service_name) + if service_name[0] != "_": + raise BadTypeInNameException( + "Service name (%s) must start with '_'" % service_name + ) test_service_name = service_name[1:] if strict and len(test_service_name) > 15: # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 - raise BadTypeInNameException("Service name (%s) must be <= 15 bytes" % test_service_name) + raise BadTypeInNameException( + "Service name (%s) must be <= 15 bytes" % test_service_name + ) - if '--' in test_service_name: - raise BadTypeInNameException("Service name (%s) must not contain '--'" % test_service_name) + if "--" in test_service_name: + raise BadTypeInNameException( + "Service name (%s) must not contain '--'" % test_service_name + ) - if '-' in (test_service_name[0], test_service_name[-1]): + if "-" in (test_service_name[0], test_service_name[-1]): raise BadTypeInNameException( "Service name (%s) may not start or end with '-'" % test_service_name ) if not _HAS_A_TO_Z.search(test_service_name): raise BadTypeInNameException( - "Service name (%s) must contain at least one letter (eg: 'A-Z')" % test_service_name + "Service name (%s) must contain at least one letter (eg: 'A-Z')" + % test_service_name ) allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN if strict else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE + _HAS_ONLY_A_TO_Z_NUM_HYPHEN + if strict + else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE ) if not allowed_characters_re.search(test_service_name): raise BadTypeInNameException( "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" % (test_service_name, "" if strict else ", underscore ('_')") + "A-Z, a-z, 0-9, hyphen ('-')%s" + % (test_service_name, "" if strict else ", underscore ('_')") ) else: - service_name = '' + service_name = "" - if remaining and remaining[-1] == '_sub': + if remaining and remaining[-1] == "_sub": remaining.pop() if len(remaining) == 0 or len(remaining[0]) == 0: raise BadTypeInNameException("_sub requires a subtype name") if len(remaining) > 1: - remaining = ['.'.join(remaining)] + remaining = [".".join(remaining)] if remaining: - length = len(remaining[0].encode('utf-8')) + length = len(remaining[0].encode("utf-8")) if length > 63: raise BadTypeInNameException("Too long: '%s'" % remaining[0]) if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): raise BadTypeInNameException( - "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" % remaining[0] + "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" + % remaining[0] ) return service_name + trailer @@ -163,14 +174,14 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis def possible_types(name: str) -> Set[str]: """Build a set of all possible types from a fully qualified name.""" - labels = name.split('.') + labels = name.split(".") label_count = len(labels) types = set() for count in range(label_count): parts = labels[label_count - count - 4 :] - if not parts[0].startswith('_'): + if not parts[0].startswith("_"): break - types.add('.'.join(parts)) + types.add(".".join(parts)) return types diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index cc4754abc..fbac9fe73 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import enum @@ -40,7 +40,9 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] +InterfacesType = Union[ + Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice +] @enum.unique @@ -65,37 +67,59 @@ def _is_v6_address(addr: bytes) -> bool: def _encode_address(address: str) -> bytes: - is_ipv6 = ':' in address + is_ipv6 = ":" in address address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET return socket.inet_pton(address_family, address) def get_all_addresses() -> List[str]: - return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) + return list( + { + addr.ip + for iface in ifaddr.get_adapters() + for addr in iface.ips + if addr.is_IPv4 + } + ) def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( - {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} + { + (addr.ip, iface.index) + for iface in ifaddr.get_adapters() + for addr in iface.ips + if addr.is_IPv6 + } ) -def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: - if '%' in ip: - ip = ip[: ip.index('%')] # Strip scope_id. +def ip6_to_address_and_index( + adapters: List[Any], ip: str +) -> Tuple[Tuple[str, int, int], int]: + if "%" in ip: + ip = ip[: ip.index("%")] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples - if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index)) + if ( + isinstance(adapter_ip.ip, tuple) + and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr + ): + return ( + cast(Tuple[str, int, int], adapter_ip.ip), + cast(int, adapter.index), + ) - raise RuntimeError('No adapter found for IP address %s' % ip) + raise RuntimeError("No adapter found for IP address %s" % ip) -def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: +def interface_index_to_ip6_address( + adapters: List[Any], index: int +) -> Tuple[str, int, int]: for adapter in adapters: if adapter.index == index: for adapter_ip in adapter.ips: @@ -103,11 +127,11 @@ def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str if isinstance(adapter_ip.ip, tuple): return cast(Tuple[str, int, int], adapter_ip.ip) - raise RuntimeError('No adapter found for index %s' % index) + raise RuntimeError("No adapter found for index %s" % index) def ip6_addresses_to_indexes( - interfaces: Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]] + interfaces: Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], ) -> List[Tuple[Tuple[str, int, int], int]]: """Convert IPv6 interface addresses to interface indexes. @@ -141,9 +165,9 @@ def normalize_interface_choice( if choice is InterfaceChoice.Default: if ip_version != IPVersion.V4Only: # IPv6 multicast uses interface 0 to mean the default - result.append((('', 0, 0), 0)) + result.append((("", 0, 0), 0)) if ip_version != IPVersion.V6Only: - result.append('0.0.0.0') + result.append("0.0.0.0") elif choice is InterfaceChoice.All: if ip_version != IPVersion.V4Only: result.extend(get_all_addresses_v6()) @@ -151,11 +175,16 @@ def normalize_interface_choice( result.extend(get_all_addresses()) if not result: raise RuntimeError( - 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version + "No interfaces to listen on, check that any interfaces have IP version %s" + % ip_version ) elif isinstance(choice, list): # First, take IPv4 addresses. - result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] + result = [ + i + for i in choice + if isinstance(i, str) and ipaddress.ip_address(i).version == 4 + ] # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. result += ip6_addresses_to_indexes(choice) else: @@ -168,7 +197,9 @@ def disable_ipv6_only_or_raise(s: socket.socket) -> None: try: s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) except OSError: - log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6') + log.error( + "Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6" + ) raise @@ -181,7 +212,7 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: # versions of Python have SO_REUSEPORT available. # Catch OSError and socket.error for kernel versions <3.9 because lacking # SO_REUSEPORT support. - if not hasattr(socket, 'SO_REUSEPORT'): + if not hasattr(socket, "SO_REUSEPORT"): return try: @@ -192,19 +223,23 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: def set_mdns_port_socket_options_for_ip_version( - s: socket.socket, bind_addr: Union[Tuple[str], Tuple[str, int, int]], ip_version: IPVersion + s: socket.socket, + bind_addr: Union[Tuple[str], Tuple[str, int, int]], + ip_version: IPVersion, ) -> None: """Set ttl/hops and loop for mdns port.""" if ip_version != IPVersion.V6Only: - ttl = struct.pack(b'B', 255) - loop = struct.pack(b'B', 1) + ttl = struct.pack(b"B", 255) + loop = struct.pack(b"B", 1) # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and # IP_MULTICAST_LOOP socket options as an unsigned char. try: s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) except OSError as e: - if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS + if ( + bind_addr[0] != "" or get_errno(e) != errno.EINVAL + ): # Fails to set on MacOS raise if ip_version != IPVersion.V4Only: @@ -220,13 +255,15 @@ def new_socket( apple_p2p: bool = False, ) -> Optional[socket.socket]: log.debug( - 'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r', + "Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r", port, ip_version, apple_p2p, bind_addr, ) - socket_family = socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6 + socket_family = ( + socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6 + ) s = socket.socket(socket_family, socket.SOCK_DGRAM) if ip_version == IPVersion.All: @@ -249,12 +286,13 @@ def new_socket( except OSError as ex: if ex.errno == errno.EADDRNOTAVAIL: log.warning( - 'Address not available when binding to %s, ' 'it is expected to happen on some systems', + "Address not available when binding to %s, " + "it is expected to happen on some systems", bind_tup, ) return None raise - log.debug('Created socket %s', s) + log.debug("Created socket %s", s) return s @@ -265,57 +303,66 @@ def add_multicast_member( # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, tuple) err_einval = {errno.EINVAL} - if sys.platform == 'win32': + if sys.platform == "win32": # No WSAEINVAL definition in typeshed err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member - log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno()) + log.debug( + "Adding %r (socket %d) to multicast group", interface, listen_socket.fileno() + ) try: if is_v6: try: mdns_addr6_bytes = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) except OSError: log.info( - 'Unable to translate IPv6 address when adding %s to multicast group, ' - 'this can happen if IPv6 is disabled on the system', + "Unable to translate IPv6 address when adding %s to multicast group, " + "this can happen if IPv6 is disabled on the system", interface, ) return False - iface_bin = struct.pack('@I', cast(int, interface[1])) + iface_bin = struct.pack("@I", cast(int, interface[1])) _value = mdns_addr6_bytes + iface_bin listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) else: - _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface)) - listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) + _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton( + cast(str, interface) + ) + listen_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value + ) except OSError as e: _errno = get_errno(e) if _errno == errno.EADDRINUSE: log.info( - 'Address in use when adding %s to multicast group, ' - 'it is expected to happen on some systems', + "Address in use when adding %s to multicast group, " + "it is expected to happen on some systems", interface, ) return False if _errno == errno.EADDRNOTAVAIL: log.info( - 'Address not available when adding %s to multicast ' - 'group, it is expected to happen on some systems', + "Address not available when adding %s to multicast " + "group, it is expected to happen on some systems", interface, ) return False if _errno in err_einval: - log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface) + log.info( + "Interface of %s does not support multicast, " "it is expected in WSL", + interface, + ) return False if _errno == errno.ENOPROTOOPT: log.info( - 'Failed to set socket option on %s, this can happen if ' - 'the network adapter is in a disconnected state', + "Failed to set socket option on %s, this can happen if " + "the network adapter is in a disconnected state", interface, ) return False if is_v6 and _errno == errno.ENODEV: log.info( - 'Address in use when adding %s to multicast group, ' - 'it is expected to happen when the device does not have ipv6', + "Address in use when adding %s to multicast group, " + "it is expected to happen when the device does not have ipv6", interface, ) return False @@ -331,17 +378,23 @@ def new_respond_socket( respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p, - bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), + bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] + if is_v6 + else (cast(str, interface),), ) if not respond_socket: return None - log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface) + log.debug( + "Configuring socket %s with multicast interface %s", respond_socket, interface + ) if is_v6: - iface_bin = struct.pack('@I', cast(int, interface[1])) + iface_bin = struct.pack("@I", cast(int, interface[1])) respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) else: respond_socket.setsockopt( - socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface)) + socket.IPPROTO_IP, + socket.IP_MULTICAST_IF, + socket.inet_aton(cast(str, interface)), ) return respond_socket @@ -355,7 +408,9 @@ def create_sockets( if unicast: listen_socket = None else: - listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=('',)) + listen_socket = new_socket( + ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",) + ) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) @@ -406,10 +461,14 @@ def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: """Auto detect the IP version when it is not provided.""" if isinstance(interfaces, list): has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + isinstance(i, int) + or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + for i in interfaces + ) + has_v4 = any( + isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces ) - has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) if has_v4 and has_v6: return IPVersion.All if has_v6: diff --git a/src/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py index 600d90285..2ed8ca925 100644 --- a/src/zeroconf/_utils/time.py +++ b/src/zeroconf/_utils/time.py @@ -1,26 +1,25 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ - import time _float = float diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index b2daeb10f..c2a51f940 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -1,24 +1,25 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ + import asyncio import contextlib from types import TracebackType # noqa # used in type hints @@ -62,7 +63,7 @@ class AsyncServiceBrowser(_ServiceBrowserBase): def __init__( self, - zeroconf: 'Zeroconf', + zeroconf: "Zeroconf", type_: Union[str, list], handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, listener: Optional[ServiceListener] = None, @@ -71,14 +72,16 @@ def __init__( delay: int = _BROWSER_TIME, question_type: Optional[DNSQuestionType] = None, ) -> None: - super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type) + super().__init__( + zeroconf, type_, handlers, listener, addr, port, delay, question_type + ) self._async_start() async def async_cancel(self) -> None: """Cancel the browser.""" self._async_cancel() - async def __aenter__(self) -> 'AsyncServiceBrowser': + async def __aenter__(self) -> "AsyncServiceBrowser": return self async def __aexit__( @@ -97,7 +100,7 @@ class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): @classmethod async def async_find( cls, - aiozc: Optional['AsyncZeroconf'] = None, + aiozc: Optional["AsyncZeroconf"] = None, timeout: Union[int, float] = 5, interfaces: InterfacesType = InterfaceChoice.All, ip_version: Optional[IPVersion] = None, @@ -231,7 +234,11 @@ async def async_close(self) -> None: await self.zeroconf._async_close() # pylint: disable=protected-access async def async_get_service_info( - self, type_: str, name: str, timeout: int = 3000, question_type: Optional[DNSQuestionType] = None + self, + type_: str, + name: str, + timeout: int = 3000, + question_type: Optional[DNSQuestionType] = None, ) -> Optional[AsyncServiceInfo]: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, @@ -242,14 +249,20 @@ async def async_get_service_info( :param timeout: milliseconds to wait for a response :param question_type: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) """ - return await self.zeroconf.async_get_service_info(type_, name, timeout, question_type) + return await self.zeroconf.async_get_service_info( + type_, name, timeout, question_type + ) - async def async_add_service_listener(self, type_: str, listener: ServiceListener) -> None: + async def async_add_service_listener( + self, type_: str, listener: ServiceListener + ) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when services of that type become available and unavailable.""" await self.async_remove_service_listener(listener) - self.async_browsers[listener] = AsyncServiceBrowser(self.zeroconf, type_, listener) + self.async_browsers[listener] = AsyncServiceBrowser( + self.zeroconf, type_, listener + ) async def async_remove_service_listener(self, listener: ServiceListener) -> None: """Removes a listener from the set that is currently listening.""" @@ -260,10 +273,13 @@ async def async_remove_service_listener(self, listener: ServiceListener) -> None async def async_remove_all_service_listeners(self) -> None: """Removes a listener from the set that is currently listening.""" await asyncio.gather( - *(self.async_remove_service_listener(listener) for listener in list(self.async_browsers)) + *( + self.async_remove_service_listener(listener) + for listener in list(self.async_browsers) + ) ) - async def __aenter__(self) -> 'AsyncZeroconf': + async def __aenter__(self) -> "AsyncZeroconf": return self async def __aexit__( diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 73c60d3b6..6c64e144d 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -1,23 +1,23 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ import re @@ -31,7 +31,9 @@ _LISTENER_TIME = 200 # ms _BROWSER_TIME = 10000 # ms _DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 # ms -_DUPLICATE_QUESTION_INTERVAL = 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL +_DUPLICATE_QUESTION_INTERVAL = ( + 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL +) _CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s _STARTUP_TIMEOUT = 9 # s must be lower than _LOADED_SYSTEM_TIMEOUT @@ -45,8 +47,8 @@ # Some DNS constants -_MDNS_ADDR = '224.0.0.251' -_MDNS_ADDR6 = 'ff02::fb' +_MDNS_ADDR = "224.0.0.251" +_MDNS_ADDR6 = "ff02::fb" _MDNS_PORT = 5353 _DNS_PORT = 53 _DNS_HOST_TTL = 120 # two minute for host records (A, SRV etc) as-per RFC6762 @@ -142,16 +144,16 @@ _ADDRESS_RECORD_TYPES = {_TYPE_A, _TYPE_AAAA} -_HAS_A_TO_Z = re.compile(r'[A-Za-z]') -_HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r'^[A-Za-z0-9\-]+$') -_HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r'^[A-Za-z0-9\-\_]+$') -_HAS_ASCII_CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]') +_HAS_A_TO_Z = re.compile(r"[A-Za-z]") +_HAS_ONLY_A_TO_Z_NUM_HYPHEN = re.compile(r"^[A-Za-z0-9\-]+$") +_HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE = re.compile(r"^[A-Za-z0-9\-\_]+$") +_HAS_ASCII_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") _EXPIRE_REFRESH_TIME_PERCENT = 75 -_LOCAL_TRAILER = '.local.' -_TCP_PROTOCOL_LOCAL_TRAILER = '._tcp.local.' -_NONTCP_PROTOCOL_LOCAL_TRAILER = '._udp.local.' +_LOCAL_TRAILER = ".local." +_TCP_PROTOCOL_LOCAL_TRAILER = "._tcp.local." +_NONTCP_PROTOCOL_LOCAL_TRAILER = "._udp.local." # https://datatracker.ietf.org/doc/html/rfc6763#section-9 _SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local." diff --git a/tests/__init__.py b/tests/__init__.py index cbba60731..1feebafb4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,24 +1,25 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - - This module provides a framework for the use of DNS Service Discovery - using IP multicast. - - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + +This module provides a framework for the use of DNS Service Discovery +using IP multicast. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ + import asyncio import socket import time @@ -35,7 +36,9 @@ class QuestionHistoryWithoutSuppression(QuestionHistory): - def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + def suppresses( + self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord] + ) -> bool: return False @@ -70,7 +73,7 @@ def has_working_ipv6(): sock = None try: sock = socket.socket(socket.AF_INET6) - sock.bind(('::1', 0)) + sock.bind(("::1", 0)) except Exception: return False finally: @@ -99,7 +102,6 @@ def time_changed_millis(millis: Optional[float] = None) -> None: mock_seconds_into_future = loop_time with mock.patch("time.monotonic", return_value=mock_seconds_into_future): - for task in list(loop._scheduled): # type: ignore[attr-defined] if not isinstance(task, asyncio.TimerHandle): continue diff --git a/tests/conftest.py b/tests/conftest.py index 5525c4ee0..5dfd900f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" conftest for zeroconf tests. """ +"""conftest for zeroconf tests.""" import threading from unittest.mock import patch diff --git a/tests/services/__init__.py b/tests/services/__init__.py index 2ef4b15b1..30920c6aa 100644 --- a/tests/services/__init__.py +++ b/tests/services/__init__.py @@ -1,21 +1,21 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 37896ba1d..17950683d 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._services.browser. """ +"""Unit tests for zeroconf._services.browser.""" import asyncio import logging @@ -39,7 +39,7 @@ time_changed_millis, ) -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -65,7 +65,7 @@ def test_service_browser_cancel_multiple_times(): """Test we can cancel a ServiceBrowser multiple times before close.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." @@ -87,7 +87,7 @@ def test_service_browser_cancel_context_manager(): """Test we can cancel a ServiceBrowser with it being used as a context manager.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." @@ -116,7 +116,7 @@ def test_service_browser_cancel_multiple_times_after_close(): """Test we can cancel a ServiceBrowser multiple times after close.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." @@ -137,7 +137,7 @@ class MyServiceListener(r.ServiceListener): def test_service_browser_started_after_zeroconf_closed(): """Test starting a ServiceBrowser after close raises RuntimeError.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." @@ -155,9 +155,9 @@ def test_multiple_instances_running_close(): """Test we can shutdown multiple instances.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - zc2 = Zeroconf(interfaces=['127.0.0.1']) - zc3 = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) + zc2 = Zeroconf(interfaces=["127.0.0.1"]) + zc3 = Zeroconf(interfaces=["127.0.0.1"]) assert zc.loop != zc2.loop assert zc.loop != zc3.loop @@ -177,13 +177,13 @@ class MyServiceListener(r.ServiceListener): class TestServiceBrowser(unittest.TestCase): def test_update_record(self): - enable_ipv6 = has_working_ipv6() and not os.environ.get('SKIP_IPV6') + enable_ipv6 = has_working_ipv6() and not os.environ.get("SKIP_IPV6") - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." + service_text = b"path=/~matt1/" + service_address = "10.0.1.2" service_v6_address = "2001:db8::1" service_v6_second_address = "6001:db8::1" @@ -221,7 +221,9 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de assert service_info.server.lower() == service_server.lower() service_updated_event.set() - def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + def mock_record_update_incoming_msg( + service_state_change: r.ServiceStateChange, + ) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) assert generated.is_response() is True @@ -232,7 +234,11 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) generated.add_answer_at_time( r.DNSText( - service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, ), 0, ) @@ -287,19 +293,26 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) ) generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + r.DNSPointer( + service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name + ), + 0, ) return r.DNSIncoming(generated.packets()[0]) - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) + service_browser = r.ServiceBrowser( + zeroconf, service_type, listener=MyServiceListener() + ) try: wait_time = 3 # service added - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Added)) + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Added) + ) service_add_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 0 @@ -307,8 +320,10 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) # service SRV updated service_updated_event.clear() - service_server = 'ash-2.local.' - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) + service_server = "ash-2.local." + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) + ) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 1 @@ -316,8 +331,10 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) # service TXT updated service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) + service_text = b"path=/~matt2/" + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) + ) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -325,8 +342,10 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) # service TXT updated - duplicate update should not trigger another service_updated service_updated_event.clear() - service_text = b'path=/~matt2/' - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) + service_text = b"path=/~matt2/" + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) + ) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -334,10 +353,12 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) # service A updated service_updated_event.clear() - service_address = '10.0.1.3' + service_address = "10.0.1.3" # Verify we match on uppercase service_server = service_server.upper() - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) + ) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 3 @@ -345,17 +366,21 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) # service all updated service_updated_event.clear() - service_server = 'ash-3.local.' - service_text = b'path=/~matt3/' - service_address = '10.0.1.3' - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) + service_server = "ash-3.local." + service_text = b"path=/~matt3/" + service_address = "10.0.1.3" + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) + ) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 assert service_removed_count == 0 # service removed - _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Removed)) + _inject_response( + zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Removed) + ) service_removed_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 @@ -372,8 +397,12 @@ def mock_record_update_incoming_msg(service_state_change: r.ServiceStateChange) class TestServiceBrowserMultipleTypes(unittest.TestCase): def test_update_record(self): - service_names = ['name2._type2._tcp.local.', 'name._type._tcp.local.', 'name._type._udp.local'] - service_types = ['_type2._tcp.local.', '_type._tcp.local.', '_type._udp.local.'] + service_names = [ + "name2._type2._tcp.local.", + "name._type._tcp.local.", + "name._type._udp.local", + ] + service_types = ["_type2._tcp.local.", "_type._tcp.local.", "_type._udp.local."] service_added_count = 0 service_removed_count = 0 @@ -394,16 +423,24 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de service_removed_event.set() def mock_record_update_incoming_msg( - service_state_change: r.ServiceStateChange, service_type: str, service_name: str, ttl: int + service_state_change: r.ServiceStateChange, + service_type: str, + service_name: str, + ttl: int, ) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + r.DNSPointer( + service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name + ), + 0, ) return r.DNSIncoming(generated.packets()[0]) - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) - service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) + service_browser = r.ServiceBrowser( + zeroconf, service_types, listener=MyServiceListener() + ) try: wait_time = 3 @@ -433,11 +470,16 @@ def _mock_get_expiration_time(self, percent): return self.created + (percent * self.ttl * 10) # Set an expire time that will force a refresh - with patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): + with patch( + "zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time + ): _inject_response( zeroconf, mock_record_update_incoming_msg( - r.ServiceStateChange.Added, service_types[0], service_names[0], 120 + r.ServiceStateChange.Added, + service_types[0], + service_names[0], + 120, ), ) # Add the last record after updating the first one @@ -446,7 +488,10 @@ def _mock_get_expiration_time(self, percent): _inject_response( zeroconf, mock_record_update_incoming_msg( - r.ServiceStateChange.Added, service_types[2], service_names[2], 120 + r.ServiceStateChange.Added, + service_types[2], + service_names[2], + 120, ), ) service_add_event.wait(wait_time) @@ -502,7 +547,7 @@ def test_first_query_delay(): https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 """ type_ = "_http._tcp.local." - zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + zeroconf_browser = Zeroconf(interfaces=["127.0.0.1"]) _wait_for_start(zeroconf_browser) # we are going to patch the zeroconf send to check query transmission @@ -525,10 +570,15 @@ def on_service_state_change(zeroconf, service_type, state_change, name): start_time = current_time_millis() browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + time.sleep( + millis_to_seconds( + _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 + ) + ) try: assert ( - current_time_millis() - start_time > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] + current_time_millis() - start_time + > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] ) finally: browser.cancel() @@ -553,7 +603,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): elif state_change is ServiceStateChange.Removed: service_removed.set() - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() @@ -573,7 +623,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(zeroconf_browser.engine.protocols) == 2 - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + aio_zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_registrar = aio_zeroconf_registrar.zeroconf await aio_zeroconf_registrar.zeroconf.async_wait_for_start() @@ -583,14 +633,16 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = AsyncServiceBrowser( + zeroconf_browser, type_, [on_service_state_change] + ) info = ServiceInfo( type_, registration_name, 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -655,7 +707,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): elif state_change is ServiceStateChange.Removed: service_removed.set() - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() @@ -675,7 +727,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(zeroconf_browser.engine.protocols) == 2 - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + aio_zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_registrar = aio_zeroconf_registrar.zeroconf await aio_zeroconf_registrar.zeroconf.async_wait_for_start() @@ -685,14 +737,16 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = AsyncServiceBrowser( + zeroconf_browser, type_, [on_service_state_change] + ) info = ServiceInfo( type_, registration_name, 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -751,7 +805,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): async def test_asking_qm_questions(): """Verify explictly asking QM questions.""" type_ = "_quservice._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf await zeroconf_browser.async_wait_for_start() # we are going to patch the zeroconf send to check query transmission @@ -773,9 +827,16 @@ def on_service_state_change(zeroconf, service_type, state_change, name): pass browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QM + zeroconf_browser, + type_, + [on_service_state_change], + question_type=r.DNSQuestionType.QM, + ) + await asyncio.sleep( + millis_to_seconds( + _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 + ) ) - await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] finally: @@ -787,7 +848,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): async def test_asking_qu_questions(): """Verify the service browser can ask QU questions.""" type_ = "_quservice._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf await zeroconf_browser.async_wait_for_start() @@ -810,9 +871,16 @@ def on_service_state_change(zeroconf, service_type, state_change, name): pass browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change], question_type=r.DNSQuestionType.QU + zeroconf_browser, + type_, + [on_service_state_change], + question_type=r.DNSQuestionType.QU, + ) + await asyncio.sleep( + millis_to_seconds( + _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 + ) ) - await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] finally: @@ -824,11 +892,15 @@ def test_legacy_record_update_listener(): """Test a RecordUpdateListener that does not implement update_records.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) with pytest.raises(RuntimeError): r.RecordUpdateListener().update_record( - zc, 0, r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) + zc, + 0, + r.DNSRecord( + "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL + ), ) updates = [] @@ -836,7 +908,9 @@ def test_legacy_record_update_listener(): class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def update_record(self, zc: 'Zeroconf', now: float, record: r.DNSRecord) -> None: + def update_record( + self, zc: "Zeroconf", now: float, record: r.DNSRecord + ) -> None: nonlocal updates updates.append(record) @@ -855,11 +929,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -871,7 +945,15 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() assert len(updates) - assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + assert ( + len( + [ + isinstance(update, r.DNSPointer) and update.name == type_ + for update in updates + ] + ) + >= 1 + ) zc.remove_listener(listener) # Removing a second time should not throw @@ -884,7 +966,7 @@ def test_service_browser_is_aware_of_port_changes(): """Test that the ServiceBrowser is aware of port changes.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -900,18 +982,29 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser = ServiceBrowser(zc, type_, [on_service_state_change]) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) _inject_response( zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), ) time.sleep(0.1) - assert callbacks == [('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.')] + assert callbacks == [ + ("_hap._tcp.local.", ServiceStateChange.Added, "xxxyyy._hap._tcp.local.") + ] service_info = zc.get_service_info(type_, registration_name) assert service_info is not None assert service_info.port == 80 @@ -926,8 +1019,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): time.sleep(0.1) assert callbacks == [ - ('_hap._tcp.local.', ServiceStateChange.Added, 'xxxyyy._hap._tcp.local.'), - ('_hap._tcp.local.', ServiceStateChange.Updated, 'xxxyyy._hap._tcp.local.'), + ("_hap._tcp.local.", ServiceStateChange.Added, "xxxyyy._hap._tcp.local."), + ("_hap._tcp.local.", ServiceStateChange.Updated, "xxxyyy._hap._tcp.local."), ] service_info = zc.get_service_info(type_, registration_name) assert service_info is not None @@ -941,7 +1034,7 @@ def test_service_browser_listeners_update_service(): """Test that the ServiceBrowser ServiceListener that implements update_service.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -967,14 +1060,23 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de browser = r.ServiceBrowser(zc, type_, None, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) _inject_response( zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), ) time.sleep(0.2) info._dns_service_cache = None # we are mutating the record so clear the cache @@ -987,8 +1089,8 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de time.sleep(0.2) assert callbacks == [ - ('add', type_, registration_name), - ('update', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), ] browser.cancel() @@ -999,7 +1101,7 @@ def test_service_browser_listeners_no_update_service(): """Test that the ServiceBrowser ServiceListener that does not implement update_service.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -1020,14 +1122,23 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de browser = r.ServiceBrowser(zc, type_, None, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) _inject_response( zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), ) time.sleep(0.2) info.port = 400 @@ -1040,7 +1151,7 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de time.sleep(0.2) assert callbacks == [ - ('add', type_, registration_name), + ("add", type_, registration_name), ] browser.cancel() @@ -1054,13 +1165,17 @@ def test_service_browser_uses_non_strict_names(): def on_service_state_change(zeroconf, service_type, state_change, name): pass - zc = r.Zeroconf(interfaces=['127.0.0.1']) - browser = ServiceBrowser(zc, ["_tivo-videostream._tcp.local."], [on_service_state_change]) + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + browser = ServiceBrowser( + zc, ["_tivo-videostream._tcp.local."], [on_service_state_change] + ) browser.cancel() # Still fail on completely invalid with pytest.raises(r.BadTypeInNameException): - browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) + browser = ServiceBrowser( + zc, ["tivo-videostream._tcp.local."], [on_service_state_change] + ) zc.close() @@ -1069,7 +1184,9 @@ def test_group_ptr_queries_with_known_answers(): now = current_time_millis() for i in range(120): name = f"_hap{i}._tcp._local." - questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = { + questions_with_known_answers[ + DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN) + ] = { DNSPointer( name, const._TYPE_PTR, @@ -1079,7 +1196,9 @@ def test_group_ptr_queries_with_known_answers(): ) for counter in range(i) } - outs = _services_browser.group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) + outs = _services_browser.group_ptr_queries_with_known_answers( + now, True, questions_with_known_answers + ) for out in outs: packets = out.packets() # If we generate multiple packets there must @@ -1092,7 +1211,7 @@ def test_group_ptr_queries_with_known_answers(): @pytest.mark.asyncio async def test_generate_service_query_suppress_duplicate_questions(): """Generate a service query for sending with zeroconf.send.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf now = current_time_millis() name = "_suppresstest._tcp.local." @@ -1102,21 +1221,25 @@ async def test_generate_service_query_suppress_duplicate_questions(): const._TYPE_PTR, const._CLASS_IN, 10000, - f'known-to-other.{name}', + f"known-to-other.{name}", ) other_known_answers: Set[r.DNSRecord] = {answer} zc.question_history.add_question_at_time(question, now, other_known_answers) assert zc.question_history.suppresses(question, now, other_known_answers) # The known answer list is different, do not suppress - outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) + outs = _services_browser.generate_service_query( + zc, now, {name}, multicast=True, question_type=None + ) assert outs zc.cache.async_add_records([answer]) # The known answer list contains all the asked questions in the history # we should suppress - outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) + outs = _services_browser.generate_service_query( + zc, now, {name}, multicast=True, question_type=None + ) assert not outs # We do not suppress once the question history expires @@ -1126,17 +1249,23 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert outs # We do not suppress QU queries ever - outs = _services_browser.generate_service_query(zc, now, {name}, multicast=False, question_type=None) + outs = _services_browser.generate_service_query( + zc, now, {name}, multicast=False, question_type=None + ) assert outs zc.question_history.async_expire(now + 2000) # No suppression after clearing the history - outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) + outs = _services_browser.generate_service_query( + zc, now, {name}, multicast=True, question_type=None + ) assert outs # The previous query we just sent is still remembered and # the next one is suppressed - outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) + outs = _services_browser.generate_service_query( + zc, now, {name}, multicast=True, question_type=None + ) assert not outs await aiozc.async_close() @@ -1146,7 +1275,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): async def test_query_scheduler(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() zc = aiozc.zeroconf sends: List[r.DNSIncoming] = [] @@ -1156,12 +1285,13 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): pout = r.DNSIncoming(out.packets()[0]) sends.append(pout) - query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) + query_scheduler = _services_browser.QueryScheduler( + zc, types_, None, 0, True, delay, (0, 0), None + ) loop = asyncio.get_running_loop() # patch the zeroconf send so we can capture what is being sent with patch.object(zc, "async_send", send): - query_scheduler.start(loop) original_now = loop.time() @@ -1186,15 +1316,23 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ) query_scheduler.reschedule_ptr_first_refresh(ptr_record) - expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_when_time = ptr_record.get_expiration_time( + const._EXPIRE_REFRESH_TIME_PERCENT + ) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( - ptr_record.alias, ptr_record.name, int(ptr_record.ttl), expected_expire_time, expected_when_time + ptr_record.alias, + ptr_record.name, + int(ptr_record.ttl), + expected_expire_time, + expected_when_time, ) assert query_scheduler._query_heap == [ptr_query] query_scheduler.reschedule_ptr_first_refresh(ptr2_record) - expected_when_time = ptr2_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_when_time = ptr2_record.get_expiration_time( + const._EXPIRE_REFRESH_TIME_PERCENT + ) expected_expire_time = ptr2_record.get_expiration_time(100) ptr2_query = _ScheduledPTRQuery( ptr2_record.alias, @@ -1236,7 +1374,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): async def test_query_scheduler_rescue_records(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() zc = aiozc.zeroconf sends: List[r.DNSIncoming] = [] @@ -1246,12 +1384,13 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): pout = r.DNSIncoming(out.packets()[0]) sends.append(pout) - query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) + query_scheduler = _services_browser.QueryScheduler( + zc, types_, None, 0, True, delay, (0, 0), None + ) loop = asyncio.get_running_loop() # patch the zeroconf send so we can capture what is being sent with patch.object(zc, "async_send", send): - query_scheduler.start(loop) original_now = loop.time() @@ -1269,10 +1408,16 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ) query_scheduler.reschedule_ptr_first_refresh(ptr_record) - expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) + expected_when_time = ptr_record.get_expiration_time( + const._EXPIRE_REFRESH_TIME_PERCENT + ) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( - ptr_record.alias, ptr_record.name, int(ptr_record.ttl), expected_expire_time, expected_when_time + ptr_record.alias, + ptr_record.name, + int(ptr_record.ttl), + expected_expire_time, + expected_when_time, ) assert query_scheduler._query_heap == [ptr_query] assert query_scheduler._query_heap[0].cancelled is False @@ -1308,7 +1453,7 @@ def test_service_browser_matching(): """Test that the ServiceBrowser matching does not match partial names.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -1336,17 +1481,33 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de browser = r.ServiceBrowser(zc, type_, None, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) should_not_match = ServiceInfo( - not_match_type_, not_match_registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + not_match_type_, + not_match_registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[address], ) _inject_response( zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), ) _inject_response( zc, @@ -1375,8 +1536,8 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de time.sleep(0.2) assert callbacks == [ - ('add', type_, registration_name), - ('update', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), ] browser.cancel() @@ -1387,7 +1548,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de def test_service_browser_expire_callbacks(): """Test that the ServiceBrowser matching does not match partial names.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_old._tcp.local." registration_name = "uniquezip323.%s" % type_ @@ -1413,7 +1574,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de browser = r.ServiceBrowser(zc, type_, None, listener) - desc = {'path': '/~paul2/'} + desc = {"path": "/~paul2/"} address_parsed = "10.0.1.3" address = socket.inet_aton(address_parsed) info = ServiceInfo( @@ -1431,7 +1592,14 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de _inject_response( zc, - mock_incoming_msg([info.dns_pointer(), info.dns_service(), info.dns_text(), *info.dns_addresses()]), + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), ) # Force the ttl to be 1 second now = current_time_millis() @@ -1454,8 +1622,8 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de break assert callbacks == [ - ('add', type_, registration_name), - ('update', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), ] for _ in range(25): @@ -1464,9 +1632,9 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de break assert callbacks == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), + ("remove", type_, registration_name), ] browser.cancel() @@ -1474,9 +1642,15 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de def test_scheduled_ptr_query_dunder_methods(): - query75 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 75) - query80 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 80) - query75_2 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 140, 75) + query75 = _ScheduledPTRQuery( + "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 75 + ) + query80 = _ScheduledPTRQuery( + "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 80 + ) + query75_2 = _ScheduledPTRQuery( + "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 140, 75 + ) other = object() stringified = str(query75) assert "zoomy._hap._tcp.local." in stringified @@ -1515,7 +1689,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): if state_change is ServiceStateChange.Added: service_added.set() - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() @@ -1529,7 +1703,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(zeroconf_browser.engine.protocols) == 2 - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + aio_zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_registrar = aio_zeroconf_registrar.zeroconf await aio_zeroconf_registrar.zeroconf.async_wait_for_start() @@ -1538,14 +1712,16 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): with patch.object(zeroconf_browser, "async_send", send): service_added = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = AsyncServiceBrowser( + zeroconf_browser, type_, [on_service_state_change] + ) info = ServiceInfo( type_, registration_name, 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -1584,7 +1760,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): if state_change is ServiceStateChange.Added: service_added.set() - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() @@ -1598,7 +1774,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(zeroconf_browser.engine.protocols) == 2 - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + aio_zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_registrar = aio_zeroconf_registrar.zeroconf await aio_zeroconf_registrar.zeroconf.async_wait_for_start() @@ -1606,7 +1782,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send so we can capture what is being sent with patch.object(zeroconf_browser, "async_send", send): service_added = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = AsyncServiceBrowser( + zeroconf_browser, type_, [on_service_state_change] + ) expected_ttl = const._DNS_OTHER_TTL info = ServiceInfo( type_, @@ -1614,7 +1792,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index c02d5e055..aefef6c80 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._services.info. """ +"""Unit tests for zeroconf._services.info.""" import asyncio import logging @@ -26,7 +26,7 @@ from .. import _inject_response, has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -44,29 +44,43 @@ def teardown_module(): class TestServiceInfo(unittest.TestCase): def test_get_name(self): """Verify the name accessor can strip the type.""" - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' + desc = {"path": "/~paulsm/"} + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." service_address = socket.inet_aton("10.0.1.2") info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + service_type, + service_name, + 22, + 0, + 0, + desc, + service_server, + addresses=[service_address], ) assert info.get_name() == "name" def test_service_info_rejects_non_matching_updates(self): """Verify records with the wrong name are rejected.""" - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." service_address = socket.inet_aton("10.0.1.2") ttl = 120 now = r.current_time_millis() info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + service_type, + service_name, + 22, + 0, + 0, + desc, + service_server, + addresses=[service_address], ) # Verify backwards compatiblity with calling with None info.async_update_records(zc, now, []) @@ -81,7 +95,7 @@ def test_service_info_rejects_non_matching_updates(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), None, ) @@ -101,14 +115,14 @@ def test_service_info_rejects_non_matching_updates(self): 0, 0, 80, - 'ASH-2.local.', + "ASH-2.local.", ), None, ) ], ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' + assert info.server_key == "ash-2.local." + assert info.server == "ASH-2.local." new_address = socket.inet_aton("10.0.1.3") info.async_update_records( zc, @@ -116,7 +130,7 @@ def test_service_info_rejects_non_matching_updates(self): [ RecordUpdate( r.DNSAddress( - 'ASH-2.local.', + "ASH-2.local.", const._TYPE_A, const._CLASS_IN | const._CLASS_UNIQUE, ttl, @@ -138,7 +152,7 @@ def test_service_info_rejects_non_matching_updates(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==", ), None, ) @@ -158,14 +172,14 @@ def test_service_info_rejects_non_matching_updates(self): 0, 0, 80, - 'ASH-2.local.', + "ASH-2.local.", ), None, ) ], ) - assert info.server_key == 'ash-2.local.' - assert info.server == 'ASH-2.local.' + assert info.server_key == "ash-2.local." + assert info.server == "ASH-2.local." new_address = socket.inet_aton("10.0.1.4") info.async_update_records( zc, @@ -188,16 +202,23 @@ def test_service_info_rejects_non_matching_updates(self): def test_service_info_rejects_expired_records(self): """Verify records that are expired are rejected.""" - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." service_address = socket.inet_aton("10.0.1.2") ttl = 120 now = r.current_time_millis() info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + service_type, + service_name, + 22, + 0, + 0, + desc, + service_server, + addresses=[service_address], ) # Matching updates info.async_update_records( @@ -210,7 +231,7 @@ def test_service_info_rejects_expired_records(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), None, ) @@ -223,24 +244,24 @@ def test_service_info_rejects_expired_records(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - b'\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==", ) expired_record.set_created_ttl(1000, 1) info.async_update_records(zc, now, [RecordUpdate(expired_record, None)]) assert info.properties[b"ci"] == b"2" zc.close() - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_get_info_partial(self): - zc = r.Zeroconf(interfaces=['127.0.0.1']) - - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' - service_address_v6_ll = 'fe80::52e:c2f2:bc5f:e9c6' + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." + service_text = b"path=/~matt1/" + service_address = "10.0.1.2" + service_address_v6_ll = "fe80::52e:c2f2:bc5f:e9c6" service_scope_id = 12 service_info = None @@ -275,7 +296,8 @@ def get_service_info_helper(zc, type, name): try: ttl = 120 helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) + target=get_service_info_helper, + args=(zc, service_type, service_name), ) helper_thread.start() wait_time = 1 @@ -284,10 +306,22 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) + in last_sent.questions + ) assert service_info is None # Expect query for SRV, A, AAAA @@ -310,9 +344,18 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 3 # type: ignore[unreachable] - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) + in last_sent.questions + ) assert service_info is None # Expect query for A, AAAA @@ -338,8 +381,14 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 2 - assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) + in last_sent.questions + ) last_sent = None assert service_info is None @@ -362,7 +411,9 @@ def get_service_info_helper(zc, type, name): const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - socket.inet_pton(socket.AF_INET6, service_address_v6_ll), + socket.inet_pton( + socket.AF_INET6, service_address_v6_ll + ), scope_id=service_scope_id, ), ] @@ -377,13 +428,13 @@ def get_service_info_helper(zc, type, name): zc.remove_all_service_listeners() zc.close() - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_get_info_suppressed_by_question_history(self): - zc = r.Zeroconf(interfaces=['127.0.0.1']) + zc = r.Zeroconf(interfaces=["127.0.0.1"]) - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." service_info = None send_event = Event() @@ -416,19 +467,34 @@ def get_service_info_helper(zc, type, name): try: helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) + target=get_service_info_helper, + args=(zc, service_type, service_name), ) helper_thread.start() - wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5) / 1000 + wait_time = ( + const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 + ) / 1000 # Expect query for SRV, TXT, A, AAAA send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) + in last_sent.questions + ) assert service_info is None # Expect query for SRV only as A, AAAA, and TXT are suppressed @@ -441,22 +507,33 @@ def get_service_info_helper(zc, type, name): ) # Wait long enough to be inside the question history window now = r.current_time_millis() zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), + now, + set(), ) zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), + now, + set(), ) zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), + now, + set(), ) send_event.wait(wait_time * 0.25) assert last_sent is not None assert len(last_sent.questions) == 1 # type: ignore[unreachable] - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) + in last_sent.questions + ) assert service_info is None wait_time = ( - const._DUPLICATE_QUESTION_INTERVAL + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 + const._DUPLICATE_QUESTION_INTERVAL + + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + + 5 ) / 1000 # Expect no queries as all are suppressed by the question history last_sent = None @@ -467,16 +544,24 @@ def get_service_info_helper(zc, type, name): ) # Wait long enough to be inside the question history window now = r.current_time_millis() zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), + now, + set(), ) zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), + now, + set(), ) zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), + now, + set(), ) zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN), now, set() + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN), + now, + set(), ) send_event.wait(wait_time * 0.25) # All questions are suppressed so no query should be sent @@ -489,13 +574,13 @@ def get_service_info_helper(zc, type, name): zc.close() def test_get_info_single(self): - zc = r.Zeroconf(interfaces=['127.0.0.1']) + zc = r.Zeroconf(interfaces=["127.0.0.1"]) - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' - service_text = b'path=/~matt1/' - service_address = '10.0.1.2' + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." + service_text = b"path=/~matt1/" + service_address = "10.0.1.2" service_info = None send_event = Event() @@ -529,7 +614,8 @@ def get_service_info_helper(zc, type, name): try: ttl = 120 helper_thread = threading.Thread( - target=get_service_info_helper, args=(zc, service_type, service_name) + target=get_service_info_helper, + args=(zc, service_type, service_name), ) helper_thread.start() wait_time = 1 @@ -538,10 +624,22 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions + assert ( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) + in last_sent.questions + ) + assert ( + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) + in last_sent.questions + ) assert service_info is None # Expext no further queries @@ -590,16 +688,23 @@ def get_service_info_helper(zc, type, name): def test_service_info_duplicate_properties_txt_records(self): """Verify the first property is always used when there are duplicates in a txt record.""" - zc = r.Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-1.local.' + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-1.local." service_address = socket.inet_aton("10.0.1.2") ttl = 120 now = r.current_time_millis() info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + service_type, + service_name, + 22, + 0, + 0, + desc, + service_server, + addresses=[service_address], ) info.async_update_records( zc, @@ -611,7 +716,7 @@ def test_service_info_duplicate_properties_txt_records(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==\x04dd=0\x04jl=2\x04qq=0\x0brr=6fLM5A==\x04ci=3', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==\x04dd=0\x04jl=2\x04qq=0\x0brr=6fLM5A==\x04ci=3", ), None, ) @@ -626,12 +731,21 @@ def test_service_info_duplicate_properties_txt_records(self): def test_multiple_addresses(): type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) # New kwarg way - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address, address]) + info = ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[address, address], + ) assert info.addresses == [address, address] assert info.parsed_addresses() == [address_parsed, address_parsed] @@ -652,7 +766,7 @@ def test_multiple_addresses(): assert info.parsed_scoped_addresses() == [address_parsed, address_parsed] ipaddress_supports_scope_id = sys.version_info >= (3, 9, 0) - if has_working_ipv6() and not os.environ.get('SKIP_IPV6'): + if has_working_ipv6() and not os.environ.get("SKIP_IPV6"): address_v6_parsed = "2001:db8::1" address_v6 = socket.inet_pton(socket.AF_INET6, address_v6_parsed) address_v6_ll_parsed = "fe80::52e:c2f2:bc5f:e9c6" @@ -679,13 +793,21 @@ def test_multiple_addresses(): 0, desc, "ash-2.local.", - parsed_addresses=[address_parsed, address_v6_parsed, address_v6_ll_parsed], + parsed_addresses=[ + address_parsed, + address_v6_parsed, + address_v6_ll_parsed, + ], interface_index=interface_index, ), ] for info in infos: assert info.addresses == [address] - assert info.addresses_by_version(r.IPVersion.All) == [address, address_v6, address_v6_ll] + assert info.addresses_by_version(r.IPVersion.All) == [ + address, + address_v6, + address_v6_ll, + ] assert info.ip_addresses_by_version(r.IPVersion.All) == [ ip_address(address), ip_address(address_v6), @@ -694,34 +816,50 @@ def test_multiple_addresses(): else ip_address(address_v6_ll), ] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ip_address(address)] - assert info.addresses_by_version(r.IPVersion.V6Only) == [address_v6, address_v6_ll] + assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ + ip_address(address) + ] + assert info.addresses_by_version(r.IPVersion.V6Only) == [ + address_v6, + address_v6_ll, + ] assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ ip_address(address_v6), ip_address(address_v6_ll_scoped_parsed) if ipaddress_supports_scope_id else ip_address(address_v6_ll), ] - assert info.parsed_addresses() == [address_parsed, address_v6_parsed, address_v6_ll_parsed] + assert info.parsed_addresses() == [ + address_parsed, + address_v6_parsed, + address_v6_ll_parsed, + ] assert info.parsed_addresses(r.IPVersion.V4Only) == [address_parsed] - assert info.parsed_addresses(r.IPVersion.V6Only) == [address_v6_parsed, address_v6_ll_parsed] + assert info.parsed_addresses(r.IPVersion.V6Only) == [ + address_v6_parsed, + address_v6_ll_parsed, + ] assert info.parsed_scoped_addresses() == [ address_parsed, address_v6_parsed, - address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, + address_v6_ll_scoped_parsed + if ipaddress_supports_scope_id + else address_v6_ll_parsed, ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ address_v6_parsed, - address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, + address_v6_ll_scoped_parsed + if ipaddress_supports_scope_id + else address_v6_ll_parsed, ] -@unittest.skipIf(sys.version_info < (3, 9, 0), 'Requires newer python') +@unittest.skipIf(sys.version_info < (3, 9, 0), "Requires newer python") def test_scoped_addresses_from_cache(): type_ = "_http._tcp.local." registration_name = f"scoped.{type_}" - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) host = "scoped.local." zeroconf.cache.async_add_records( @@ -758,7 +896,9 @@ def test_scoped_addresses_from_cache(): info = ServiceInfo(type_, registration_name) info.load_from_cache(zeroconf) assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%12"] - assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%12")] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ + ip_address("fe80::52e:c2f2:bc5f:e9c6%12") + ] zeroconf.close() @@ -769,18 +909,22 @@ async def test_multiple_a_addresses_newest_address_first(): """Test that info.addresses returns the newest seen address first.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) cache = aiozc.zeroconf.cache host = "multahost.local." - record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'\x7f\x00\x00\x01') - record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'\x7f\x00\x00\x02') + record1 = r.DNSAddress( + host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x01" + ) + record2 = r.DNSAddress( + host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x02" + ) cache.async_add_records([record1, record2]) # New kwarg way info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) info.load_from_cache(aiozc.zeroconf) - assert info.addresses == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + assert info.addresses == [b"\x7f\x00\x00\x02", b"\x7f\x00\x00\x01"] await aiozc.async_close() @@ -788,12 +932,12 @@ async def test_multiple_a_addresses_newest_address_first(): async def test_invalid_a_addresses(caplog): type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) cache = aiozc.zeroconf.cache host = "multahost.local." - record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'a') - record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b'b') + record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b"a") + record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b"b") cache.async_add_records([record1, record2]) # New kwarg way @@ -805,25 +949,34 @@ async def test_invalid_a_addresses(caplog): await aiozc.async_close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_filter_address_by_type_from_service_info(): """Verify dns_addresses can filter by ipversion.""" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} type_ = "_homeassistant._tcp.local." name = "MyTestHome" registration_name = f"{name}.{type_}" ipv4 = socket.inet_aton("10.0.1.2") ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6] + ) def dns_addresses_to_addresses(dns_address: List[DNSAddress]) -> List[bytes]: return [address.address for address in dns_address] assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ipv4, ipv6] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] - assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.All)) == [ + ipv4, + ipv6, + ] + assert dns_addresses_to_addresses( + info.dns_addresses(version=r.IPVersion.V4Only) + ) == [ipv4] + assert dns_addresses_to_addresses( + info.dns_addresses(version=r.IPVersion.V6Only) + ) == [ipv6] def test_changing_name_updates_serviceinfo_key(): @@ -832,11 +985,11 @@ def test_changing_name_updates_serviceinfo_key(): name = "MyTestHome" info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -854,11 +1007,11 @@ def test_serviceinfo_address_updates(): with pytest.raises(TypeError): info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], parsed_addresses=["10.0.1.2"], @@ -866,11 +1019,11 @@ def test_serviceinfo_address_updates(): info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -885,48 +1038,55 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): addresses = [socket.inet_aton("10.0.1.2")] server_name = "ash-2.local." info_service = ServiceInfo( - type_, f'{name}.{type_}', 80, 0, 0, {b'path': b'/~paulsm/'}, server_name, addresses=addresses + type_, + f"{name}.{type_}", + 80, + 0, + 0, + {b"path": b"/~paulsm/"}, + server_name, + addresses=addresses, ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + assert info_service.dns_text().text == b"\x0epath=/~paulsm/" info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, server_name, addresses=addresses, ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + assert info_service.dns_text().text == b"\x0epath=/~paulsm/" info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {b'path': '/~paulsm/'}, + {b"path": "/~paulsm/"}, server_name, addresses=addresses, ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + assert info_service.dns_text().text == b"\x0epath=/~paulsm/" info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': b'/~paulsm/'}, + {"path": b"/~paulsm/"}, server_name, addresses=addresses, ) - assert info_service.dns_text().text == b'\x0epath=/~paulsm/' + assert info_service.dns_text().text == b"\x0epath=/~paulsm/" def test_asking_qu_questions(): """Verify explictly asking QU questions.""" type_ = "_quservice._tcp.local." - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) # we are going to patch the zeroconf send to check query transmission old_send = zeroconf.async_send @@ -942,7 +1102,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) + zeroconf.get_service_info( + f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU + ) assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] zeroconf.close() @@ -950,7 +1112,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): def test_asking_qm_questions(): """Verify explictly asking QM questions.""" type_ = "_quservice._tcp.local." - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) # we are going to patch the zeroconf send to check query transmission old_send = zeroconf.async_send @@ -966,16 +1128,21 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) + zeroconf.get_service_info( + f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM + ) assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] zeroconf.close() def test_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) start_time = r.current_time_millis() - assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") is None + assert ( + zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") + is None + ) end_time = r.current_time_millis() zeroconf.close() # 3000ms for the default timeout @@ -987,7 +1154,7 @@ def test_request_timeout(): async def test_we_try_four_times_with_random_delay(): """Verify we try four times even with the random delay.""" type_ = "_typethatisnothere._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) # we are going to patch the zeroconf send to check query transmission request_count = 0 @@ -1011,8 +1178,8 @@ async def test_release_wait_when_new_recorded_added(): """Test that async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1049,7 +1216,7 @@ async def test_release_wait_when_new_recorded_added(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1059,15 +1226,17 @@ async def test_release_wait_when_new_recorded_added(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) assert await asyncio.wait_for(task, timeout=2) - assert info.addresses == [b'\x7f\x00\x00\x01'] + assert info.addresses == [b"\x7f\x00\x00\x01"] await aiozc.async_close() @@ -1076,8 +1245,8 @@ async def test_port_changes_are_seen(): """Test that port changes are seen by async_request.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1112,7 +1281,7 @@ async def test_port_changes_are_seen(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1122,13 +1291,15 @@ async def test_port_changes_are_seen(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1144,7 +1315,9 @@ async def test_port_changes_are_seen(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1159,8 +1332,8 @@ async def test_port_changes_are_seen_with_directed_request(): """Test that port changes are seen by async_request with a directed request.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1195,7 +1368,7 @@ async def test_port_changes_are_seen_with_directed_request(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1205,13 +1378,15 @@ async def test_port_changes_are_seen_with_directed_request(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1227,7 +1402,9 @@ async def test_port_changes_are_seen_with_directed_request(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200, addr="127.0.0.1", port=5353) @@ -1242,7 +1419,7 @@ async def test_ipv4_changes_are_seen(): """Test that ipv4 changes are seen by async_request.""" type_ = "_http._tcp.local." registration_name = "multiaipv4rec.%s" % type_ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1277,7 +1454,7 @@ async def test_ipv4_changes_are_seen(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1287,16 +1464,18 @@ async def test_ipv4_changes_are_seen(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] + assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1305,17 +1484,25 @@ async def test_ipv4_changes_are_seen(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x02', + b"\x7f\x00\x00\x02", ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + assert info.addresses_by_version(IPVersion.V4Only) == [ + b"\x7f\x00\x00\x02", + b"\x7f\x00\x00\x01", + ] await info.async_request(aiozc.zeroconf, timeout=200) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02', b'\x7f\x00\x00\x01'] + assert info.addresses_by_version(IPVersion.V4Only) == [ + b"\x7f\x00\x00\x02", + b"\x7f\x00\x00\x01", + ] await aiozc.async_close() @@ -1324,7 +1511,7 @@ async def test_ipv6_changes_are_seen(): """Test that ipv6 changes are seen by async_request.""" type_ = "_http._tcp.local." registration_name = "multiaipv6rec.%s" % type_ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1359,7 +1546,7 @@ async def test_ipv6_changes_are_seen(): const._TYPE_AAAA, const._CLASS_IN, 10000, - b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ), 0, ) @@ -1369,17 +1556,19 @@ async def test_ipv6_changes_are_seen(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V6Only) == [ - b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ] generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -1389,22 +1578,24 @@ async def test_ipv6_changes_are_seen(): const._TYPE_AAAA, const._CLASS_IN, 10000, - b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b"\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V6Only) == [ - b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b"\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ] await info.async_request(aiozc.zeroconf, timeout=200) assert info.addresses_by_version(IPVersion.V6Only) == [ - b'\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - b'\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + b"\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ] await aiozc.async_close() @@ -1414,7 +1605,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): """Test that bad ip address in the cache are ignored async_request.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1438,7 +1629,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1448,19 +1639,23 @@ async def test_bad_ip_addresses_ignored_in_cache(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) # Manually add a bad record to the cache - aiozc.zeroconf.cache.async_add_records([DNSAddress(host, const._TYPE_A, const._CLASS_IN, 10000, b'\x00')]) + aiozc.zeroconf.cache.async_add_records( + [DNSAddress(host, const._TYPE_A, const._CLASS_IN, 10000, b"\x00")] + ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x01'] + assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] @pytest.mark.asyncio @@ -1468,7 +1663,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): """Test that service name changes are seen by async_request when the ip is in the cache.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1490,7 +1685,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1500,7 +1695,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x02', + b"\x7f\x00\x00\x02", ), 0, ) @@ -1510,13 +1705,15 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1536,11 +1733,13 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02'] + assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x02"] await aiozc.async_close() @@ -1550,7 +1749,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): """Test that service name changes are seen by async_request when the ip is not in the cache.""" type_ = "_http._tcp.local." registration_name = "multiarec.%s" % type_ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." # New kwarg way @@ -1572,7 +1771,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1582,13 +1781,15 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1614,15 +1815,17 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x02', + b"\x7f\x00\x00\x02", ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) - assert info.addresses_by_version(IPVersion.V4Only) == [b'\x7f\x00\x00\x02'] + assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x02"] await aiozc.async_close() @@ -1633,14 +1836,17 @@ async def test_release_wait_when_new_recorded_added_concurrency(): """Test that concurrent async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." registration_name = "multiareccon.%s" % type_ - desc = {'path': '/~paulsm/'} - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + desc = {"path": "/~paulsm/"} + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahostcon.local." await aiozc.zeroconf.async_wait_for_start() # New kwarg way info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) - tasks = [asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200000)) for _ in range(10)] + tasks = [ + asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200000)) + for _ in range(10) + ] await asyncio.sleep(0.1) for task in tasks: assert not task.done() @@ -1675,7 +1881,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): const._TYPE_A, const._CLASS_IN, 10000, - b'\x7f\x00\x00\x01', + b"\x7f\x00\x00\x01", ), 0, ) @@ -1685,17 +1891,19 @@ async def test_release_wait_when_new_recorded_added_concurrency(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 10000, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) await asyncio.sleep(0) for task in tasks: assert not task.done() - aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) + aiozc.zeroconf.record_manager.async_updates_from_response( + r.DNSIncoming(generated.packets()[0]) + ) _, pending = await asyncio.wait(tasks, timeout=2) assert not pending - assert info.addresses == [b'\x7f\x00\x00\x01'] + assert info.addresses == [b"\x7f\x00\x00\x01"] await aiozc.async_close() @@ -1704,7 +1912,7 @@ async def test_service_info_nsec_records(): """Test we can generate nsec records from ServiceInfo.""" type_ = "_http._tcp.local." registration_name = "multiareccon.%s" % type_ - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} host = "multahostcon.local." info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) nsec_record = info.dns_nsec([const._TYPE_A, const._TYPE_AAAA], 50) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index f8656e2fa..d3f60179a 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -16,9 +16,16 @@ def test_only_register_once(self): name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) registry = r.ServiceRegistry() @@ -34,12 +41,26 @@ def test_register_same_server(self): registration_name = f"{name}.{type_}" registration_name2 = f"{name2}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "same.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "same.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( - type_, registration_name2, 80, 0, 0, desc, "same.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name2, + 80, + 0, + 0, + desc, + "same.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) registry = r.ServiceRegistry() registry.async_add(info) @@ -62,9 +83,16 @@ def test_unregister_multiple_times(self): name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) registry = r.ServiceRegistry() @@ -78,9 +106,16 @@ def test_lookups(self): name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) registry = r.ServiceRegistry() @@ -97,9 +132,16 @@ def test_lookups_upper_case_by_lower_case(self): name = "Xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ASH-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ASH-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) registry = r.ServiceRegistry() diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 1afe6d530..d9340283a 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -14,7 +14,7 @@ from .. import _clear_cache, has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -34,8 +34,8 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): name = "xxxyyy" registration_name = f"{name}.{type_}" - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} + zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -48,7 +48,7 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) @@ -58,16 +58,16 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): zeroconf_registrar.close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_integration_with_listener_v6_records(disable_duplicate_packet_suppression): type_ = "_test-listenv6rec-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} + zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -80,7 +80,7 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) @@ -90,8 +90,8 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi zeroconf_registrar.close() -@unittest.skipIf(not has_working_ipv6() or sys.platform == 'win32', 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6() or sys.platform == "win32", "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" @@ -99,7 +99,7 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com zeroconf_registrar = Zeroconf(ip_version=r.IPVersion.V6Only) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -112,7 +112,9 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) + service_types = ZeroconfServiceTypes.find( + ip_version=r.IPVersion.V6Only, timeout=2 + ) assert type_ in service_types _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) @@ -130,8 +132,8 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress discovery_type = f"{subtype_}.{type_}" registration_name = f"{name}.{type_}" - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} + zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} info = ServiceInfo( discovery_type, registration_name, @@ -144,7 +146,7 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=['127.0.0.1'], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) assert discovery_type in service_types _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 382b1a3d7..053ed26bb 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -50,7 +50,7 @@ time_changed_millis, ) -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -83,14 +83,14 @@ def verify_threads_ended(): @pytest.mark.asyncio async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() @pytest.mark.asyncio async def test_async_close_twice() -> None: """Test we can close twice.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() await aiozc.async_close() @@ -98,7 +98,7 @@ async def test_async_close_twice() -> None: @pytest.mark.asyncio async def test_async_with_sync_passed_in() -> None: """Test we can create and close the instance when passing in a sync Zeroconf.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) aiozc = AsyncZeroconf(zc=zc) assert aiozc.zeroconf is zc await aiozc.async_close() @@ -107,7 +107,7 @@ async def test_async_with_sync_passed_in() -> None: @pytest.mark.asyncio async def test_async_with_sync_passed_in_closed_in_async() -> None: """Test caller closes the sync version in async.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) aiozc = AsyncZeroconf(zc=zc) assert aiozc.zeroconf is zc zc.close() @@ -119,8 +119,13 @@ async def test_sync_within_event_loop_executor() -> None: """Test sync version still works from an executor within an event loop.""" def sync_code(): - zc = Zeroconf(interfaces=['127.0.0.1']) - assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None + zc = Zeroconf(interfaces=["127.0.0.1"]) + assert ( + zc.get_service_info( + "_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10 + ) + is None + ) zc.close() await asyncio.get_event_loop().run_in_executor(None, sync_code) @@ -129,7 +134,7 @@ def sync_code(): @pytest.mark.asyncio async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -150,7 +155,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: aiozc.zeroconf.add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -186,10 +191,10 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), + ("update", type_, registration_name), + ("remove", type_, registration_name), ] @@ -200,7 +205,7 @@ async def test_async_service_registration_with_server_missing() -> None: For backwards compatibility, the server should be set to the name that was passed in. """ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -221,7 +226,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: aiozc.zeroconf.add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -254,16 +259,16 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), + ("remove", type_, registration_name), ] @pytest.mark.asyncio async def test_async_service_registration_same_server_different_ports() -> None: """Test registering services with the same server with different srv records.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "xxxyyy2" @@ -287,7 +292,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: aiozc.zeroconf.add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -320,17 +325,17 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: assert info2.dns_service() in entries await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('add', type_, registration_name2), - ('remove', type_, registration_name), - ('remove', type_, registration_name2), + ("add", type_, registration_name), + ("add", type_, registration_name2), + ("remove", type_, registration_name), + ("remove", type_, registration_name2), ] @pytest.mark.asyncio async def test_async_service_registration_same_server_same_ports() -> None: """Test registering services with the same server with the exact same srv record.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "xxxyyy2" @@ -354,7 +359,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: aiozc.zeroconf.add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -387,22 +392,22 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: assert info2.dns_service() in entries await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('add', type_, registration_name2), - ('remove', type_, registration_name), - ('remove', type_, registration_name2), + ("add", type_, registration_name), + ("add", type_, registration_name2), + ("remove", type_, registration_name), + ("remove", type_, registration_name2), ] @pytest.mark.asyncio async def test_async_service_registration_name_conflict() -> None: """Test registering services throws on name conflict.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test-srvc2-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -445,12 +450,12 @@ async def test_async_service_registration_name_conflict() -> None: @pytest.mark.asyncio async def test_async_service_registration_name_does_not_match_type() -> None: """Test registering services throws when the name does not match the type.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test-srvc3-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -471,13 +476,13 @@ async def test_async_service_registration_name_does_not_match_type() -> None: @pytest.mark.asyncio async def test_async_service_registration_name_strict_check() -> None: """Test registering services throws when the name does not comply.""" - zc = Zeroconf(interfaces=['127.0.0.1']) - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_ibisip_http._tcp.local." name = "CustomerInformationService-F4D4895E9EEB" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -507,7 +512,7 @@ async def test_async_service_registration_name_strict_check() -> None: async def test_async_tasks() -> None: """Test awaiting broadcast tasks""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test-srvc4-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -527,7 +532,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: listener = MyListener() aiozc.zeroconf.add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -563,9 +568,9 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), + ("remove", type_, registration_name), ] @@ -573,12 +578,12 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: async def test_async_wait_unblocks_on_update() -> None: """Test async_wait will unblock on update.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test-srvc4-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -608,10 +613,10 @@ async def test_async_wait_unblocks_on_update() -> None: @pytest.mark.asyncio async def test_service_info_async_request() -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" - if not has_working_ipv6() or os.environ.get('SKIP_IPV6'): - pytest.skip('Requires IPv6') + if not has_working_ipv6() or os.environ.get("SKIP_IPV6"): + pytest.skip("Requires IPv6") - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "abc" @@ -620,11 +625,15 @@ async def test_service_info_async_request() -> None: # Start a tasks BEFORE the registration that will keep trying # and see the registration a bit later - get_service_info_task1 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + get_service_info_task1 = asyncio.ensure_future( + aiozc.async_get_service_info(type_, registration_name) + ) await asyncio.sleep(_LISTENER_TIME / 1000 / 2) - get_service_info_task2 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) + get_service_info_task2 = asyncio.ensure_future( + aiozc.async_get_service_info(type_, registration_name) + ) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -670,7 +679,10 @@ async def test_service_info_async_request() -> None: 0, desc, "ash-2.local.", - addresses=[socket.inet_aton("10.0.1.3"), socket.inet_pton(socket.AF_INET6, "6001:db8::1")], + addresses=[ + socket.inet_aton("10.0.1.3"), + socket.inet_pton(socket.AF_INET6, "6001:db8::1"), + ], ) task = await aiozc.async_update_service(new_info) @@ -714,7 +726,7 @@ async def test_service_info_async_request() -> None: @pytest.mark.asyncio async def test_async_service_browser() -> None: """Test AsyncServiceBrowser.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test9-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -734,7 +746,7 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: listener = MyListener() await aiozc.async_add_service_listener(type_, listener) - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -765,9 +777,9 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: await aiozc.async_close() assert calls == [ - ('add', type_, registration_name), - ('update', type_, registration_name), - ('remove', type_, registration_name), + ("add", type_, registration_name), + ("update", type_, registration_name), + ("remove", type_, registration_name), ] @@ -778,14 +790,14 @@ async def test_async_context_manager() -> None: name = "xxxyyy" registration_name = f"{name}.{type_}" - async with AsyncZeroconf(interfaces=['127.0.0.1']) as aiozc: + async with AsyncZeroconf(interfaces=["127.0.0.1"]) as aiozc: info = ServiceInfo( type_, registration_name, 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -800,7 +812,7 @@ async def test_service_browser_cancel_async_context_manager(): """Test we can cancel an AsyncServiceBrowser with it being used as an async context manager.""" # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_hap._tcp.local." @@ -824,14 +836,14 @@ class MyServiceListener(ServiceListener): @pytest.mark.asyncio async def test_async_unregister_all_services() -> None: """Test unregistering all services.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." name = "xxxyyy" name2 = "abc" registration_name = f"{name}.{type_}" registration_name2 = f"{name2}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -886,8 +898,8 @@ async def test_async_zeroconf_service_types(): name = "xxxyyy" registration_name = f"{name}.{type_}" - zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} + zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -904,10 +916,14 @@ async def test_async_zeroconf_service_types(): await asyncio.sleep(0.2) _clear_cache(zeroconf_registrar.zeroconf) try: - service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=['127.0.0.1'], timeout=2) + service_types = await AsyncZeroconfServiceTypes.async_find( + interfaces=["127.0.0.1"], timeout=2 + ) assert type_ in service_types _clear_cache(zeroconf_registrar.zeroconf) - service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=2) + service_types = await AsyncZeroconfServiceTypes.async_find( + aiozc=zeroconf_registrar, timeout=2 + ) assert type_ in service_types finally: @@ -917,9 +933,11 @@ async def test_async_zeroconf_service_types(): @pytest.mark.asyncio async def test_guard_against_running_serviceinfo_request_event_loop() -> None: """Test that running ServiceInfo.request from the event loop throws.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) - service_info = AsyncServiceInfo("_hap._tcp.local.", "doesnotmatter._hap._tcp.local.") + service_info = AsyncServiceInfo( + "_hap._tcp.local.", "doesnotmatter._hap._tcp.local." + ) with pytest.raises(RuntimeError): service_info.request(aiozc.zeroconf, 3000) await aiozc.async_close() @@ -930,7 +948,7 @@ async def test_service_browser_instantiation_generates_add_events_from_cache(): """Test that the ServiceBrowser will generate Add events with the existing cache when starting.""" # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_hap._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -954,10 +972,12 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de listener = MyServiceListener() - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) zc.cache.async_add_records( [info.dns_pointer(), info.dns_service(), *info.dns_addresses(), info.dns_text()] ) @@ -967,7 +987,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await asyncio.sleep(0) assert callbacks == [ - ('add', type_, registration_name), + ("add", type_, registration_name), ] await browser.async_cancel() @@ -991,7 +1011,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): elif state_change is ServiceStateChange.Removed: service_removed.set() - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf zeroconf_browser.question_history = QuestionHistoryWithoutSuppression() await zeroconf_browser.async_wait_for_start() @@ -1023,7 +1043,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(zeroconf_browser.engine.protocols) == 2 - aio_zeroconf_registrar = AsyncZeroconf(interfaces=['127.0.0.1']) + aio_zeroconf_registrar = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_registrar = aio_zeroconf_registrar.zeroconf await aio_zeroconf_registrar.zeroconf.async_wait_for_start() @@ -1033,14 +1053,16 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + browser = AsyncServiceBrowser( + zeroconf_browser, type_, [on_service_state_change] + ) info = ServiceInfo( type_, registration_name, 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -1126,15 +1148,22 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): """Verify the service info first question is QU and subsequent ones are QM questions.""" type_ = "_quservice._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_info = aiozc.zeroconf name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zeroconf_info.registry.async_add(info) @@ -1174,7 +1203,7 @@ async def test_service_browser_ignores_unrelated_updates(): """Test that the ServiceBrowser ignores unrelated updates.""" # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_veryuniqueone._tcp.local." registration_name = "xxxyyy.%s" % type_ @@ -1198,10 +1227,12 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de listener = MyServiceListener() - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] + ) zc.cache.async_add_records( [ info.dns_pointer(), @@ -1216,7 +1247,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de 0, 0, 81, - 'unrelated.local.', + "unrelated.local.", ), ] ) @@ -1235,7 +1266,13 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de 0, ) generated.add_answer_at_time( - DNSAddress("unrelated.local.", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"1234"), + DNSAddress( + "unrelated.local.", + const._TYPE_A, + const._CLASS_IN, + const._DNS_HOST_TTL, + b"1234", + ), 0, ) generated.add_answer_at_time( @@ -1255,7 +1292,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await asyncio.sleep(0) assert callbacks == [ - ('add', type_, registration_name), + ("add", type_, registration_name), ] await aiozc.async_close() @@ -1263,10 +1300,15 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de @pytest.mark.asyncio async def test_async_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() start_time = current_time_millis() - assert await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") is None + assert ( + await aiozc.async_get_service_info( + "_notfound.local.", "notthere._notfound.local." + ) + is None + ) end_time = current_time_millis() await aiozc.async_close() # 3000ms for the default timeout @@ -1277,25 +1319,34 @@ async def test_async_request_timeout(): @pytest.mark.asyncio async def test_async_request_non_running_instance(): """Test that the async_request throws when zeroconf is not running.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() with pytest.raises(NotRunningException): - await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") + await aiozc.async_get_service_info( + "_notfound.local.", "notthere._notfound.local." + ) @pytest.mark.asyncio async def test_legacy_unicast_response(run_isolated): """Verify legacy unicast responses include questions and correct id.""" type_ = "_mservice._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) aiozc.zeroconf.registry.async_add(info) @@ -1305,11 +1356,11 @@ async def test_legacy_unicast_response(run_isolated): protocol = aiozc.zeroconf.engine.protocols[0] with patch.object(aiozc.zeroconf, "async_send") as send_mock: - protocol.datagram_received(query.packets()[0], ('127.0.0.1', 6503)) + protocol.datagram_received(query.packets()[0], ("127.0.0.1", 6503)) calls = send_mock.mock_calls # Verify the response is sent back on the socket it was recieved from - assert calls == [call(ANY, '127.0.0.1', 6503, (), protocol.transport)] + assert calls == [call(ANY, "127.0.0.1", 6503, (), protocol.transport)] outgoing = send_mock.call_args[0][0] assert isinstance(outgoing, DNSOutgoing) assert outgoing.questions == [question] @@ -1320,7 +1371,7 @@ async def test_legacy_unicast_response(run_isolated): @pytest.mark.asyncio async def test_update_with_uppercase_names(run_isolated): """Test an ip update from a shelly which uses uppercase names.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() callbacks = [] @@ -1342,15 +1393,15 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de browser = AsyncServiceBrowser(aiozc.zeroconf, "_http._tcp.local.", None, listener) protocol = aiozc.zeroconf.engine.protocols[0] - packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbc=\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' # noqa: E501 - protocol.datagram_received(packet, ('127.0.0.1', 6503)) + packet = b"\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbc=\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@" # noqa: E501 + protocol.datagram_received(packet, ("127.0.0.1", 6503)) await asyncio.sleep(0) - packet = b'\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00\'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbcA\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@' # noqa: E501 - protocol.datagram_received(packet, ('127.0.0.1', 6503)) + packet = b"\x00\x00\x84\x80\x00\x00\x00\n\x00\x00\x00\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x14\x07_shelly\x04_tcp\x05local\x00\t_services\x07_dns-sd\x04_udp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x12\x05_http\x04_tcp\x05local\x00\x07_shelly\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00.\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19shellypro4pm-94b97ec07650\x07_shelly\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\"\napp=Pro4PM\x10ver=0.10.0-beta5\x05gen=2\x05_http\x04_tcp\x05local\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00,\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00!\x80\x01\x00\x00\x00x\x00'\x00\x00\x00\x00\x00P\x19ShellyPro4PM-94B97EC07650\x05local\x00\x19ShellyPro4PM-94B97EC07650\x05_http\x04_tcp\x05local\x00\x00\x10\x80\x01\x00\x00\x00x\x00\x06\x05gen=2\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01\x80\x01\x00\x00\x00x\x00\x04\xc0\xa8\xbcA\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00/\x80\x01\x00\x00\x00x\x00$\x19ShellyPro4PM-94B97EC07650\x05local\x00\x00\x01@" # noqa: E501 + protocol.datagram_received(packet, ("127.0.0.1", 6503)) await browser.async_cancel() await aiozc.async_close() assert callbacks == [ - ('add', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), - ('update', '_http._tcp.local.', 'ShellyPro4PM-94B97EC07650._http._tcp.local.'), + ("add", "_http._tcp.local.", "ShellyPro4PM-94B97EC07650._http._tcp.local."), + ("update", "_http._tcp.local.", "ShellyPro4PM-94B97EC07650._http._tcp.local."), ] diff --git a/tests/test_cache.py b/tests/test_cache.py index aac7e0ca2..4b3859bdf 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,8 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._cache. """ - +"""Unit tests for zeroconf._cache.""" import logging import unittest @@ -11,7 +10,7 @@ import zeroconf as r from zeroconf import const -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -28,11 +27,11 @@ def teardown_module(): class TestDNSCache(unittest.TestCase): def test_order(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - entry = r.DNSEntry('a', const._TYPE_SOA, const._CLASS_IN) + entry = r.DNSEntry("a", const._TYPE_SOA, const._CLASS_IN) cached_record = cache.get(entry) assert cached_record == record2 @@ -42,8 +41,8 @@ def test_adding_same_record_to_cache_different_ttls_with_get(self): This ensures we only have one source of truth for TTLs as a record cannot be both expired and not expired. """ - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 10, b"a") cache = r.DNSCache() cache.async_add_records([record1, record2]) entry = r.DNSEntry(record2.name, const._TYPE_A, const._CLASS_IN) @@ -58,144 +57,231 @@ def test_adding_same_record_to_cache_different_ttls_with_get_all(self): only have one source of truth for TTLs as a record cannot be both expired and not expired. """ - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 10, b'a') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 10, b"a") cache = r.DNSCache() cache.async_add_records([record1, record2]) - cached_records = cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN) + cached_records = cache.get_all_by_details("a", const._TYPE_A, const._CLASS_IN) assert cached_records == [record2] def test_cache_empty_does_not_leak_memory_by_leaving_empty_list(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert 'a' in cache.cache + assert "a" in cache.cache cache.async_remove_records([record1, record2]) - assert 'a' not in cache.cache + assert "a" not in cache.cache def test_cache_empty_multiple_calls(self): - record1 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert 'a' in cache.cache + assert "a" in cache.cache cache.async_remove_records([record1, record2]) - assert 'a' not in cache.cache + assert "a" not in cache.cache class TestDNSAsyncCacheAPI(unittest.TestCase): def test_async_get_unique(self): - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) assert cache.async_get_unique(record1) == record1 assert cache.async_get_unique(record2) == record2 def test_async_all_by_details(self): - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == {record1, record2} + assert set(cache.async_all_by_details("a", const._TYPE_A, const._CLASS_IN)) == { + record1, + record2, + } def test_async_entries_with_server(self): record1 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 85, + "ab", ) record2 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_entries_with_server('ab')) == {record1, record2} - assert set(cache.async_entries_with_server('AB')) == {record1, record2} + assert set(cache.async_entries_with_server("ab")) == {record1, record2} + assert set(cache.async_entries_with_server("AB")) == {record1, record2} def test_async_entries_with_name(self): record1 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 85, + "ab", ) record2 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.async_entries_with_name('irrelevant')) == {record1, record2} - assert set(cache.async_entries_with_name('Irrelevant')) == {record1, record2} + assert set(cache.async_entries_with_name("irrelevant")) == {record1, record2} + assert set(cache.async_entries_with_name("Irrelevant")) == {record1, record2} # These functions have been seen in other projects so # we try to maintain a stable API for all the threadsafe getters class TestDNSCacheAPI(unittest.TestCase): def test_get(self): - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') - record3 = r.DNSAddress('a', const._TYPE_AAAA, const._CLASS_IN, 1, b'ipv6') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"b") + record3 = r.DNSAddress("a", const._TYPE_AAAA, const._CLASS_IN, 1, b"ipv6") cache = r.DNSCache() cache.async_add_records([record1, record2, record3]) assert cache.get(record1) == record1 assert cache.get(record2) == record2 - assert cache.get(r.DNSEntry('a', const._TYPE_A, const._CLASS_IN)) == record2 - assert cache.get(r.DNSEntry('a', const._TYPE_AAAA, const._CLASS_IN)) == record3 - assert cache.get(r.DNSEntry('notthere', const._TYPE_A, const._CLASS_IN)) is None + assert cache.get(r.DNSEntry("a", const._TYPE_A, const._CLASS_IN)) == record2 + assert cache.get(r.DNSEntry("a", const._TYPE_AAAA, const._CLASS_IN)) == record3 + assert cache.get(r.DNSEntry("notthere", const._TYPE_A, const._CLASS_IN)) is None def test_get_by_details(self): - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert cache.get_by_details('a', const._TYPE_A, const._CLASS_IN) == record2 + assert cache.get_by_details("a", const._TYPE_A, const._CLASS_IN) == record2 def test_get_all_by_details(self): - record1 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'a') - record2 = r.DNSAddress('a', const._TYPE_A, const._CLASS_IN, 1, b'b') + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a") + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"b") cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.get_all_by_details('a', const._TYPE_A, const._CLASS_IN)) == {record1, record2} + assert set(cache.get_all_by_details("a", const._TYPE_A, const._CLASS_IN)) == { + record1, + record2, + } def test_entries_with_server(self): record1 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 85, + "ab", ) record2 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.entries_with_server('ab')) == {record1, record2} - assert set(cache.entries_with_server('AB')) == {record1, record2} + assert set(cache.entries_with_server("ab")) == {record1, record2} + assert set(cache.entries_with_server("AB")) == {record1, record2} def test_entries_with_name(self): record1 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 85, + "ab", ) record2 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert set(cache.entries_with_name('irrelevant')) == {record1, record2} - assert set(cache.entries_with_name('Irrelevant')) == {record1, record2} + assert set(cache.entries_with_name("irrelevant")) == {record1, record2} + assert set(cache.entries_with_name("Irrelevant")) == {record1, record2} def test_current_entry_with_name_and_alias(self): record1 = r.DNSPointer( - 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'x.irrelevant' + "irrelevant", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "x.irrelevant", ) record2 = r.DNSPointer( - 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'y.irrelevant' + "irrelevant", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "y.irrelevant", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert cache.current_entry_with_name_and_alias('irrelevant', 'x.irrelevant') == record1 + assert ( + cache.current_entry_with_name_and_alias("irrelevant", "x.irrelevant") + == record1 + ) def test_name(self): record1 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 85, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 85, + "ab", ) record2 = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert cache.names() == ['irrelevant'] + assert cache.names() == ["irrelevant"] diff --git a/tests/test_core.py b/tests/test_core.py index de4b2ef5b..10545357b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._core """ +"""Unit tests for zeroconf._core""" import asyncio import logging @@ -30,7 +30,7 @@ from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -46,8 +46,8 @@ def teardown_module(): def threadsafe_query( - zc: 'Zeroconf', - protocol: 'AsyncListener', + zc: "Zeroconf", + protocol: "AsyncListener", msg: DNSIncoming, addr: str, port: int, @@ -88,34 +88,44 @@ def test_close_multiple_times(self): rv.close() rv.close() - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) + rv = r.Zeroconf( + interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All + ) rv.close() - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) + rv = r.Zeroconf( + interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only + ) rv.close() - @unittest.skipIf(sys.platform == 'darwin', reason="apple_p2p failure path not testable on mac") + @unittest.skipIf( + sys.platform == "darwin", reason="apple_p2p failure path not testable on mac" + ) def test_launch_and_close_apple_p2p_not_mac(self): with pytest.raises(RuntimeError): r.Zeroconf(apple_p2p=True) - @unittest.skipIf(sys.platform != 'darwin', reason="apple_p2p happy path only testable on mac") + @unittest.skipIf( + sys.platform != "darwin", reason="apple_p2p happy path only testable on mac" + ) def test_launch_and_close_apple_p2p_on_mac(self): rv = r.Zeroconf(apple_p2p=True) rv.close() def test_async_updates_from_response(self): - def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + def mock_incoming_msg( + service_state_change: r.ServiceStateChange, + ) -> r.DNSIncoming: ttl = 120 generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -136,7 +146,10 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ttl = 0 generated.add_answer_at_time( - r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0 + r.DNSPointer( + service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name + ), + 0, ) generated.add_answer_at_time( r.DNSService( @@ -153,7 +166,11 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi ) generated.add_answer_at_time( r.DNSText( - service_name, const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, ttl, service_text + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, ), 0, ) @@ -170,7 +187,9 @@ def mock_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncomi return r.DNSIncoming(generated.packets()[0]) - def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNSIncoming: + def mock_split_incoming_msg( + service_state_change: r.ServiceStateChange, + ) -> r.DNSIncoming: """Mock an incoming message for the case where the packet is split.""" ttl = 120 generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -199,21 +218,27 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS ) return r.DNSIncoming(generated.packets()[0]) - service_name = 'name._type._tcp.local.' - service_type = '_type._tcp.local.' - service_server = 'ash-2.local.' - service_text = b'path=/~paulsm/' - service_address = '10.0.1.2' + service_name = "name._type._tcp.local." + service_type = "_type._tcp.local." + service_server = "ash-2.local." + service_text = b"path=/~paulsm/" + service_address = "10.0.1.2" - zeroconf = r.Zeroconf(interfaces=['127.0.0.1']) + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) try: # service added _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + dns_text = zeroconf.cache.get_by_details( + service_name, const._TYPE_TXT, const._CLASS_IN + ) assert dns_text is not None - assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' - all_dns_text = zeroconf.cache.get_all_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + assert ( + cast(r.DNSText, dns_text).text == service_text + ) # service_text is b'path=/~paulsm/' + all_dns_text = zeroconf.cache.get_all_by_details( + service_name, const._TYPE_TXT, const._CLASS_IN + ) assert [dns_text] == all_dns_text # https://tools.ietf.org/html/rfc6762#section-10.2 @@ -225,25 +250,37 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS time.sleep(1.1) # service updated. currently only text record can be updated - service_text = b'path=/~humingchun/' + service_text = b"path=/~humingchun/" _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + dns_text = zeroconf.cache.get_by_details( + service_name, const._TYPE_TXT, const._CLASS_IN + ) assert dns_text is not None - assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' + assert ( + cast(r.DNSText, dns_text).text == service_text + ) # service_text is b'path=/~humingchun/' time.sleep(1.1) # The split message only has a SRV and A record. # This should not evict TXT records from the cache - _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) + _inject_response( + zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated) + ) time.sleep(1.1) - dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + dns_text = zeroconf.cache.get_by_details( + service_name, const._TYPE_TXT, const._CLASS_IN + ) assert dns_text is not None - assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' + assert ( + cast(r.DNSText, dns_text).text == service_text + ) # service_text is b'path=/~humingchun/' # service removed _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) + dns_text = zeroconf.cache.get_by_details( + service_name, const._TYPE_TXT, const._CLASS_IN + ) assert dns_text is not None assert dns_text.is_expired(current_time_millis() + 1000) @@ -254,12 +291,19 @@ def mock_split_incoming_msg(service_state_change: r.ServiceStateChange) -> r.DNS def test_generate_service_query_set_qu_bit(): """Test generate_service_query sets the QU bit.""" - zeroconf_registrar = Zeroconf(interfaces=['127.0.0.1']) - desc = {'path': '/~paulsm/'} + zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) + desc = {"path": "/~paulsm/"} type_ = "._hap._tcp.local." registration_name = "this-host-is-not-used._hap._tcp.local." info = r.ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) out = zeroconf_registrar.generate_service_query(info) assert out.questions[0].unicast is True @@ -268,10 +312,10 @@ def test_generate_service_query_set_qu_bit(): def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): """Ensure an invalid packet cannot cause the loop to collapse.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) generated = r.DNSOutgoing(0) packet = generated.packets()[0] - packet = packet[:8] + b'deadbeef' + packet[8:] + packet = packet[:8] + b"deadbeef" + packet[8:] parsed = r.DNSIncoming(packet) assert parsed.valid is False @@ -291,7 +335,7 @@ def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ) assert isinstance(entry, r.DNSText) assert isinstance(entry, r.DNSRecord) @@ -306,14 +350,21 @@ def test_invalid_packets_ignored_and_does_not_cause_loop_exception(): def test_goodbye_all_services(): """Verify generating the goodbye query does not change with time.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) out = zc.generate_unregister_all_services() assert out is None type_ = "_http._tcp.local." registration_name = "xxxyyy.%s" % type_ - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = r.ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) out = zc.generate_unregister_all_services() @@ -337,18 +388,18 @@ def test_register_service_with_custom_ttl(): """Test a registering a service with a custom ttl.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_homeassistant._tcp.local." name = "MyTestHome" info_service = r.ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-90.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -364,58 +415,65 @@ def test_logging_packets(caplog): """Test packets are only logged with debug logging.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_logging._tcp.local." name = "TLD" info_service = r.ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-90.local.", addresses=[socket.inet_aton("10.0.1.2")], ) - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) caplog.clear() zc.register_service(info_service, ttl=3000) assert "Sending to" in caplog.text record = zc.cache.get(info_service.dns_pointer()) assert record is not None assert record.ttl == 3000 - logging.getLogger('zeroconf').setLevel(logging.INFO) + logging.getLogger("zeroconf").setLevel(logging.INFO) caplog.clear() zc.unregister_service(info_service) assert "Sending to" not in caplog.text - logging.getLogger('zeroconf').setLevel(logging.DEBUG) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) zc.close() def test_get_service_info_failure_path(): """Verify get_service_info return None when the underlying call returns False.""" - zc = Zeroconf(interfaces=['127.0.0.1']) - assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None + zc = Zeroconf(interfaces=["127.0.0.1"]) + assert ( + zc.get_service_info( + "_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10 + ) + is None + ) zc.close() def test_sending_unicast(): """Test sending unicast response.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) entry = r.DNSText( "didnotcrashincoming._crash._tcp.local.", const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ) generated.add_answer_at_time(entry, 0) - zc.send(generated, "2001:db8::1", const._MDNS_PORT) # https://www.iana.org/go/rfc3849 + zc.send( + generated, "2001:db8::1", const._MDNS_PORT + ) # https://www.iana.org/go/rfc3849 time.sleep(0.2) assert zc.cache.get(entry) is None @@ -437,7 +495,7 @@ def test_sending_unicast(): def test_tc_bit_defers(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) _wait_for_start(zc) type_ = "_tcbitdefer._tcp.local." name = "knownname" @@ -448,19 +506,40 @@ def test_tc_bit_defers(): registration2_name = f"{name2}.{type_}" registration3_name = f"{name3}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." server_name2 = "ash-3.local." server_name3 = "ash-4.local." info = r.ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = r.ServiceInfo( - type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration2_name, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) info3 = r.ServiceInfo( - type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration3_name, + 80, + 0, + 0, + desc, + server_name3, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) zc.registry.async_add(info2) @@ -481,7 +560,7 @@ def test_tc_bit_defers(): packets = generated.packets() assert len(packets) == 4 expected_deferred = [] - source_ip = '203.0.113.13' + source_ip = "203.0.113.13" next_packet = r.DNSIncoming(packets.pop(0)) expected_deferred.append(next_packet) @@ -516,7 +595,7 @@ def test_tc_bit_defers(): def test_tc_bit_defers_last_response_missing(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) _wait_for_start(zc) type_ = "_knowndefer._tcp.local." name = "knownname" @@ -527,19 +606,40 @@ def test_tc_bit_defers_last_response_missing(): registration2_name = f"{name2}.{type_}" registration3_name = f"{name3}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." server_name2 = "ash-3.local." server_name3 = "ash-4.local." info = r.ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = r.ServiceInfo( - type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration2_name, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) info3 = r.ServiceInfo( - type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration3_name, + 80, + 0, + 0, + desc, + server_name3, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) zc.registry.async_add(info2) @@ -548,7 +648,7 @@ def test_tc_bit_defers_last_response_missing(): protocol = zc.engine.protocols[0] now = r.current_time_millis() _clear_cache(zc) - source_ip = '203.0.113.12' + source_ip = "203.0.113.12" generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) @@ -620,7 +720,7 @@ async def test_open_close_twice_from_async() -> None: version they won't yield with an await like async_close we don't have much choice but to force things down. """ - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) zc.close() zc.close() await asyncio.sleep(0) @@ -631,8 +731,8 @@ async def test_multiple_sync_instances_stared_from_async_close(): """Test we can shutdown multiple sync instances from async.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) - zc2 = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) + zc2 = Zeroconf(interfaces=["127.0.0.1"]) assert zc.loop is not None assert zc2.loop is not None @@ -642,7 +742,7 @@ async def test_multiple_sync_instances_stared_from_async_close(): zc2.close() assert zc2.loop.is_running() - zc3 = Zeroconf(interfaces=['127.0.0.1']) + zc3 = Zeroconf(interfaces=["127.0.0.1"]) assert zc3.loop == zc2.loop zc3.close() @@ -655,18 +755,18 @@ def test_shutdown_while_register_in_process(): """Test we can shutdown while registering a service in another thread.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_homeassistant._tcp.local." name = "MyTestHome" info_service = r.ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-90.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -683,12 +783,14 @@ def _background_register(): @pytest.mark.asyncio -@unittest.skipIf(sys.version_info[:3][1] < 8, 'Requires Python 3.8 or later to patch _async_setup') +@unittest.skipIf( + sys.version_info[:3][1] < 8, "Requires Python 3.8 or later to patch _async_setup" +) @patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): """Test we raise NotRunningException when waiting for startup that times out.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) with pytest.raises(NotRunningException): await aiozc.zeroconf.async_wait_for_start() assert aiozc.zeroconf.started is False diff --git a/tests/test_dns.py b/tests/test_dns.py index 055621356..b4ac6f886 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._dns. """ +"""Unit tests for zeroconf._dns.""" import logging import os @@ -17,7 +17,7 @@ from . import has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -36,50 +36,71 @@ class TestDunder(unittest.TestCase): def test_dns_text_repr(self): # There was an issue on Python 3 that prevented DNSText's repr # from working when the text was longer than 10 bytes - text = DNSText('irrelevant', 0, 0, 0, b'12345678901') + text = DNSText("irrelevant", 0, 0, 0, b"12345678901") repr(text) - text = DNSText('irrelevant', 0, 0, 0, b'123') + text = DNSText("irrelevant", 0, 0, 0, b"123") repr(text) def test_dns_hinfo_repr_eq(self): - hinfo = DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os') + hinfo = DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "os") assert hinfo == hinfo repr(hinfo) def test_dns_pointer_repr(self): - pointer = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') + pointer = r.DNSPointer( + "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" + ) repr(pointer) - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_dns_address_repr(self): - address = r.DNSAddress('irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + address = r.DNSAddress("irrelevant", const._TYPE_SOA, const._CLASS_IN, 1, b"a") assert repr(address).endswith("b'a'") address_ipv4 = r.DNSAddress( - 'irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, socket.inet_pton(socket.AF_INET, '127.0.0.1') + "irrelevant", + const._TYPE_SOA, + const._CLASS_IN, + 1, + socket.inet_pton(socket.AF_INET, "127.0.0.1"), ) - assert repr(address_ipv4).endswith('127.0.0.1') + assert repr(address_ipv4).endswith("127.0.0.1") address_ipv6 = r.DNSAddress( - 'irrelevant', const._TYPE_SOA, const._CLASS_IN, 1, socket.inet_pton(socket.AF_INET6, '::1') + "irrelevant", + const._TYPE_SOA, + const._CLASS_IN, + 1, + socket.inet_pton(socket.AF_INET6, "::1"), ) - assert repr(address_ipv6).endswith('::1') + assert repr(address_ipv6).endswith("::1") def test_dns_question_repr(self): - question = r.DNSQuestion('irrelevant', const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE) + question = r.DNSQuestion( + "irrelevant", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE + ) repr(question) assert not question != question def test_dns_service_repr(self): service = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "a", ) repr(service) def test_dns_record_abc(self): - record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) + record = r.DNSRecord( + "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL + ) self.assertRaises(r.AbstractMethodException, record.__eq__, record) with pytest.raises((r.AbstractMethodException, TypeError)): record.write(None) # type: ignore[arg-type] @@ -87,11 +108,19 @@ def test_dns_record_abc(self): def test_dns_record_reset_ttl(self): start = r.current_time_millis() record = r.DNSRecord( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, created=start + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + created=start, ) later = start + 1000 record2 = r.DNSRecord( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, created=later + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + created=later, ) now = r.current_time_millis() @@ -114,7 +143,7 @@ def test_service_info_dunder(self): 80, 0, 0, - b'', + b"", "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -142,14 +171,14 @@ def test_dns_outgoing_repr(self): repr(dns_outgoing) def test_dns_record_is_expired(self): - record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + record = r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, 8) now = current_time_millis() assert record.is_expired(now) is False assert record.is_expired(now + (8 / 2 * 1000)) is False assert record.is_expired(now + (8 * 1000)) is True def test_dns_record_is_stale(self): - record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + record = r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, 8) now = current_time_millis() assert record.is_stale(now) is False assert record.is_stale(now + (8 / 4.1 * 1000)) is False @@ -158,7 +187,7 @@ def test_dns_record_is_stale(self): def test_dns_record_is_recent(self): now = current_time_millis() - record = r.DNSRecord('irrelevant', const._TYPE_SRV, const._CLASS_IN, 8) + record = r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, 8) assert record.is_recent(now + (8 / 4.2 * 1000)) is True assert record.is_recent(now + (8 / 3 * 1000)) is False assert record.is_recent(now + (8 / 2 * 1000)) is False @@ -168,8 +197,8 @@ def test_dns_record_is_recent(self): def test_dns_question_hashablity(): """Test DNSQuestions are hashable.""" - record1 = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) - record2 = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) + record1 = r.DNSQuestion("irrelevant", const._TYPE_A, const._CLASS_IN) + record2 = r.DNSQuestion("irrelevant", const._TYPE_A, const._CLASS_IN) record_set = {record1, record2} assert len(record_set) == 1 @@ -177,14 +206,14 @@ def test_dns_question_hashablity(): record_set.add(record1) assert len(record_set) == 1 - record3_dupe = r.DNSQuestion('irrelevant', const._TYPE_A, const._CLASS_IN) + record3_dupe = r.DNSQuestion("irrelevant", const._TYPE_A, const._CLASS_IN) assert record2 == record3_dupe assert record2.__hash__() == record3_dupe.__hash__() record_set.add(record3_dupe) assert len(record_set) == 1 - record4_dupe = r.DNSQuestion('notsame', const._TYPE_A, const._CLASS_IN) + record4_dupe = r.DNSQuestion("notsame", const._TYPE_A, const._CLASS_IN) assert record2 != record4_dupe assert record2.__hash__() != record4_dupe.__hash__() @@ -196,8 +225,12 @@ def test_dns_record_hashablity_does_not_consider_ttl(): """Test DNSRecord are hashable.""" # Verify the TTL is not considered in the hash - record1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b'same') - record2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') + record1 = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same" + ) + record2 = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same" + ) record_set = {record1, record2} assert len(record_set) == 1 @@ -205,7 +238,9 @@ def test_dns_record_hashablity_does_not_consider_ttl(): record_set.add(record1) assert len(record_set) == 1 - record3_dupe = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b'same') + record3_dupe = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same" + ) assert record2 == record3_dupe assert record2.__hash__() == record3_dupe.__hash__() @@ -218,9 +253,15 @@ def test_dns_record_hashablity_does_not_consider_unique(): # Verify the unique value is not considered in the hash record1 = r.DNSAddress( - 'irrelevant', const._TYPE_A, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, b'same' + "irrelevant", + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"same", + ) + record2 = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same" ) - record2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b'same') assert record1.class_ == record2.class_ assert record1.__hash__() == record2.__hash__() @@ -230,10 +271,10 @@ def test_dns_record_hashablity_does_not_consider_unique(): def test_dns_address_record_hashablity(): """Test DNSAddress are hashable.""" - address1 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'a') - address2 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'b') - address3 = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'c') - address4 = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 1, b'c') + address1 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 1, b"a") + address2 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 1, b"b") + address3 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 1, b"c") + address4 = r.DNSAddress("irrelevant", const._TYPE_AAAA, const._CLASS_IN, 1, b"c") record_set = {address1, address2, address3, address4} assert len(record_set) == 4 @@ -241,7 +282,7 @@ def test_dns_address_record_hashablity(): record_set.add(address1) assert len(record_set) == 4 - address3_dupe = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1, b'c') + address3_dupe = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 1, b"c") record_set.add(address3_dupe) assert len(record_set) == 4 @@ -254,8 +295,8 @@ def test_dns_address_record_hashablity(): def test_dns_hinfo_record_hashablity(): """Test DNSHinfo are hashable.""" - hinfo1 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu1', 'os') - hinfo2 = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') + hinfo1 = r.DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu1", "os") + hinfo2 = r.DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu2", "os") record_set = {hinfo1, hinfo2} assert len(record_set) == 2 @@ -263,7 +304,7 @@ def test_dns_hinfo_record_hashablity(): record_set.add(hinfo1) assert len(record_set) == 2 - hinfo2_dupe = r.DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu2', 'os') + hinfo2_dupe = r.DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu2", "os") assert hinfo2 == hinfo2_dupe assert hinfo2.__hash__() == hinfo2_dupe.__hash__() @@ -273,8 +314,12 @@ def test_dns_hinfo_record_hashablity(): def test_dns_pointer_record_hashablity(): """Test DNSPointer are hashable.""" - ptr1 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') - ptr2 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') + ptr1 = r.DNSPointer( + "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" + ) + ptr2 = r.DNSPointer( + "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456" + ) record_set = {ptr1, ptr2} assert len(record_set) == 2 @@ -282,7 +327,9 @@ def test_dns_pointer_record_hashablity(): record_set.add(ptr1) assert len(record_set) == 2 - ptr2_dupe = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '456') + ptr2_dupe = r.DNSPointer( + "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456" + ) assert ptr2 == ptr2 assert ptr2.__hash__() == ptr2_dupe.__hash__() @@ -292,18 +339,26 @@ def test_dns_pointer_record_hashablity(): def test_dns_pointer_comparison_is_case_insensitive(): """Test DNSPointer comparison is case insensitive.""" - ptr1 = r.DNSPointer('irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') - ptr2 = r.DNSPointer('irrelevant'.upper(), const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, '123') + ptr1 = r.DNSPointer( + "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" + ) + ptr2 = r.DNSPointer( + "irrelevant".upper(), + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "123", + ) assert ptr1 == ptr2 def test_dns_text_record_hashablity(): """Test DNSText are hashable.""" - text1 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') - text2 = r.DNSText('irrelevant', 1, 0, const._DNS_OTHER_TTL, b'12345678901') - text3 = r.DNSText('irrelevant', 0, 1, const._DNS_OTHER_TTL, b'12345678901') - text4 = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'ABCDEFGHIJK') + text1 = r.DNSText("irrelevant", 0, 0, const._DNS_OTHER_TTL, b"12345678901") + text2 = r.DNSText("irrelevant", 1, 0, const._DNS_OTHER_TTL, b"12345678901") + text3 = r.DNSText("irrelevant", 0, 1, const._DNS_OTHER_TTL, b"12345678901") + text4 = r.DNSText("irrelevant", 0, 0, const._DNS_OTHER_TTL, b"ABCDEFGHIJK") record_set = {text1, text2, text3, text4} @@ -312,7 +367,7 @@ def test_dns_text_record_hashablity(): record_set.add(text1) assert len(record_set) == 4 - text1_dupe = r.DNSText('irrelevant', 0, 0, const._DNS_OTHER_TTL, b'12345678901') + text1_dupe = r.DNSText("irrelevant", 0, 0, const._DNS_OTHER_TTL, b"12345678901") assert text1 == text1_dupe assert text1.__hash__() == text1_dupe.__hash__() @@ -322,10 +377,46 @@ def test_dns_text_record_hashablity(): def test_dns_service_record_hashablity(): """Test DNSService are hashable.""" - srv1 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a') - srv2 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 1, 80, 'a') - srv3 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 81, 'a') - srv4 = r.DNSService('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'ab') + srv1 = r.DNSService( + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "a", + ) + srv2 = r.DNSService( + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 1, + 80, + "a", + ) + srv3 = r.DNSService( + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 81, + "a", + ) + srv4 = r.DNSService( + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "ab", + ) record_set = {srv1, srv2, srv3, srv4} @@ -335,7 +426,14 @@ def test_dns_service_record_hashablity(): assert len(record_set) == 4 srv1_dupe = r.DNSService( - 'irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'a' + "irrelevant", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "a", ) assert srv1 == srv1_dupe assert srv1.__hash__() == srv1_dupe.__hash__() @@ -347,21 +445,42 @@ def test_dns_service_record_hashablity(): def test_dns_service_server_key(): """Test DNSService server_key is lowercase.""" srv1 = r.DNSService( - 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'X.local.' + "X._tcp._http.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "X.local.", ) - assert srv1.name == 'X._tcp._http.local.' - assert srv1.key == 'x._tcp._http.local.' - assert srv1.server == 'X.local.' - assert srv1.server_key == 'x.local.' + assert srv1.name == "X._tcp._http.local." + assert srv1.key == "x._tcp._http.local." + assert srv1.server == "X.local." + assert srv1.server_key == "x.local." def test_dns_service_server_comparison_is_case_insensitive(): """Test DNSService server comparison is case insensitive.""" srv1 = r.DNSService( - 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'X.local.' + "X._tcp._http.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "X.local.", ) srv2 = r.DNSService( - 'X._tcp._http.local.', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 0, 0, 80, 'x.local.' + "X._tcp._http.local.", + const._TYPE_SRV, + const._CLASS_IN, + const._DNS_HOST_TTL, + 0, + 0, + 80, + "x.local.", ) assert srv1 == srv2 @@ -369,10 +488,20 @@ def test_dns_service_server_comparison_is_case_insensitive(): def test_dns_nsec_record_hashablity(): """Test DNSNsec are hashable.""" nsec1 = r.DNSNsec( - 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2, 3] + "irrelevant", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "irrelevant", + [1, 2, 3], ) nsec2 = r.DNSNsec( - 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2] + "irrelevant", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "irrelevant", + [1, 2], ) record_set = {nsec1, nsec2} @@ -382,7 +511,12 @@ def test_dns_nsec_record_hashablity(): assert len(record_set) == 2 nsec2_dupe = r.DNSNsec( - 'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2] + "irrelevant", + const._TYPE_PTR, + const._CLASS_IN, + const._DNS_OTHER_TTL, + "irrelevant", + [1, 2], ) assert nsec2 == nsec2_dupe assert nsec2.__hash__() == nsec2_dupe.__hash__() @@ -394,10 +528,14 @@ def test_dns_nsec_record_hashablity(): def test_rrset_does_not_consider_ttl(): """Test DNSRRSet does not consider the ttl in the hash.""" - longarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 100, b'same') - shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') - longaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 100, b'same') - shortaaaarec = r.DNSAddress('irrelevant', const._TYPE_AAAA, const._CLASS_IN, 10, b'same') + longarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 100, b"same") + shortarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 10, b"same") + longaaaarec = r.DNSAddress( + "irrelevant", const._TYPE_AAAA, const._CLASS_IN, 100, b"same" + ) + shortaaaarec = r.DNSAddress( + "irrelevant", const._TYPE_AAAA, const._CLASS_IN, 10, b"same" + ) rrset = DNSRRSet([longarec, shortaaaarec]) @@ -406,10 +544,12 @@ def test_rrset_does_not_consider_ttl(): assert not rrset.suppresses(longaaaarec) assert rrset.suppresses(shortaaaarec) - verylongarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 1000, b'same') - longarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 100, b'same') - mediumarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 60, b'same') - shortarec = r.DNSAddress('irrelevant', const._TYPE_A, const._CLASS_IN, 10, b'same') + verylongarec = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, 1000, b"same" + ) + longarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 100, b"same") + mediumarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 60, b"same") + shortarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 10, b"same") rrset2 = DNSRRSet([mediumarec]) assert not rrset2.suppresses(verylongarec) diff --git a/tests/test_engine.py b/tests/test_engine.py index dc6674dd2..7a10b48d3 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._engine """ +"""Unit tests for zeroconf._engine""" import asyncio import itertools @@ -15,7 +15,7 @@ from zeroconf import _engine, const from zeroconf.asyncio import AsyncZeroconf -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -35,12 +35,18 @@ def teardown_module(): @pytest.mark.asyncio async def test_reaper(): with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf = aiozc.zeroconf cache = zeroconf.cache - original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) - record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + original_entries = list( + itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) + ) + record_with_10s_ttl = r.DNSAddress( + "a", const._TYPE_SOA, const._CLASS_IN, 10, b"a" + ) + record_with_1s_ttl = r.DNSAddress( + "a", const._TYPE_SOA, const._CLASS_IN, 1, b"b" + ) zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() @@ -50,17 +56,25 @@ async def test_reaper(): const._TYPE_PTR, const._CLASS_IN, 10000, - 'known-to-other._hap._tcp.local.', + "known-to-other._hap._tcp.local.", ) } - zeroconf.question_history.add_question_at_time(question, now, other_known_answers) + zeroconf.question_history.add_question_at_time( + question, now, other_known_answers + ) assert zeroconf.question_history.suppresses(question, now, other_known_answers) - entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + entries_with_cache = list( + itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) + ) await asyncio.sleep(1.2) - entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + entries = list( + itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) + ) assert zeroconf.cache.get(record_with_1s_ttl) is None await aiozc.async_close() - assert not zeroconf.question_history.suppresses(question, now, other_known_answers) + assert not zeroconf.question_history.suppresses( + question, now, other_known_answers + ) assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries @@ -71,10 +85,14 @@ async def test_reaper(): async def test_reaper_aborts_when_done(): """Ensure cache cleanup stops when zeroconf is done.""" with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf = aiozc.zeroconf - record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') - record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') + record_with_10s_ttl = r.DNSAddress( + "a", const._TYPE_SOA, const._CLASS_IN, 10, b"a" + ) + record_with_1s_ttl = r.DNSAddress( + "a", const._TYPE_SOA, const._CLASS_IN, 1, b"b" + ) zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) assert zeroconf.cache.get(record_with_10s_ttl) is not None assert zeroconf.cache.get(record_with_1s_ttl) is not None diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 6a37c6dbc..33eac2d4d 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._exceptions """ +"""Unit tests for zeroconf._exceptions""" import logging import unittest @@ -10,7 +10,7 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -30,7 +30,7 @@ class Exceptions(unittest.TestCase): @classmethod def setUpClass(cls): - cls.browser = Zeroconf(interfaces=['127.0.0.1']) + cls.browser = Zeroconf(interfaces=["127.0.0.1"]) @classmethod def tearDownClass(cls): @@ -38,62 +38,74 @@ def tearDownClass(cls): del cls.browser def test_bad_service_info_name(self): - self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") + self.assertRaises( + r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not" + ) def test_bad_service_names(self): bad_names_to_try = ( - '', - 'local', - '_tcp.local.', - '_udp.local.', - '._udp.local.', - '_@._tcp.local.', - '_A@._tcp.local.', - '_x--x._tcp.local.', - '_-x._udp.local.', - '_x-._tcp.local.', - '_22._udp.local.', - '_2-2._tcp.local.', - '\x00._x._udp.local.', + "", + "local", + "_tcp.local.", + "_udp.local.", + "._udp.local.", + "_@._tcp.local.", + "_A@._tcp.local.", + "_x--x._tcp.local.", + "_-x._udp.local.", + "_x-._tcp.local.", + "_22._udp.local.", + "_2-2._tcp.local.", + "\x00._x._udp.local.", ) for name in bad_names_to_try: - self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, name, 'x.' + name) + self.assertRaises( + r.BadTypeInNameException, + self.browser.get_service_info, + name, + "x." + name, + ) def test_bad_local_names_for_get_service_info(self): bad_names_to_try = ( - 'homekitdev._nothttp._tcp.local.', - 'homekitdev._http._udp.local.', + "homekitdev._nothttp._tcp.local.", + "homekitdev._http._udp.local.", ) for name in bad_names_to_try: self.assertRaises( - r.BadTypeInNameException, self.browser.get_service_info, '_http._tcp.local.', name + r.BadTypeInNameException, + self.browser.get_service_info, + "_http._tcp.local.", + name, ) def test_good_instance_names(self): - assert r.service_type_name('.._x._tcp.local.') == '_x._tcp.local.' - assert r.service_type_name('x.y._http._tcp.local.') == '_http._tcp.local.' - assert r.service_type_name('1.2.3._mqtt._tcp.local.') == '_mqtt._tcp.local.' - assert r.service_type_name('x.sub._http._tcp.local.') == '_http._tcp.local.' + assert r.service_type_name(".._x._tcp.local.") == "_x._tcp.local." + assert r.service_type_name("x.y._http._tcp.local.") == "_http._tcp.local." + assert r.service_type_name("1.2.3._mqtt._tcp.local.") == "_mqtt._tcp.local." + assert r.service_type_name("x.sub._http._tcp.local.") == "_http._tcp.local." assert ( - r.service_type_name('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') - == '_http._tcp.local.' + r.service_type_name( + "6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local." + ) + == "_http._tcp.local." ) def test_good_instance_names_without_protocol(self): good_names_to_try = ( "Rachio-C73233.local.", - 'YeelightColorBulb-3AFD.local.', - 'YeelightTunableBulb-7220.local.', + "YeelightColorBulb-3AFD.local.", + "YeelightTunableBulb-7220.local.", "AlexanderHomeAssistant 74651D.local.", - 'iSmartGate-152.local.', - 'MyQ-FGA.local.', - 'lutron-02c4392a.local.', - 'WICED-hap-3E2734.local.', - 'MyHost.local.', - 'MyHost.sub.local.', + "iSmartGate-152.local.", + "MyQ-FGA.local.", + "lutron-02c4392a.local.", + "WICED-hap-3E2734.local.", + "MyHost.local.", + "MyHost.sub.local.", ) for name in good_names_to_try: - assert r.service_type_name(name, strict=False) == 'local.' + assert r.service_type_name(name, strict=False) == "local." for name in good_names_to_try: # Raises without strict=False @@ -101,48 +113,51 @@ def test_good_instance_names_without_protocol(self): def test_bad_types(self): bad_names_to_try = ( - '._x._tcp.local.', - 'a' * 64 + '._sub._http._tcp.local.', - 'a' * 62 + 'â._sub._http._tcp.local.', + "._x._tcp.local.", + "a" * 64 + "._sub._http._tcp.local.", + "a" * 62 + "â._sub._http._tcp.local.", ) for name in bad_names_to_try: self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) def test_bad_sub_types(self): bad_names_to_try = ( - '_sub._http._tcp.local.', - '._sub._http._tcp.local.', - '\x7f._sub._http._tcp.local.', - '\x1f._sub._http._tcp.local.', + "_sub._http._tcp.local.", + "._sub._http._tcp.local.", + "\x7f._sub._http._tcp.local.", + "\x1f._sub._http._tcp.local.", ) for name in bad_names_to_try: self.assertRaises(r.BadTypeInNameException, r.service_type_name, name) def test_good_service_names(self): good_names_to_try = ( - ('_x._tcp.local.', '_x._tcp.local.'), - ('_x._udp.local.', '_x._udp.local.'), - ('_12345-67890-abc._udp.local.', '_12345-67890-abc._udp.local.'), - ('x._sub._http._tcp.local.', '_http._tcp.local.'), - ('a' * 63 + '._sub._http._tcp.local.', '_http._tcp.local.'), - ('a' * 61 + 'â._sub._http._tcp.local.', '_http._tcp.local.'), + ("_x._tcp.local.", "_x._tcp.local."), + ("_x._udp.local.", "_x._udp.local."), + ("_12345-67890-abc._udp.local.", "_12345-67890-abc._udp.local."), + ("x._sub._http._tcp.local.", "_http._tcp.local."), + ("a" * 63 + "._sub._http._tcp.local.", "_http._tcp.local."), + ("a" * 61 + "â._sub._http._tcp.local.", "_http._tcp.local."), ) for name, result in good_names_to_try: assert r.service_type_name(name) == result - assert r.service_type_name('_one_two._tcp.local.', strict=False) == '_one_two._tcp.local.' + assert ( + r.service_type_name("_one_two._tcp.local.", strict=False) + == "_one_two._tcp.local." + ) def test_invalid_addresses(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - bad = (b'127.0.0.1', b'::1') + bad = (b"127.0.0.1", b"::1") for addr in bad: self.assertRaisesRegex( TypeError, - 'Addresses must either ', + "Addresses must either ", ServiceInfo, type_, registration_name, diff --git a/tests/test_handlers.py b/tests/test_handlers.py index a13824e03..e2e69aea0 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._handlers """ +"""Unit tests for zeroconf._handlers""" import asyncio import logging @@ -26,7 +26,7 @@ from . import _clear_cache, _inject_response, has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -44,14 +44,14 @@ def teardown_module(): class TestRegistrar(unittest.TestCase): def test_ttl(self): # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( type_, registration_name, @@ -103,12 +103,16 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) + query.add_question( + r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN) + ) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) assert question_answers - _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) + _process_outgoing_packet( + construct_outgoing_multicast_answers(question_answers.mcast_aggregate) + ) # The additonals should all be suppresed since they are all in the answers section # There will be one NSEC additional to indicate the lack of AAAA record @@ -142,12 +146,16 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) + query.add_question( + r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN) + ) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) assert question_answers - _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) + _process_outgoing_packet( + construct_outgoing_multicast_answers(question_answers.mcast_aggregate) + ) # There will be one NSEC additional to indicate the lack of AAAA record assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 @@ -164,7 +172,7 @@ def _process_outgoing_packet(out): def test_name_conflicts(self): # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_homeassistant._tcp.local." name = "Home" registration_name = f"{name}.{type_}" @@ -193,7 +201,7 @@ def test_name_conflicts(self): def test_register_and_lookup_type_by_uppercase_name(self): # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_mylowertype._tcp.local." name = "Home" registration_name = f"{name}.{type_}" @@ -225,16 +233,23 @@ def test_register_and_lookup_type_by_uppercase_name(self): def test_ptr_optimization(): # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) # register @@ -289,18 +304,20 @@ def test_ptr_optimization(): zc.close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_any_query_for_ptr(): """Test that queries for ANY will return PTR records and the response is aggregated.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_anyptr._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] + ) zc.registry.async_add(info) _clear_cache(zc) @@ -308,7 +325,9 @@ def test_any_query_for_ptr(): question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers mcast_answers = list(question_answers.mcast_aggregate) assert mcast_answers[0].name == type_ @@ -318,25 +337,29 @@ def test_any_query_for_ptr(): zc.close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_aaaa_query(): """Test that queries for AAAA records work and should respond right away.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_knownaaaservice._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] + ) zc.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] @@ -345,25 +368,29 @@ def test_aaaa_query(): zc.close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_aaaa_query_upper_case(): """Test that queries for AAAA records work and should respond right away with an upper case name.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_knownaaaservice._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) + info = ServiceInfo( + type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] + ) zc.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name.upper(), const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] @@ -372,20 +399,27 @@ def test_aaaa_query_upper_case(): zc.close() -@unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') -@unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_a_and_aaaa_record_fate_sharing(): """Test that queries for AAAA always return A records in the additionals and should respond right away.""" - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_a-and-aaaa-service._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") ipv4_address = socket.inet_aton("10.0.1.2") info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address, ipv4_address] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[ipv6_address, ipv4_address], ) aaaa_record = info.dns_addresses(version=r.IPVersion.V6Only)[0] a_record = info.dns_addresses(version=r.IPVersion.V4Only)[0] @@ -397,7 +431,9 @@ def test_a_and_aaaa_record_fate_sharing(): question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert aaaa_record in question_answers.mcast_now @@ -410,7 +446,9 @@ def test_a_and_aaaa_record_fate_sharing(): question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert a_record in question_answers.mcast_now @@ -426,15 +464,22 @@ def test_a_and_aaaa_record_fate_sharing(): def test_unicast_response(): """Ensure we send a unicast response when the source port is not the MDNS port.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) # register zc.registry.async_add(info) @@ -478,15 +523,22 @@ def test_unicast_response(): async def test_probe_answered_immediately(): """Verify probes are responded to immediately.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -522,15 +574,22 @@ async def test_probe_answered_immediately(): async def test_probe_answered_immediately_with_uppercase_name(): """Verify probes are responded to immediately with an uppercase name.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -565,7 +624,7 @@ async def test_probe_answered_immediately_with_uppercase_name(): def test_qu_response(): """Handle multicast incoming with the QU bit set.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # service definition type_ = "_test-srvc-type._tcp.local." @@ -573,9 +632,16 @@ def test_qu_response(): name = "xxxyyy" registration_name = f"{name}.{type_}" registration_name2 = f"{name}.{other_type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( other_type_, @@ -658,7 +724,12 @@ def _validate_complete_response(answers): _validate_complete_response(question_answers.mcast_now) _inject_response( - zc, r.DNSIncoming(construct_outgoing_multicast_answers(question_answers.mcast_now).packets()[0]) + zc, + r.DNSIncoming( + construct_outgoing_multicast_answers(question_answers.mcast_now).packets()[ + 0 + ] + ), ) # With the cache repopulated; should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -680,14 +751,21 @@ def _validate_complete_response(answers): def test_known_answer_supression(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_knownanswersv8._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) @@ -698,7 +776,9 @@ def test_known_answer_supression(): question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -710,7 +790,9 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -722,7 +804,9 @@ def test_known_answer_supression(): question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert question_answers.mcast_now @@ -735,7 +819,9 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -749,7 +835,9 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast expected_nsec_record = cast(r.DNSNsec, list(question_answers.mcast_now)[0]) @@ -763,7 +851,9 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert question_answers.mcast_now @@ -775,7 +865,9 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -787,7 +879,9 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -799,7 +893,9 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -812,7 +908,7 @@ def test_known_answer_supression(): def test_multi_packet_known_answer_supression(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_handlermultis._tcp.local." name = "knownname" name2 = "knownname2" @@ -822,19 +918,40 @@ def test_multi_packet_known_answer_supression(): registration2_name = f"{name2}.{type_}" registration3_name = f"{name3}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." server_name2 = "ash-3.local." server_name3 = "ash-4.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( - type_, registration2_name, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration2_name, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) info3 = ServiceInfo( - type_, registration3_name, 80, 0, 0, desc, server_name3, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration3_name, + 80, + 0, + 0, + desc, + server_name3, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) zc.registry.async_add(info2) @@ -853,7 +970,9 @@ def test_multi_packet_known_answer_supression(): generated.add_answer_at_time(info3.dns_pointer(), now) packets = generated.packets() assert len(packets) > 1 - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -867,24 +986,38 @@ def test_multi_packet_known_answer_supression(): def test_known_answer_supression_service_type_enumeration_query(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_otherknown._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) type_2 = "_otherknown2._tcp.local." name = "knownname" registration_name2 = f"{name}.{type_2}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name2 = "ash-3.local." info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info2) now = current_time_millis() @@ -892,10 +1025,14 @@ def test_known_answer_supression_service_type_enumeration_query(): # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion( + const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN + ) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -903,7 +1040,9 @@ def test_known_answer_supression_service_type_enumeration_query(): assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion( + const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN + ) generated.add_question(question) generated.add_answer_at_time( r.DNSPointer( @@ -926,7 +1065,9 @@ def test_known_answer_supression_service_type_enumeration_query(): now, ) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -940,34 +1081,52 @@ def test_known_answer_supression_service_type_enumeration_query(): def test_upper_case_enumeration_query(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) type_ = "_otherknown._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) type_2 = "_otherknown2._tcp.local." name = "knownname" registration_name2 = f"{name}.{type_2}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name2 = "ash-3.local." info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info2) _clear_cache(zc) # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion( + const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN + ) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -980,13 +1139,17 @@ def test_upper_case_enumeration_query(): def test_enumeration_query_with_no_registered_services(): - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) _clear_cache(zc) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion( + const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN + ) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert not question_answers # unregister zc.close() @@ -998,26 +1161,40 @@ def test_enumeration_query_with_no_registered_services(): async def test_qu_response_only_sends_additionals_if_sends_answer(): """Test that a QU response does not send additionals unless it sends the answer as well.""" # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_addtest1._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "ash-2.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info) type_2 = "_addtest2._tcp.local." name = "knownname" registration_name2 = f"{name}.{type_2}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name2 = "ash-3.local." info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, server_name2, addresses=[socket.inet_aton("10.0.1.2")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + server_name2, + addresses=[socket.inet_aton("10.0.1.2")], ) zc.registry.async_add(info2) @@ -1028,7 +1205,9 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Add the A record to the cache with 50% ttl remaining a_record = info.dns_addresses()[0] - a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) + a_record.set_created_ttl( + current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl + ) assert not a_record.is_recent(current_time_millis()) info._dns_address_cache = None # we are mutating the record so clear the cache zc.cache.async_add_records([a_record]) @@ -1079,7 +1258,9 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Remove the 100% PTR record and add a 50% PTR record zc.cache.async_remove_records([ptr_record]) - ptr_record.set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) + ptr_record.set_created_ttl( + current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl + ) assert not ptr_record.is_recent(current_time_millis()) zc.cache.async_add_records([ptr_record]) # With QU should respond to only multicast since the has less @@ -1117,7 +1298,9 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - zc.cache.async_add_records([info2.dns_pointer()]) # Add 100% TTL for info2 to the cache + zc.cache.async_add_records( + [info2.dns_pointer()] + ) # Add 100% TTL for info2 to the cache question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False @@ -1149,19 +1332,28 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): async def test_cache_flush_bit(): """Test that the cache flush bit sets the TTL to one for matching records.""" # instantiate a zeroconf instance - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_cacheflush._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "server-uu1.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) a_record = info.dns_addresses()[0] - zc.cache.async_add_records([info.dns_pointer(), a_record, info.dns_text(), info.dns_service()]) + zc.cache.async_add_records( + [info.dns_pointer(), a_record, info.dns_text(), info.dns_service()] + ) info.addresses = [socket.inet_aton("10.0.1.5"), socket.inet_aton("10.0.1.6")] new_records = info.dns_addresses() @@ -1210,7 +1402,9 @@ async def test_cache_flush_bit(): assert cached_record is not None assert cached_record.ttl == 1 - for entry in zc.cache.async_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): + for entry in zc.cache.async_all_by_details( + server_name, const._TYPE_A, const._CLASS_IN + ): assert isinstance(entry, r.DNSAddress) if entry.address == fresh_address: assert entry.ttl > 1 @@ -1233,28 +1427,39 @@ async def test_cache_flush_bit(): async def test_record_update_manager_add_listener_callsback_existing_records(): """Test that the RecordUpdateManager will callback existing records.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc: Zeroconf = aiozc.zeroconf updated = [] class MyListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] + ) -> None: """Update multiple records in one shot.""" updated.extend(records) type_ = "_cacheflush._tcp.local." name = "knownname" registration_name = f"{name}.{type_}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} server_name = "server-uu1.local." info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + server_name, + addresses=[socket.inet_aton("10.0.1.2")], ) a_record = info.dns_addresses()[0] ptr_record = info.dns_pointer() - zc.cache.async_add_records([ptr_record, a_record, info.dns_text(), info.dns_service()]) + zc.cache.async_add_records( + [ptr_record, a_record, info.dns_text(), info.dns_service()] + ) listener = MyListener() @@ -1278,7 +1483,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor @pytest.mark.asyncio async def test_questions_query_handler_populates_the_question_history_from_qm_questions(): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf now = current_time_millis() _clear_cache(zc) @@ -1301,13 +1506,19 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) question.unicast = False known_answer = r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + "known-to-other._hap._tcp.local.", ) generated.add_question(question) generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1320,7 +1531,7 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu @pytest.mark.asyncio async def test_questions_query_handler_does_not_put_qu_questions_in_history(): - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf now = current_time_millis() _clear_cache(zc) @@ -1339,13 +1550,19 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) question.unicast = True known_answer = r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'notqu._hap._tcp.local.' + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + "notqu._hap._tcp.local.", ) generated.add_question(question) generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) + question_answers = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], False + ) assert question_answers assert "qu._hap._tcp.local." in str(question_answers) assert not question_answers.ucast # has not multicast recently @@ -1365,7 +1582,7 @@ async def test_guard_against_low_ptr_ttl(): TTLs would will cause ServiceBrowsers to flood the network with excessive refresh queries. """ - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf # Apple uses a 15s minimum TTL, however we do not have the same # level of rate limit and safe guards so we use 1/4 of the recommended value @@ -1374,21 +1591,21 @@ async def test_guard_against_low_ptr_ttl(): const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, 2, - 'low.local.', + "low.local.", ) answer_with_normal_ttl = r.DNSPointer( "myservicelow_tcp._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'normal.local.', + "normal.local.", ) good_bye_answer = r.DNSPointer( "myservicelow_tcp._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, 0, - 'goodbye.local.', + "goodbye.local.", ) # TTL should be adjusted to a safe value response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -1411,21 +1628,21 @@ async def test_guard_against_low_ptr_ttl(): @pytest.mark.asyncio async def test_duplicate_goodbye_answers_in_packet(): """Ensure we do not throw an exception when there are duplicate goodbye records in a packet.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf answer_with_normal_ttl = r.DNSPointer( "myservicelow_tcp._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'host.local.', + "host.local.", ) good_bye_answer = r.DNSPointer( "myservicelow_tcp._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, 0, - 'host.local.', + "host.local.", ) response = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) response.add_answer_at_time(answer_with_normal_ttl, 0) @@ -1447,7 +1664,7 @@ async def test_response_aggregation_timings(run_isolated): type_2 = "_mservice2._tcp.local." type_3 = "_mservice3._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() name = "xxxyyy" @@ -1455,15 +1672,36 @@ async def test_response_aggregation_timings(run_isolated): registration_name2 = f"{name}.{type_2}" registration_name3 = f"{name}.{type_3}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + "ash-4.local.", + addresses=[socket.inet_aton("10.0.1.3")], ) info3 = ServiceInfo( - type_3, registration_name3, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + type_3, + registration_name3, + 80, + 0, + 0, + desc, + "ash-4.local.", + addresses=[socket.inet_aton("10.0.1.3")], ) aiozc.zeroconf.registry.async_add(info) aiozc.zeroconf.registry.async_add(info2) @@ -1489,9 +1727,9 @@ async def test_response_aggregation_timings(run_isolated): protocol = zc.engine.protocols[0] with patch.object(aiozc.zeroconf, "async_send") as send_mock: - protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.datagram_received(query.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) await asyncio.sleep(0.7) # Should aggregate into a single answer with up to a 500ms + 120ms delay @@ -1504,7 +1742,7 @@ async def test_response_aggregation_timings(run_isolated): assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() - protocol.datagram_received(query3.packets()[0], ('127.0.0.1', const._MDNS_PORT)) + protocol.datagram_received(query3.packets()[0], ("127.0.0.1", const._MDNS_PORT)) await asyncio.sleep(0.3) # Should send within 120ms since there are no other @@ -1520,7 +1758,7 @@ async def test_response_aggregation_timings(run_isolated): # Because the response was sent in the last second we need to make # sure the next answer is delayed at least a second aiozc.zeroconf.engine.protocols[0].datagram_received( - query4.packets()[0], ('127.0.0.1', const._MDNS_PORT) + query4.packets()[0], ("127.0.0.1", const._MDNS_PORT) ) await asyncio.sleep(0.5) @@ -1542,21 +1780,30 @@ async def test_response_aggregation_timings(run_isolated): @pytest.mark.asyncio -async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): +async def test_response_aggregation_timings_multiple( + run_isolated, disable_duplicate_packet_suppression +): """Verify multicast responses that are aggregated do not take longer than 620ms to send. 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" type_2 = "_mservice2._tcp.local." - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() name = "xxxyyy" registration_name2 = f"{name}.{type_2}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.3")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + "ash-4.local.", + addresses=[socket.inet_aton("10.0.1.3")], ) aiozc.zeroconf.registry.async_add(info2) @@ -1569,8 +1816,10 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli with patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() - protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.last_time = ( + 0 # manually reset the last time to avoid duplicate packet suppression + ) await asyncio.sleep(0.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1580,8 +1829,10 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() - protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.last_time = ( + 0 # manually reset the last time to avoid duplicate packet suppression + ) await asyncio.sleep(1.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1591,10 +1842,14 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert info2.dns_pointer() in incoming.answers() send_mock.reset_mock() - protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression - protocol.datagram_received(query2.packets()[0], ('127.0.0.1', const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.last_time = ( + 0 # manually reset the last time to avoid duplicate packet suppression + ) + protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) + protocol.last_time = ( + 0 # manually reset the last time to avoid duplicate packet suppression + ) # The delay should increase with two packets and # 900ms is beyond the maximum aggregation delay # when there is no network protection delay @@ -1636,21 +1891,56 @@ async def test_response_aggregation_random_delay(): registration_name4 = f"{name}.{type_4}" registration_name5 = f"{name}.{type_5}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-1.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-1.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.3")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], ) info3 = ServiceInfo( - type_3, registration_name3, 80, 0, 0, desc, "ash-3.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_3, + registration_name3, + 80, + 0, + 0, + desc, + "ash-3.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info4 = ServiceInfo( - type_4, registration_name4, 80, 0, 0, desc, "ash-4.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_4, + registration_name4, + 80, + 0, + 0, + desc, + "ash-4.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info5 = ServiceInfo( - type_5, registration_name5, 80, 0, 0, desc, "ash-5.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_5, + registration_name5, + 80, + 0, + 0, + desc, + "ash-5.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) mocked_zc = unittest.mock.MagicMock() outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 500) @@ -1668,7 +1958,9 @@ async def test_response_aggregation_random_delay(): # The third group should always be coalesced into first group since it will always come before outgoing_queue._multicast_delay_random_min = 100 outgoing_queue._multicast_delay_random_max = 200 - outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) + outgoing_queue.async_add( + now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()} + ) assert len(outgoing_queue.queue) == 1 assert info.dns_pointer() in outgoing_queue.queue[0].answers @@ -1698,12 +1990,26 @@ async def test_future_answers_are_removed_on_send(): registration_name = f"{name}.{type_}" registration_name2 = f"{name}.{type_2}" - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-1.local.", addresses=[socket.inet_aton("10.0.1.2")] + type_, + registration_name, + 80, + 0, + 0, + desc, + "ash-1.local.", + addresses=[socket.inet_aton("10.0.1.2")], ) info2 = ServiceInfo( - type_2, registration_name2, 80, 0, 0, desc, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.3")] + type_2, + registration_name2, + 80, + 0, + 0, + desc, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.3")], ) mocked_zc = unittest.mock.MagicMock() outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) @@ -1743,22 +2049,25 @@ async def test_future_answers_are_removed_on_send(): async def test_add_listener_warns_when_not_using_record_update_listener(caplog): """Log when a listener is added that is not using RecordUpdateListener as a base class.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc: Zeroconf = aiozc.zeroconf updated = [] class MyListener: """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] + ) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.add_listener(MyListener(), None) # type: ignore[arg-type] await asyncio.sleep(0) # flush out any call soons assert ( - "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text - or "TypeError: Argument \'listener\' has incorrect type" in caplog.text + "listeners passed to async_add_listener must inherit from RecordUpdateListener" + in caplog.text + or "TypeError: Argument 'listener' has incorrect type" in caplog.text ) await aiozc.async_close() @@ -1768,7 +2077,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor async def test_async_updates_iteration_safe(): """Ensure we can safely iterate over the async_updates.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc: Zeroconf = aiozc.zeroconf updated = [] good_bye_answer = r.DNSPointer( @@ -1776,13 +2085,15 @@ async def test_async_updates_iteration_safe(): const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, 0, - 'goodbye.local.', + "goodbye.local.", ) class OtherListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] + ) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -1791,7 +2102,9 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor class ListenerThatAddsListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records( + self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] + ) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.async_add_listener(other, None) @@ -1812,7 +2125,7 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor async def test_async_updates_complete_iteration_safe(): """Ensure we can safely iterate over the async_updates_complete.""" - aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc: Zeroconf = aiozc.zeroconf class OtherListener(r.RecordUpdateListener): diff --git a/tests/test_history.py b/tests/test_history.py index fca57be2a..659e67f8e 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -17,12 +17,20 @@ def test_question_suppression(): now = r.current_time_millis() other_known_answers: Set[r.DNSRecord] = { r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + "known-to-other._hap._tcp.local.", ) } our_known_answers: Set[r.DNSRecord] = { r.DNSPointer( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + "known-to-us._hap._tcp.local.", ) } @@ -55,7 +63,7 @@ def test_question_expire(): const._TYPE_PTR, const._CLASS_IN, 10000, - 'known-to-other._hap._tcp.local.', + "known-to-other._hap._tcp.local.", created=now, ) } diff --git a/tests/test_init.py b/tests/test_init.py index 1d1f7086b..3ba285d5e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf.py """ +"""Unit tests for zeroconf.py""" import logging import socket @@ -15,7 +15,7 @@ from . import _inject_responses -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -34,7 +34,9 @@ class Names(unittest.TestCase): def test_long_name(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) question = r.DNSQuestion( - "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", const._TYPE_SRV, const._CLASS_IN + "this.is.a.very.long.name.with.lots.of.parts.in.it.local.", + const._TYPE_SRV, + const._CLASS_IN, ) generated.add_question(question) r.DNSIncoming(generated.packets()[0]) @@ -70,11 +72,11 @@ def test_same_name(self): def test_verify_name_change_with_lots_of_names(self): # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) # create a bunch of servers type_ = "_my-service._tcp.local." - name = 'a wonderful service' + name = "a wonderful service" server_count = 300 self.generate_many_hosts(zc, type_, name, server_count) @@ -87,15 +89,15 @@ def test_large_packet_exception_log_handling(self): """Verify we downgrade debug after warning.""" # instantiate a zeroconf instance - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) - with patch('zeroconf._logger.log.warning') as mocked_log_warn, patch( - 'zeroconf._logger.log.debug' + with patch("zeroconf._logger.log.warning") as mocked_log_warn, patch( + "zeroconf._logger.log.debug" ) as mocked_log_debug: # now that we have a long packet in our possession, let's verify the # exception handling. out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) - out.data.append(b'\0' * 10000) + out.data.append(b"\0" * 10000) # mock the zeroconf logger and check for the correct logging backoff call_counts = mocked_log_warn.call_count, mocked_log_debug.call_count @@ -112,7 +114,7 @@ def test_large_packet_exception_log_handling(self): zc.send(out, const._MDNS_ADDR, const._MDNS_PORT) time.sleep(0.3) r.log.debug( - 'warn %d debug %d was %s', + "warn %d debug %d was %s", mocked_log_warn.call_count, mocked_log_debug.call_count, call_counts, @@ -123,10 +125,10 @@ def test_large_packet_exception_log_handling(self): zc.close() def verify_name_change(self, zc, type_, name, number_hosts): - desc = {'path': '/~paulsm/'} + desc = {"path": "/~paulsm/"} info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, @@ -146,7 +148,7 @@ def verify_name_change(self, zc, type_, name, number_hosts): # in the registry info_service2 = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, @@ -155,23 +157,26 @@ def verify_name_change(self, zc, type_, name, number_hosts): addresses=[socket.inet_aton("10.0.1.2")], ) zc.register_service(info_service2, allow_name_change=True) - assert info_service2.name.split('.')[0] == '%s-%d' % (name, number_hosts + 1) + assert info_service2.name.split(".")[0] == "%s-%d" % (name, number_hosts + 1) def generate_many_hosts(self, zc, type_, name, number_hosts): block_size = 25 number_hosts = int((number_hosts - 1) / block_size + 1) * block_size out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) for i in range(1, number_hosts + 1): - next_name = name if i == 1 else '%s-%d' % (name, i) + next_name = name if i == 1 else "%s-%d" % (name, i) self.generate_host(out, next_name, type_) _inject_responses(zc, [r.DNSIncoming(packet) for packet in out.packets()]) @staticmethod def generate_host(out, host_name, type_): - name = '.'.join((host_name, type_)) + name = ".".join((host_name, type_)) out.add_answer_at_time( - r.DNSPointer(type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name), 0 + r.DNSPointer( + type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name + ), + 0, ) out.add_answer_at_time( r.DNSService( diff --git a/tests/test_listener.py b/tests/test_listener.py index bd8022736..6faab4e80 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._listener """ +"""Unit tests for zeroconf._listener""" import logging import unittest @@ -23,7 +23,7 @@ from . import QuestionHistoryWithoutSuppression -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -43,7 +43,7 @@ def test_guard_against_oversized_packets(): These packets can quickly overwhelm the system. """ - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) @@ -54,7 +54,7 @@ def test_guard_against_oversized_packets(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ), 0, ) @@ -77,7 +77,7 @@ def test_guard_against_oversized_packets(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ) generated.add_answer_at_time( @@ -91,10 +91,10 @@ def test_guard_against_oversized_packets(): listener = _listener.AsyncListener(zc) listener.transport = unittest.mock.MagicMock() - listener.datagram_received(ok_packet, ('127.0.0.1', const._MDNS_PORT)) + listener.datagram_received(ok_packet, ("127.0.0.1", const._MDNS_PORT)) assert zc.cache.async_get_unique(okpacket_record) is not None - listener.datagram_received(over_sized_packet, ('127.0.0.1', const._MDNS_PORT)) + listener.datagram_received(over_sized_packet, ("127.0.0.1", const._MDNS_PORT)) assert ( zc.cache.async_get_unique( r.DNSText( @@ -102,15 +102,15 @@ def test_guard_against_oversized_packets(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ) ) is None ) - logging.getLogger('zeroconf').setLevel(logging.INFO) + logging.getLogger("zeroconf").setLevel(logging.INFO) - listener.datagram_received(over_sized_packet, ('::1', const._MDNS_PORT, 1, 1)) + listener.datagram_received(over_sized_packet, ("::1", const._MDNS_PORT, 1, 1)) assert ( zc.cache.async_get_unique( r.DNSText( @@ -118,7 +118,7 @@ def test_guard_against_oversized_packets(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 500, - b'path=/~paulsm/', + b"path=/~paulsm/", ) ) is None @@ -131,9 +131,14 @@ def test_guard_against_duplicate_packets(): """Ensure we do not process duplicate packets. These packets can quickly overwhelm the system. """ - zc = Zeroconf(interfaces=['127.0.0.1']) + zc = Zeroconf(interfaces=["127.0.0.1"]) zc.registry.async_add( - ServiceInfo("_http._tcp.local.", "Test._http._tcp.local.", server="Test._http._tcp.local.", port=4) + ServiceInfo( + "_http._tcp.local.", + "Test._http._tcp.local.", + server="Test._http._tcp.local.", + port=4, + ) ) zc.question_history = QuestionHistoryWithoutSuppression() @@ -174,14 +179,22 @@ def handle_query_or_defer( start_time = current_time_millis() listener._process_datagram_at_time( - False, len(packet_with_qm_question), start_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + start_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the same packet again and handle_query_or_defer should not fire listener._process_datagram_at_time( - False, len(packet_with_qm_question), start_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + start_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() @@ -190,35 +203,55 @@ def handle_query_or_defer( new_time = start_time + 1100 # Now call with the same packet again and handle_query_or_defer should fire listener._process_datagram_at_time( - False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + new_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet and handle_query_or_defer should fire listener._process_datagram_at_time( - False, len(packet_with_qm_question2), new_time, packet_with_qm_question2, addrs + False, + len(packet_with_qm_question2), + new_time, + packet_with_qm_question2, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet and handle_query_or_defer should fire listener._process_datagram_at_time( - False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + new_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the different packet with qu question and handle_query_or_defer should fire listener._process_datagram_at_time( - False, len(packet_with_qu_question), new_time, packet_with_qu_question, addrs + False, + len(packet_with_qu_question), + new_time, + packet_with_qu_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call again with the same packet that has a qu question and handle_query_or_defer should fire listener._process_datagram_at_time( - False, len(packet_with_qu_question), new_time, packet_with_qu_question, addrs + False, + len(packet_with_qu_question), + new_time, + packet_with_qu_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() @@ -227,20 +260,30 @@ def handle_query_or_defer( # Call with the QM packet again listener._process_datagram_at_time( - False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + new_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() # Now call with the same packet again and handle_query_or_defer should not fire listener._process_datagram_at_time( - False, len(packet_with_qm_question), new_time, packet_with_qm_question, addrs + False, + len(packet_with_qm_question), + new_time, + packet_with_qm_question, + addrs, ) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() # Now call with garbage - listener._process_datagram_at_time(False, len(b'garbage'), new_time, b'garbage', addrs) + listener._process_datagram_at_time( + False, len(b"garbage"), new_time, b"garbage", addrs + ) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() diff --git a/tests/test_logger.py b/tests/test_logger.py index 84a46f89d..7a9b48676 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -11,16 +11,16 @@ def test_loading_logger(): """Test loading logger does not change level unless it is unset.""" - log = logging.getLogger('zeroconf') + log = logging.getLogger("zeroconf") log.setLevel(logging.CRITICAL) set_logger_level_if_unset() - log = logging.getLogger('zeroconf') + log = logging.getLogger("zeroconf") assert log.level == logging.CRITICAL - log = logging.getLogger('zeroconf') + log = logging.getLogger("zeroconf") log.setLevel(logging.NOTSET) set_logger_level_if_unset() - log = logging.getLogger('zeroconf') + log = logging.getLogger("zeroconf") assert log.level == logging.WARNING @@ -73,12 +73,12 @@ def test_llog_exception_debug(): with patch("zeroconf._logger.log.debug") as mock_log_debug: quiet_logger.log_exception_debug("the exception") - assert mock_log_debug.mock_calls == [call('the exception', exc_info=True)] + assert mock_log_debug.mock_calls == [call("the exception", exc_info=True)] with patch("zeroconf._logger.log.debug") as mock_log_debug: quiet_logger.log_exception_debug("the exception") - assert mock_log_debug.mock_calls == [call('the exception', exc_info=False)] + assert mock_log_debug.mock_calls == [call("the exception", exc_info=False)] def test_log_exception_once(): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6990917a2..e682a34c8 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -""" Unit tests for zeroconf._protocol """ +"""Unit tests for zeroconf._protocol""" import copy import logging @@ -19,7 +19,7 @@ from . import has_working_ipv6 -log = logging.getLogger('zeroconf') +log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -49,16 +49,18 @@ def test_parse_own_packet_flags(self): def test_parse_own_packet_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) + generated.add_question( + r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) + ) r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_nsec(self): answer = r.DNSNsec( - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", [const._TYPE_TXT, const._TYPE_SRV], ) @@ -69,11 +71,11 @@ def test_parse_own_packet_nsec(self): # Now with the higher RD type first answer = r.DNSNsec( - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", [const._TYPE_SRV, const._TYPE_TXT], ) @@ -84,30 +86,30 @@ def test_parse_own_packet_nsec(self): # Types > 255 should raise an exception answer_invalid_types = r.DNSNsec( - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", [const._TYPE_TXT, const._TYPE_SRV, 1000], ) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time(answer_invalid_types, 0) - with pytest.raises(ValueError, match='rdtype 1000 is too large for NSEC'): + with pytest.raises(ValueError, match="rdtype 1000 is too large for NSEC"): generated.packets() # Empty rdtypes are not allowed answer_invalid_types = r.DNSNsec( - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", [], ) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time(answer_invalid_types, 0) - with pytest.raises(ValueError, match='NSEC must have at least one rdtype'): + with pytest.raises(ValueError, match="NSEC must have at least one rdtype"): generated.packets() def test_parse_own_packet_response(self): @@ -250,14 +252,18 @@ def test_suppress_answer(self): def test_dns_hinfo(self): generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'os')) + generated.add_additional_answer( + DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "os") + ) parsed = r.DNSIncoming(generated.packets()[0]) answer = cast(r.DNSHinfo, parsed.answers()[0]) - assert answer.cpu == 'cpu' - assert answer.os == 'os' + assert answer.cpu == "cpu" + assert answer.os == "os" generated = r.DNSOutgoing(0) - generated.add_additional_answer(DNSHinfo('irrelevant', const._TYPE_HINFO, 0, 0, 'cpu', 'x' * 257)) + generated.add_additional_answer( + DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "x" * 257) + ) self.assertRaises(r.NamePartTooLongException, generated.packets) def test_many_questions(self): @@ -265,7 +271,9 @@ def test_many_questions(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for i in range(100): - question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) + question = r.DNSQuestion( + f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN + ) generated.add_question(question) questions.append(question) assert len(generated.questions) == 100 @@ -285,7 +293,9 @@ def test_many_questions_with_many_known_answers(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for _ in range(30): - question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + question = r.DNSQuestion( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN + ) generated.add_question(question) questions.append(question) assert len(generated.questions) == 30 @@ -296,7 +306,7 @@ def test_many_questions_with_many_known_answers(self): const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - '123.local.', + "123.local.", ) generated.add_answer_at_time(known_answer, now) packets = generated.packets() @@ -324,7 +334,9 @@ def test_massive_probe_packet_split(self): questions = [] for _ in range(30): question = r.DNSQuestion( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, ) generated.add_question(question) questions.append(question) @@ -335,7 +347,7 @@ def test_massive_probe_packet_split(self): const._TYPE_PTR, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - '123.local.', + "123.local.", ) generated.add_authorative_answer(authorative_answer) packets = generated.packets() @@ -374,7 +386,7 @@ def test_only_one_answer_can_by_large(self): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, 1200, - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==' * 100, + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==" * 100, ), ) generated.add_answer( @@ -421,7 +433,9 @@ def test_questions_do_not_end_up_every_packet(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) for i in range(35): - question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) + question = r.DNSQuestion( + f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN + ) generated.add_question(question) answer = r.DNSService( f"testname{i}.local.", @@ -480,7 +494,9 @@ def test_response_header_bits(self): def test_numbers(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack( + "!4H", bytes[4:12] + ) assert num_questions == 0 assert num_answers == 0 assert num_authorities == 0 @@ -492,7 +508,9 @@ def test_numbers_questions(self): for i in range(10): generated.add_question(question) bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack('!4H', bytes[4:12]) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack( + "!4H", bytes[4:12] + ) assert num_questions == 10 assert num_answers == 0 assert num_authorities == 0 @@ -503,14 +521,14 @@ class TestDnsIncoming(unittest.TestCase): def test_incoming_exception_handling(self): generated = r.DNSOutgoing(0) packet = generated.packets()[0] - packet = packet[:8] + b'deadbeef' + packet[8:] + packet = packet[:8] + b"deadbeef" + packet[8:] parsed = r.DNSIncoming(packet) parsed = r.DNSIncoming(packet) assert parsed.valid is False def test_incoming_unknown_type(self): generated = r.DNSOutgoing(0) - answer = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'a') + answer = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"a") generated.add_additional_answer(answer) packet = generated.packets()[0] parsed = r.DNSIncoming(packet) @@ -520,20 +538,22 @@ def test_incoming_unknown_type(self): def test_incoming_circular_reference(self): assert not r.DNSIncoming( bytes.fromhex( - '01005e0000fb542a1bf0577608004500006897934000ff11d81bc0a86a31e00000fb' - '14e914e90054f9b2000084000000000100000000095f7365727669636573075f646e' - '732d7364045f756470056c6f63616c00000c0001000011940018105f73706f746966' - '792d636f6e6e656374045f746370c023' + "01005e0000fb542a1bf0577608004500006897934000ff11d81bc0a86a31e00000fb" + "14e914e90054f9b2000084000000000100000000095f7365727669636573075f646e" + "732d7364045f756470056c6f63616c00000c0001000011940018105f73706f746966" + "792d636f6e6e656374045f746370c023" ) ).valid - @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') - @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") + @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) generated = r.DNSOutgoing(0) - answer = r.DNSAddress('domain', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) + answer = r.DNSAddress( + "domain", const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed + ) generated.add_additional_answer(answer) packet = generated.packets()[0] parsed = r.DNSIncoming(packet) @@ -650,8 +670,8 @@ def test_dns_compression_rollback_for_corruption(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) @@ -695,7 +715,9 @@ def test_dns_compression_rollback_for_corruption(): assert incoming.valid is True assert ( len(incoming.answers()) - == incoming.num_answers + incoming.num_authorities + incoming.num_additionals + == incoming.num_answers + + incoming.num_authorities + + incoming.num_additionals ) @@ -712,8 +734,8 @@ def test_tc_bit_in_query_packet(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) @@ -744,8 +766,8 @@ def test_tc_bit_not_set_in_answer_packet(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) @@ -769,9 +791,7 @@ def test_tc_bit_not_set_in_answer_packet(): # 4003 15.973052 192.168.107.68 224.0.0.251 MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question def test_qm_packet_parser(): """Test we can parse a query packet with the QM bit.""" - qm_packet = ( - b'\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01' - ) + qm_packet = b"\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01" parsed = DNSIncoming(qm_packet) assert parsed.questions[0].unicast is False assert ",QM," in str(parsed.questions[0]) @@ -781,8 +801,8 @@ def test_qm_packet_parser(): def test_qu_packet_parser(): """Test we can parse a query packet with the QU bit.""" qu_packet = ( - b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local' - b'\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff' + b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x0f_companion-link\x04_tcp\x05local" + b"\x00\x00\x0c\x80\x01\x00\x00)\x05\xa0\x00\x00\x11\x94\x00\x12\x00\x04\x00\x0e\x00dz{\x8a6\x9czF\x84,\xcaQ\xff" ) parsed = DNSIncoming(qu_packet) assert parsed.questions[0].unicast is True @@ -818,8 +838,8 @@ def test_records_same_packet_share_fate(): const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - b'\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1' - b'\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==', + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", ), 0, ) @@ -834,19 +854,19 @@ def test_records_same_packet_share_fate(): def test_dns_compression_invalid_skips_bad_name_compress_in_question(): """Test our wire parser can skip bad compression in questions.""" packet = ( - b'\x00\x00\x00\x00\x00\x04\x00\x00\x00\x07\x00\x00\x11homeassistant1128\x05l' - b'ocal\x00\x00\xff\x00\x014homeassistant1128 [534a4794e5ed41879ecf012252d3e02' - b'a]\x0c_workstation\x04_tcp\xc0\x1e\x00\xff\x00\x014homeassistant1127 [534a47' - b'94e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1123 [534a479' - b'4e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1118 [534a4794' - b'e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x01\xc0\x0c\x00\x01\x80' - b'\x01\x00\x00\x00x\x00\x04\xc0\xa8<\xc3\xc0v\x00\x10\x80\x01\x00\x00\x00' - b'x\x00\x01\x00\xc0v\x00!\x80\x01\x00\x00\x00x\x00\x1f\x00\x00\x00\x00' - b'\x00\x00\x11homeassistant1127\x05local\x00\xc0\xb1\x00\x10\x80' - b'\x01\x00\x00\x00x\x00\x01\x00\xc0\xb1\x00!\x80\x01\x00\x00\x00x\x00\x1f' - b'\x00\x00\x00\x00\x00\x00\x11homeassistant1123\x05local\x00\xc0)\x00\x10\x80' - b'\x01\x00\x00\x00x\x00\x01\x00\xc0)\x00!\x80\x01\x00\x00\x00x\x00\x1f' - b'\x00\x00\x00\x00\x00\x00\x11homeassistant1128\x05local\x00' + b"\x00\x00\x00\x00\x00\x04\x00\x00\x00\x07\x00\x00\x11homeassistant1128\x05l" + b"ocal\x00\x00\xff\x00\x014homeassistant1128 [534a4794e5ed41879ecf012252d3e02" + b"a]\x0c_workstation\x04_tcp\xc0\x1e\x00\xff\x00\x014homeassistant1127 [534a47" + b"94e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1123 [534a479" + b"4e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x014homeassistant1118 [534a4794" + b"e5ed41879ecf012252d3e02a]\xc0^\x00\xff\x00\x01\xc0\x0c\x00\x01\x80" + b"\x01\x00\x00\x00x\x00\x04\xc0\xa8<\xc3\xc0v\x00\x10\x80\x01\x00\x00\x00" + b"x\x00\x01\x00\xc0v\x00!\x80\x01\x00\x00\x00x\x00\x1f\x00\x00\x00\x00" + b"\x00\x00\x11homeassistant1127\x05local\x00\xc0\xb1\x00\x10\x80" + b"\x01\x00\x00\x00x\x00\x01\x00\xc0\xb1\x00!\x80\x01\x00\x00\x00x\x00\x1f" + b"\x00\x00\x00\x00\x00\x00\x11homeassistant1123\x05local\x00\xc0)\x00\x10\x80" + b"\x01\x00\x00\x00x\x00\x01\x00\xc0)\x00!\x80\x01\x00\x00\x00x\x00\x1f" + b"\x00\x00\x00\x00\x00\x00\x11homeassistant1128\x05local\x00" ) parsed = r.DNSIncoming(packet) assert len(parsed.questions) == 4 @@ -855,8 +875,8 @@ def test_dns_compression_invalid_skips_bad_name_compress_in_question(): def test_dns_compression_all_invalid(caplog): """Test our wire parser can skip all invalid data.""" packet = ( - b'\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00!roborock-vacuum-s5e_miio416' - b'112328\x00\x00/\x80\x01\x00\x00\x00x\x00\t\xc0P\x00\x05@\x00\x00\x00\x00' + b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00!roborock-vacuum-s5e_miio416" + b"112328\x00\x00/\x80\x01\x00\x00\x00x\x00\t\xc0P\x00\x05@\x00\x00\x00\x00" ) parsed = r.DNSIncoming(packet, ("2.4.5.4", 5353)) assert len(parsed.questions) == 0 @@ -871,9 +891,9 @@ def test_invalid_next_name_ignored(): The RFC states it should be ignored when used with mDNS. """ packet = ( - b'\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x07Android\x05local\x00\x00' - b'\xff\x00\x01\xc0\x0c\x00/\x00\x01\x00\x00\x00x\x00\x08\xc02\x00\x04@' - b'\x00\x00\x08\xc0\x0c\x00\x01\x00\x01\x00\x00\x00x\x00\x04\xc0\xa8X<' + b"\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x07Android\x05local\x00\x00" + b"\xff\x00\x01\xc0\x0c\x00/\x00\x01\x00\x00\x00x\x00\x08\xc02\x00\x04@" + b"\x00\x00\x08\xc0\x0c\x00\x01\x00\x01\x00\x00\x00x\x00\x04\xc0\xa8X<" ) parsed = r.DNSIncoming(packet) assert len(parsed.questions) == 1 @@ -893,11 +913,11 @@ def test_dns_compression_invalid_skips_record(): ) parsed = r.DNSIncoming(packet) answer = r.DNSNsec( - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'eufy HomeBase2-2464._hap._tcp.local.', + "eufy HomeBase2-2464._hap._tcp.local.", [const._TYPE_TXT, const._TYPE_SRV], ) assert answer in parsed.answers() @@ -918,11 +938,11 @@ def test_dns_compression_points_forward(): ) parsed = r.DNSIncoming(packet) answer = r.DNSNsec( - 'TV Beneden (2)._androidtvremote._tcp.local.', + "TV Beneden (2)._androidtvremote._tcp.local.", const._TYPE_NSEC, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, - 'TV Beneden (2)._androidtvremote._tcp.local.', + "TV Beneden (2)._androidtvremote._tcp.local.", [const._TYPE_TXT, const._TYPE_SRV], ) assert answer in parsed.answers() @@ -942,9 +962,9 @@ def test_dns_compression_points_to_itself(): def test_dns_compression_points_beyond_packet(): """Test our wire parser does not fail when the compression pointer points beyond the packet.""" packet = ( - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01' - b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xe7\x0f\x00\x01\x80\x01\x00\x00' - b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01" + b"\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xe7\x0f\x00\x01\x80\x01\x00\x00" + b"\x00\x01\x00\x04\xc0\xa8\xd0\x06" ) parsed = r.DNSIncoming(packet) assert len(parsed.answers()) == 1 @@ -953,9 +973,9 @@ def test_dns_compression_points_beyond_packet(): def test_dns_compression_generic_failure(caplog): """Test our wire parser does not loop forever when dns compression is corrupt.""" packet = ( - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01' - b'\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00' - b'\x00\x01\x00\x04\xc0\xa8\xd0\x06' + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01" + b"\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00" + b"\x00\x01\x00\x04\xc0\xa8\xd0\x06" ) parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353)) assert len(parsed.answers()) == 1 @@ -965,17 +985,17 @@ def test_dns_compression_generic_failure(caplog): def test_label_length_attack(): """Test our wire parser does not loop forever when the name exceeds 253 chars.""" packet = ( - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d' - b'\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x00\x00\x01\x80' - b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0\x0c\x00\x01\x80\x01\x00\x00\x00' - b'\x01\x00\x04\xc0\xa8\xd0\x06' + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d" + b"\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x01d\x00\x00\x01\x80" + b"\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\xc0\x0c\x00\x01\x80\x01\x00\x00\x00" + b"\x01\x00\x04\xc0\xa8\xd0\x06" ) parsed = r.DNSIncoming(packet) assert len(parsed.answers()) == 0 @@ -984,28 +1004,28 @@ def test_label_length_attack(): def test_label_compression_attack(): """Test our wire parser does not loop forever when exceeding the maximum number of labels.""" packet = ( - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03atk\x00\x00\x01\x80' - b'\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03' - b'atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\xc0' - b'\x0c\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x06' + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03atk\x00\x00\x01\x80" + b"\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03" + b"atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\x03atk\xc0" + b"\x0c\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x06" ) parsed = r.DNSIncoming(packet) assert len(parsed.answers()) == 1 @@ -1014,15 +1034,15 @@ def test_label_compression_attack(): def test_dns_compression_loop_attack(): """Test our wire parser does not loop forever when dns compression is in a loop.""" packet = ( - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x03atk\x03dns\x05loc' - b'al\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x04a' - b'tk2\x04dns2\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05' - b'\x04atk3\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0' - b'\x05\x04atk4\x04dns5\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0' - b'\xa8\xd0\x05\x04atk5\x04dns2\xc0^\x00\x01\x80\x01\x00\x00\x00\x01\x00' - b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00' - b'\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00' - b'\x04\xc0\xa8\xd0\x05' + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x03atk\x03dns\x05loc" + b"al\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05\x04a" + b"tk2\x04dns2\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05" + b"\x04atk3\xc0\x10\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0" + b"\x05\x04atk4\x04dns5\xc0\x14\x00\x01\x80\x01\x00\x00\x00\x01\x00\x04\xc0" + b"\xa8\xd0\x05\x04atk5\x04dns2\xc0^\x00\x01\x80\x01\x00\x00\x00\x01\x00" + b"\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00" + b"\x04\xc0\xa8\xd0\x05\xc0s\x00\x01\x80\x01\x00\x00\x00\x01\x00" + b"\x04\xc0\xa8\xd0\x05" ) parsed = r.DNSIncoming(packet) assert len(parsed.answers()) == 0 @@ -1031,28 +1051,28 @@ def test_dns_compression_loop_attack(): def test_txt_after_invalid_nsec_name_still_usable(): """Test that we can see the txt record after the invalid nsec record.""" packet = ( - b'\x00\x00\x84\x00\x00\x00\x00\x06\x00\x00\x00\x00\x06_sonos\x04_tcp\x05loc' - b'al\x00\x00\x0c\x00\x01\x00\x00\x11\x94\x00\x15\x12Sonos-542A1BC9220E' - b'\xc0\x0c\x12Sonos-542A1BC9220E\xc0\x18\x00/\x80\x01\x00\x00\x00x\x00' - b'\x08\xc1t\x00\x04@\x00\x00\x08\xc0)\x00/\x80\x01\x00\x00\x11\x94\x00' - b'\t\xc0)\x00\x05\x00\x00\x80\x00@\xc0)\x00!\x80\x01\x00\x00\x00x' - b'\x00\x08\x00\x00\x00\x00\x05\xa3\xc0>\xc0>\x00\x01\x80\x01\x00\x00\x00x' - b'\x00\x04\xc0\xa8\x02:\xc0)\x00\x10\x80\x01\x00\x00\x11\x94\x01*2info=/api' - b'/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers=1.24.1\nbo' - b'otseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn9K9DLXJe0f3LP9' - b'747lbvFvh.Q45RuMaeC07rfXh7OJGm\xc0>\x00\x01\x80\x01\x00\x00\x00x" + b"\x00\x04\xc0\xa8\x02:\xc0)\x00\x10\x80\x01\x00\x00\x11\x94\x01*2info=/api" + b"/v1/players/RINCON_542A1BC9220E01400/info\x06vers=3\x10protovers=1.24.1\nbo" + b"otseq=11%hhid=Sonos_rYn9K9DLXJe0f3LP9747lbvFvh;mhhid=Sonos_rYn9K9DLXJe0f3LP9" + b"747lbvFvh.Q45RuMaeC07rfXh7OJGm None: + def update_record( + self, zc: "Zeroconf", now: float, record: r.DNSRecord + ) -> None: nonlocal updates updates.append(record) @@ -65,11 +71,11 @@ def on_service_state_change(zeroconf, service_type, state_change, name): info_service = ServiceInfo( type_, - f'{name}.{type_}', + f"{name}.{type_}", 80, 0, 0, - {'path': '/~paulsm/'}, + {"path": "/~paulsm/"}, "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) @@ -81,7 +87,15 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() assert len(updates) - assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 + assert ( + len( + [ + isinstance(update, r.DNSPointer) and update.name == type_ + for update in updates + ] + ) + >= 1 + ) zc.remove_listener(listener) # Removing a second time should not throw @@ -92,8 +106,12 @@ def on_service_state_change(zeroconf, service_type, state_change, name): def test_record_update_compat(): """Test a RecordUpdate can fetch by index.""" - new = r.DNSPointer('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 'new') - old = r.DNSPointer('irrelevant', const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, 'old') + new = r.DNSPointer( + "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "new" + ) + old = r.DNSPointer( + "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "old" + ) update = RecordUpdate(new, old) assert update[0] == new assert update[1] == old diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 2ef4b15b1..30920c6aa 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,21 +1,21 @@ -""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine - Copyright 2003 Paul Scott-Murphy, 2014 William McBrine +"""Multicast DNS Service Discovery for Python, v0.14-wmcbrine +Copyright 2003 Paul Scott-Murphy, 2014 William McBrine - This module provides a framework for the use of DNS Service Discovery - using IP multicast. +This module provides a framework for the use of DNS Service Discovery +using IP multicast. - This library is free software; you can redistribute it and/or - modify it under the terms of the GNU Lesser General Public - License as published by the Free Software Foundation; either - version 2.1 of the License, or (at your option) any later version. +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. - This library is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - Lesser General Public License for more details. +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. - You should have received a copy of the GNU Lesser General Public - License along with this library; if not, write to the Free Software - Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 - USA +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 +USA """ diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index a03855157..cf4b4e8e2 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -29,7 +29,7 @@ async def test_async_get_all_tasks() -> None: loop = aioutils.get_running_loop() assert loop is not None await aioutils._async_get_all_tasks(loop) - if not hasattr(asyncio, 'all_tasks'): + if not hasattr(asyncio, "all_tasks"): return with patch("zeroconf._utils.asyncio.asyncio.all_tasks", side_effect=RuntimeError): await aioutils._async_get_all_tasks(loop) @@ -115,7 +115,9 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): raised if something goes wrong. """ assert ( - aioutils._TASK_AWAIT_TIMEOUT + aioutils._GET_ALL_TASKS_TIMEOUT + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT + aioutils._TASK_AWAIT_TIMEOUT + + aioutils._GET_ALL_TASKS_TIMEOUT + + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT @@ -134,7 +136,9 @@ async def _saved_sleep_task(): def _run_in_loop(): aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 0.1) - with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): + with pytest.raises(EventLoopBlocked), patch.object( + aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0 + ): await loop.run_in_executor(None, _run_in_loop) assert task is not None diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 73c5ab7e2..4066eba4f 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -13,61 +13,92 @@ def test_cached_ip_addresses_wrapper(): """Test the cached_ip_addresses_wrapper.""" - assert ipaddress.cached_ip_addresses('') is None - assert ipaddress.cached_ip_addresses('foo') is None + assert ipaddress.cached_ip_addresses("") is None + assert ipaddress.cached_ip_addresses("foo") is None assert ( - str(ipaddress.cached_ip_addresses(b'&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F')) - == '2606:2800:220:1:248:1893:25c8:1946' + str( + ipaddress.cached_ip_addresses( + b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F" + ) + ) + == "2606:2800:220:1:248:1893:25c8:1946" ) - assert ipaddress.cached_ip_addresses('::1') == ipaddress.IPv6Address('::1') + assert ipaddress.cached_ip_addresses("::1") == ipaddress.IPv6Address("::1") - ipv4 = ipaddress.cached_ip_addresses('169.254.0.0') + ipv4 = ipaddress.cached_ip_addresses("169.254.0.0") assert ipv4 is not None assert ipv4.is_link_local is True assert ipv4.is_unspecified is False - ipv4 = ipaddress.cached_ip_addresses('0.0.0.0') + ipv4 = ipaddress.cached_ip_addresses("0.0.0.0") assert ipv4 is not None assert ipv4.is_link_local is False assert ipv4.is_unspecified is True - ipv6 = ipaddress.cached_ip_addresses('fe80::1') + ipv6 = ipaddress.cached_ip_addresses("fe80::1") assert ipv6 is not None assert ipv6.is_link_local is True assert ipv6.is_unspecified is False - ipv6 = ipaddress.cached_ip_addresses('0:0:0:0:0:0:0:0') + ipv6 = ipaddress.cached_ip_addresses("0:0:0:0:0:0:0:0") assert ipv6 is not None assert ipv6.is_link_local is False assert ipv6.is_unspecified is True -@pytest.mark.skipif(sys.version_info < (3, 9, 0), reason='scope_id is not supported') +@pytest.mark.skipif(sys.version_info < (3, 9, 0), reason="scope_id is not supported") def test_get_ip_address_object_from_record(): """Test the get_ip_address_object_from_record.""" # not link local - packed = b'&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F' + packed = b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F" record = DNSAddress( - 'domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=3 + "domain.local", + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 1, + packed, + scope_id=3, ) assert record.scope_id == 3 assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( - '2606:2800:220:1:248:1893:25c8:1946' + "2606:2800:220:1:248:1893:25c8:1946" ) # link local - packed = b'\xfe\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01' + packed = b"\xfe\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" record = DNSAddress( - 'domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=3 + "domain.local", + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 1, + packed, + scope_id=3, ) assert record.scope_id == 3 - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1%3') - record = DNSAddress('domain.local', const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( + "fe80::1%3" + ) + record = DNSAddress( + "domain.local", + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 1, + packed, + ) assert record.scope_id is None - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1') + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( + "fe80::1" + ) record = DNSAddress( - 'domain.local', const._TYPE_A, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed, scope_id=0 + "domain.local", + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + 1, + packed, + scope_id=0, ) assert record.scope_id == 0 # Ensure scope_id of 0 is not appended to the address - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address('fe80::1') + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( + "fe80::1" + ) diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 9604b7758..d4c57c40f 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -2,6 +2,7 @@ """Unit tests for zeroconf._utils.name.""" + import socket import pytest @@ -24,7 +25,9 @@ def test_service_type_name_overlong_full_name(): with pytest.raises(BadTypeInNameException): nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.") with pytest.raises(BadTypeInNameException): - nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.", strict=False) + nameutils.service_type_name( + f"{long_name}._tivo-videostream._tcp.local.", strict=False + ) @pytest.mark.parametrize( @@ -36,12 +39,19 @@ def test_service_type_name_overlong_full_name(): ) def test_service_type_name_non_strict_compliant_names(instance_name, service_type): """Test service_type_name for valid names, but not strict-compliant.""" - desc = {'path': '/~paulsm/'} - service_name = f'{instance_name}.{service_type}' - service_server = 'ash-1.local.' + desc = {"path": "/~paulsm/"} + service_name = f"{instance_name}.{service_type}" + service_server = "ash-1.local." service_address = socket.inet_aton("10.0.1.2") info = ServiceInfo( - service_type, service_name, 22, 0, 0, desc, service_server, addresses=[service_address] + service_type, + service_name, + 22, + 0, + 0, + desc, + service_server, + addresses=[service_address], ) assert info.get_name() == instance_name @@ -56,21 +66,25 @@ def test_service_type_name_non_strict_compliant_names(instance_name, service_typ def test_possible_types(): """Test possible types from name.""" - assert nameutils.possible_types('.') == set() - assert nameutils.possible_types('local.') == set() - assert nameutils.possible_types('_tcp.local.') == set() - assert nameutils.possible_types('_test-srvc-type._tcp.local.') == {'_test-srvc-type._tcp.local.'} - assert nameutils.possible_types('_any._tcp.local.') == {'_any._tcp.local.'} - assert nameutils.possible_types('.._x._tcp.local.') == {'_x._tcp.local.'} - assert nameutils.possible_types('x.y._http._tcp.local.') == {'_http._tcp.local.'} - assert nameutils.possible_types('1.2.3._mqtt._tcp.local.') == {'_mqtt._tcp.local.'} - assert nameutils.possible_types('x.sub._http._tcp.local.') == {'_http._tcp.local.'} - assert nameutils.possible_types('6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.') == { - '_http._tcp.local.', - '_zget._http._tcp.local.', + assert nameutils.possible_types(".") == set() + assert nameutils.possible_types("local.") == set() + assert nameutils.possible_types("_tcp.local.") == set() + assert nameutils.possible_types("_test-srvc-type._tcp.local.") == { + "_test-srvc-type._tcp.local." + } + assert nameutils.possible_types("_any._tcp.local.") == {"_any._tcp.local."} + assert nameutils.possible_types(".._x._tcp.local.") == {"_x._tcp.local."} + assert nameutils.possible_types("x.y._http._tcp.local.") == {"_http._tcp.local."} + assert nameutils.possible_types("1.2.3._mqtt._tcp.local.") == {"_mqtt._tcp.local."} + assert nameutils.possible_types("x.sub._http._tcp.local.") == {"_http._tcp.local."} + assert nameutils.possible_types( + "6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local." + ) == { + "_http._tcp.local.", + "_zget._http._tcp.local.", } - assert nameutils.possible_types('my._printer._sub._http._tcp.local.') == { - '_http._tcp.local.', - '_sub._http._tcp.local.', - '_printer._sub._http._tcp.local.', + assert nameutils.possible_types("my._printer._sub._http._tcp.local.") == { + "_http._tcp.local.", + "_sub._http._tcp.local.", + "_printer._sub._http._tcp.local.", } diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 29844d575..5a229b0d8 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -2,6 +2,7 @@ """Unit tests for zeroconf._utils.net.""" + import errno import socket import unittest @@ -37,8 +38,14 @@ def _generate_mock_adapters(): def test_ip6_to_address_and_index(): """Test we can extract from mocked adapters.""" adapters = _generate_mock_adapters() - assert netutils.ip6_to_address_and_index(adapters, "2001:db8::") == (('2001:db8::', 1, 1), 1) - assert netutils.ip6_to_address_and_index(adapters, "2001:db8::%1") == (('2001:db8::', 1, 1), 1) + assert netutils.ip6_to_address_and_index(adapters, "2001:db8::") == ( + ("2001:db8::", 1, 1), + 1, + ) + assert netutils.ip6_to_address_and_index(adapters, "2001:db8::%1") == ( + ("2001:db8::", 1, 1), + 1, + ) with pytest.raises(RuntimeError): assert netutils.ip6_to_address_and_index(adapters, "2005:db8::") @@ -46,7 +53,7 @@ def test_ip6_to_address_and_index(): def test_interface_index_to_ip6_address(): """Test we can extract from mocked adapters.""" adapters = _generate_mock_adapters() - assert netutils.interface_index_to_ip6_address(adapters, 1) == ('2001:db8::', 1, 1) + assert netutils.interface_index_to_ip6_address(adapters, 1) == ("2001:db8::", 1, 1) # call with invalid adapter with pytest.raises(RuntimeError): @@ -60,12 +67,22 @@ def test_interface_index_to_ip6_address(): def test_ip6_addresses_to_indexes(): """Test we can extract from mocked adapters.""" interfaces = [1] - with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): - assert netutils.ip6_addresses_to_indexes(interfaces) == [(('2001:db8::', 1, 1), 1)] - - interfaces_2 = ['2001:db8::'] - with patch("zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters()): - assert netutils.ip6_addresses_to_indexes(interfaces_2) == [(('2001:db8::', 1, 1), 1)] + with patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert netutils.ip6_addresses_to_indexes(interfaces) == [ + (("2001:db8::", 1, 1), 1) + ] + + interfaces_2 = ["2001:db8::"] + with patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert netutils.ip6_addresses_to_indexes(interfaces_2) == [ + (("2001:db8::", 1, 1), 1) + ] def test_normalize_interface_choice_errors(): @@ -81,12 +98,19 @@ def test_normalize_interface_choice_errors(): @pytest.mark.parametrize( "errno,expected_result", - [(errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), (errno.EINVAL, False), (0, True)], + [ + (errno.EADDRINUSE, False), + (errno.EADDRNOTAVAIL, False), + (errno.EINVAL, False), + (0, True), + ], ) def test_add_multicast_member_socket_errors(errno, expected_result): """Test we handle socket errors when adding multicast members.""" if errno: - setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, f"Error: {errno}")) + setsockopt_mock = unittest.mock.Mock( + side_effect=OSError(errno, f"Error: {errno}") + ) else: setsockopt_mock = unittest.mock.Mock() fileno_mock = unittest.mock.PropertyMock(return_value=10) @@ -118,22 +142,26 @@ def _log_error(*args): assert ( errors_logged[0][0] - == 'Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6' + == "Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6" ) -@pytest.mark.skipif(not hasattr(socket, 'SO_REUSEPORT'), reason="System does not have SO_REUSEPORT") +@pytest.mark.skipif( + not hasattr(socket, "SO_REUSEPORT"), reason="System does not have SO_REUSEPORT" +) def test_set_so_reuseport_if_available_is_present(): """Test that setting socket.SO_REUSEPORT only OSError errno.ENOPROTOOPT is trapped.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError): netutils.set_so_reuseport_if_available(sock) - with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + with patch( + "socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None) + ): netutils.set_so_reuseport_if_available(sock) -@pytest.mark.skipif(hasattr(socket, 'SO_REUSEPORT'), reason="System has SO_REUSEPORT") +@pytest.mark.skipif(hasattr(socket, "SO_REUSEPORT"), reason="System has SO_REUSEPORT") def test_set_so_reuseport_if_available_not_present(): """Test that we do not try to set SO_REUSEPORT if it is not present.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -146,24 +174,36 @@ def test_set_mdns_port_socket_options_for_ip_version(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Should raise on EPERM always - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ('',), r.IPVersion.V4Only) + with pytest.raises(OSError), patch( + "socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None) + ): + netutils.set_mdns_port_socket_options_for_ip_version( + sock, ("",), r.IPVersion.V4Only + ) # Should raise on EINVAL always when bind address is not '' - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ('127.0.0.1',), r.IPVersion.V4Only) + with pytest.raises(OSError), patch( + "socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None) + ): + netutils.set_mdns_port_socket_options_for_ip_version( + sock, ("127.0.0.1",), r.IPVersion.V4Only + ) # Should not raise on EINVAL when bind address is '' with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ('',), r.IPVersion.V4Only) + netutils.set_mdns_port_socket_options_for_ip_version( + sock, ("",), r.IPVersion.V4Only + ) def test_add_multicast_member(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - interface = '127.0.0.1' + interface = "127.0.0.1" # EPERM should always raise - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): + with pytest.raises(OSError), patch( + "socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None) + ): netutils.add_multicast_member(sock, interface) # EADDRINUSE should return False @@ -171,7 +211,9 @@ def test_add_multicast_member(): assert netutils.add_multicast_member(sock, interface) is False # EADDRNOTAVAIL should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None)): + with patch( + "socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None) + ): assert netutils.add_multicast_member(sock, interface) is False # EINVAL should return False @@ -179,20 +221,24 @@ def test_add_multicast_member(): assert netutils.add_multicast_member(sock, interface) is False # ENOPROTOOPT should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + with patch( + "socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None) + ): assert netutils.add_multicast_member(sock, interface) is False # ENODEV should raise for ipv4 - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): + with pytest.raises(OSError), patch( + "socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None) + ): netutils.add_multicast_member(sock, interface) is False # ENODEV should return False for ipv6 with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): - assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False # type: ignore[arg-type] + assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] # No IPv6 support should return False for IPv6 with patch("socket.inet_pton", side_effect=OSError()): - assert netutils.add_multicast_member(sock, ('2001:db8::', 1, 1)) is False # type: ignore[arg-type] + assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] # No error should return True with patch("socket.socket.setsockopt"): From 596edb2432b15ffbb5b90b724b6699c400a2a7d3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 17:37:16 -0500 Subject: [PATCH 1085/1433] chore(pre-commit.ci): pre-commit autoupdate (#1381) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 10 +++++----- src/zeroconf/__init__.py | 11 ----------- src/zeroconf/_engine.py | 2 +- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4a882037..8fa230dea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,12 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v2.32.4 + rev: v3.27.0 hooks: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.6.0 hooks: - id: debug-statements - id: check-builtin-literals @@ -29,12 +29,12 @@ repos: - id: trailing-whitespace - id: debug-statements - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v4.0.0-alpha.8 hooks: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/asottile/pyupgrade - rev: v2.37.3 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py37-plus] @@ -53,7 +53,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.931 + rev: v1.10.1 hooks: - id: mypy additional_dependencies: [] diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0c89a8816..f3130307f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -20,8 +20,6 @@ USA """ -import sys - from ._cache import DNSCache # noqa # import needed for backwards compat from ._core import Zeroconf from ._dns import ( # noqa # import needed for backwards compat @@ -114,12 +112,3 @@ "NotRunningException", "ServiceNameAlreadyRegistered", ] - -if sys.version_info <= (3, 6): # pragma: no cover - raise ImportError( # pragma: no cover - """ -Python version > 3.6 required for python-zeroconf. -If you need support for Python 2 or Python 3.3-3.4 please use version 19.1 -If you need support for Python 3.5 please use version 0.28.0 - """ - ) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 6083c19a3..afe22f598 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -105,7 +105,7 @@ async def _async_create_endpoints(self) -> None: sender_sockets.append(s) for s in reader_sockets: - transport, protocol = await loop.create_datagram_endpoint( + transport, protocol = await loop.create_datagram_endpoint( # type: ignore[type-var] lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value] sock=s, ) From 144449223cc8b68a388376a2386b6bad02c647a7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:24:07 -1000 Subject: [PATCH 1086/1433] chore(pre-commit.ci): pre-commit autoupdate (#1383) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8fa230dea..1e37e5a00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v3.27.0 + rev: v3.29.0 hooks: - id: commitizen stages: [commit-msg] @@ -34,12 +34,12 @@ repos: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 + rev: v3.17.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.6.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -49,11 +49,11 @@ repos: # hooks: # - id: codespell - repo: https://github.com/PyCQA/flake8 - rev: 7.1.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.2 hooks: - id: mypy additional_dependencies: [] From bddbe9e594483c23c2e6277c36f3156b54a9fa94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 14:37:08 -1000 Subject: [PATCH 1087/1433] chore: create dependabot.yml (#1391) --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..9d866e392 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From 98cfa83710e43880698353821bae61108b08cb2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 14:40:27 -1000 Subject: [PATCH 1088/1433] feat: python 3.13 support (#1390) --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad892f24..56010b578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v5 with: python-version: "3.9" - uses: pre-commit/action@v2.0.3 @@ -41,6 +41,7 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" - "pypy-3.8" - "pypy-3.9" os: @@ -69,10 +70,11 @@ jobs: - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" + allow-prereleases: true - name: Install Dependencies no cython if: ${{ matrix.extension == 'skip_cython' }} env: @@ -136,7 +138,7 @@ jobs: # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 - name: Install python-semantic-release run: pipx install python-semantic-release==7.34.6 @@ -159,7 +161,7 @@ jobs: platforms: arm64 - name: Build wheels - uses: pypa/cibuildwheel@v2.17.0 + uses: pypa/cibuildwheel@v2.20.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 From 7fb2bb21421c70db0eb288fa7e73d955f58b0f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 14:46:34 -1000 Subject: [PATCH 1089/1433] feat: add classifier for python 3.13 (#1393) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1d88efbde..7518f3a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers=[ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] From 8d8b9ca395fed1deae5f115442a41fa1454ad3e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:10:45 -1000 Subject: [PATCH 1090/1433] chore(deps-dev): bump pytest from 7.4.4 to 8.3.2 (#1394) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 24 ++++++++++++------------ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index a9a7c6c2b..f3dd4dfab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "async-timeout" @@ -205,13 +205,13 @@ files = [ [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -220,13 +220,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "7.4.4" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -234,11 +234,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -320,4 +320,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "26c7f2ec91a34a0661a5511d2ade43511d80dd4f89e1aefbb59c9fafc2c92df2" +content-hash = "80115c5f3c7fd52ab1466c37903845b099ffc803b6ddc6329b612af96ee1d421" diff --git a/pyproject.toml b/pyproject.toml index 7518f3a36..a8949be85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ async-timeout = {version = ">=3.0.0", python = "<3.11"} ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] -pytest = "^7.2.0" +pytest = ">=7.2,<9.0" pytest-cov = "^4.0.0" pytest-asyncio = "^0.20.3" cython = "^3.0.5" From 764bdabe76099a7dbb206433310f04c55234f299 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:11:34 -1000 Subject: [PATCH 1091/1433] chore(deps-dev): bump coverage from 7.4.1 to 7.6.1 (#1396) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 126 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/poetry.lock b/poetry.lock index f3dd4dfab..b1fe410e3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,63 +24,83 @@ files = [ [[package]] name = "coverage" -version = "7.4.1" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, - {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, - {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, - {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, - {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, - {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, - {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, - {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, - {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, - {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, - {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, - {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, - {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, - {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, - {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, - {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, - {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, - {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, - {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, - {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, - {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, - {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, - {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, - {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, - {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, - {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, - {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, - {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, - {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, - {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, - {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, - {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.dependencies] From 0df2ce0e6f7313831da6a63d477019982d5df55c Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu <93059748+devbanu@users.noreply.github.com> Date: Mon, 26 Aug 2024 21:16:18 -0400 Subject: [PATCH 1092/1433] feat: enable building of arm64 macOS builds (#1384) Co-authored-by: Alex Ciobanu Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56010b578..f96694242 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04, windows-2019, macOS-11] + os: [ubuntu-20.04, windows-2019, macos-12, macos-latest] steps: - uses: actions/checkout@v3 @@ -164,8 +164,8 @@ jobs: uses: pypa/cibuildwheel@v2.20.0 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 - CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 cp38-*_arm64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 + CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 From 5145617db95e214d33d5c5e68d27ff011df3d38e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:29:02 -1000 Subject: [PATCH 1093/1433] chore(deps-dev): bump pytest-asyncio from 0.20.3 to 0.24.0 (#1400) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 14 +++++++------- pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index b1fe410e3..af80c2260 100644 --- a/poetry.lock +++ b/poetry.lock @@ -262,21 +262,21 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.20.3" +version = "0.24.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, - {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, ] [package.dependencies] -pytest = ">=6.1.0" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" @@ -340,4 +340,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "80115c5f3c7fd52ab1466c37903845b099ffc803b6ddc6329b612af96ee1d421" +content-hash = "259e5ec479b559f3c02fdb7224f17b4979b66419c1f82b273d837ecd75b743ac" diff --git a/pyproject.toml b/pyproject.toml index a8949be85..a5e84a158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = "^4.0.0" -pytest-asyncio = "^0.20.3" +pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" setuptools = "^65.6.3" pytest-timeout = "^2.1.0" From d399a4ede0fd68214dc05eccae392e06ef49bd2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 15:33:23 -1000 Subject: [PATCH 1094/1433] chore: fix wheel builds with newer python (#1401) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f96694242..217cc11cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04, windows-2019, macos-12, macos-latest] + os: [ubuntu-latest, windows-2019, macos-12, macos-latest] steps: - uses: actions/checkout@v3 From a43753f9249c78564ea23e77103cd74e9522e305 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 15:36:34 -1000 Subject: [PATCH 1095/1433] chore: enable and fix additional ruff rules (#1399) --- bench/create_destory.py | 4 +- bench/incoming.py | 2 +- bench/txt_properties.py | 2 +- build_ext.py | 17 +- examples/async_apple_scanner.py | 20 +- examples/async_browser.py | 23 +- examples/async_service_info_request.py | 5 +- examples/browser.py | 9 +- examples/self_test.py | 9 +- pyproject.toml | 24 +- src/zeroconf/_cache.py | 29 +-- src/zeroconf/_core.py | 58 ++--- src/zeroconf/_dns.py | 34 +-- src/zeroconf/_engine.py | 17 +- src/zeroconf/_handlers/answers.py | 4 +- .../_handlers/multicast_outgoing_queue.py | 12 +- src/zeroconf/_handlers/query_handler.py | 56 ++--- src/zeroconf/_handlers/record_manager.py | 10 +- src/zeroconf/_history.py | 8 +- src/zeroconf/_listener.py | 21 +- src/zeroconf/_logger.py | 4 +- src/zeroconf/_protocol/incoming.py | 31 +-- src/zeroconf/_protocol/outgoing.py | 28 +-- src/zeroconf/_services/__init__.py | 8 +- src/zeroconf/_services/browser.py | 116 +++------ src/zeroconf/_services/info.py | 88 ++----- src/zeroconf/_services/registry.py | 4 +- src/zeroconf/_services/types.py | 4 +- src/zeroconf/_updates.py | 8 +- src/zeroconf/_utils/asyncio.py | 18 +- src/zeroconf/_utils/ipaddress.py | 8 +- src/zeroconf/_utils/name.py | 44 ++-- src/zeroconf/_utils/net.py | 89 ++----- src/zeroconf/asyncio.py | 23 +- src/zeroconf/const.py | 4 +- tests/__init__.py | 4 +- tests/services/test_browser.py | 199 ++++------------ tests/services/test_info.py | 220 ++++-------------- tests/services/test_types.py | 4 +- tests/test_asyncio.py | 50 +--- tests/test_cache.py | 5 +- tests/test_core.py | 71 ++---- tests/test_dns.py | 56 ++--- tests/test_engine.py | 36 +-- tests/test_exceptions.py | 13 +- tests/test_handlers.py | 213 +++++------------ tests/test_init.py | 4 +- tests/test_listener.py | 6 +- tests/test_protocol.py | 52 ++--- tests/test_services.py | 31 ++- tests/test_updates.py | 26 +-- tests/utils/test_asyncio.py | 8 +- tests/utils/test_ipaddress.py | 18 +- tests/utils/test_name.py | 12 +- tests/utils/test_net.py | 62 ++--- 55 files changed, 520 insertions(+), 1411 deletions(-) diff --git a/bench/create_destory.py b/bench/create_destory.py index 77d8af6f0..6fde9ebe3 100644 --- a/bench/create_destory.py +++ b/bench/create_destory.py @@ -18,9 +18,7 @@ async def _run() -> None: start = time.perf_counter() await _create_destroy(iterations) duration = time.perf_counter() - start - print( - f"Creating and destroying {iterations} Zeroconf instances took {duration} seconds" - ) + print(f"Creating and destroying {iterations} Zeroconf instances took {duration} seconds") asyncio.run(_run()) diff --git a/bench/incoming.py b/bench/incoming.py index d0cc3588e..3edcfec21 100644 --- a/bench/incoming.py +++ b/bench/incoming.py @@ -178,7 +178,7 @@ def generate_packets() -> List[bytes]: def parse_incoming_message() -> None: for packet in packets: - DNSIncoming(packet).answers + DNSIncoming(packet).answers # noqa: B018 break diff --git a/bench/txt_properties.py b/bench/txt_properties.py index 792d5312d..f9adeccf1 100644 --- a/bench/txt_properties.py +++ b/bench/txt_properties.py @@ -14,7 +14,7 @@ def process_properties() -> None: info._properties = None - info.properties + info.properties # noqa: B018 count = 100000 diff --git a/build_ext.py b/build_ext.py index 4fecbdf11..26b4eb96f 100644 --- a/build_ext.py +++ b/build_ext.py @@ -1,16 +1,19 @@ """Build optional cython modules.""" +import logging import os from distutils.command.build_ext import build_ext from typing import Any +_LOGGER = logging.getLogger(__name__) + class BuildExt(build_ext): def build_extensions(self) -> None: try: super().build_extensions() except Exception: - pass + _LOGGER.info("Failed to build cython extensions") def build(setup_kwargs: Any) -> None: @@ -20,8 +23,8 @@ def build(setup_kwargs: Any) -> None: from Cython.Build import cythonize setup_kwargs.update( - dict( - ext_modules=cythonize( + { + "ext_modules": cythonize( [ "src/zeroconf/_dns.py", "src/zeroconf/_cache.py", @@ -44,12 +47,10 @@ def build(setup_kwargs: Any) -> None: ], compiler_directives={"language_level": "3"}, # Python 3 ), - cmdclass=dict(build_ext=BuildExt), - ) + "cmdclass": {"build_ext": BuildExt}, + } ) - setup_kwargs["exclude_package_data"] = { - pkg: ["*.c"] for pkg in setup_kwargs["packages"] - } + setup_kwargs["exclude_package_data"] = {pkg: ["*.c"] for pkg in setup_kwargs["packages"]} except Exception: if os.environ.get("REQUIRE_CYTHON"): raise diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index ed549e017..29eb5f70f 100644 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -32,6 +32,8 @@ log = logging.getLogger(__name__) +_PENDING_TASKS: set[asyncio.Task] = set() + def async_on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange @@ -41,23 +43,21 @@ def async_on_service_state_change( return base_name = name[: -len(service_type) - 1] device_name = f"{base_name}.{DEVICE_INFO_SERVICE}" - asyncio.ensure_future(_async_show_service_info(zeroconf, service_type, name)) + task = asyncio.ensure_future(_async_show_service_info(zeroconf, service_type, name)) + _PENDING_TASKS.add(task) + task.add_done_callback(_PENDING_TASKS.discard) # Also probe for device info - asyncio.ensure_future( - _async_show_service_info(zeroconf, DEVICE_INFO_SERVICE, device_name) - ) + task = asyncio.ensure_future(_async_show_service_info(zeroconf, DEVICE_INFO_SERVICE, device_name)) + _PENDING_TASKS.add(task) + task.add_done_callback(_PENDING_TASKS.discard) -async def _async_show_service_info( - zeroconf: Zeroconf, service_type: str, name: str -) -> None: +async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = [ - "%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses() - ] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) diff --git a/examples/async_browser.py b/examples/async_browser.py index cd4c77860..bc5f252ee 100644 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -18,6 +18,8 @@ AsyncZeroconfServiceTypes, ) +_PENDING_TASKS: set[asyncio.Task] = set() + def async_on_service_state_change( zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange @@ -25,20 +27,17 @@ def async_on_service_state_change( print(f"Service {name} of type {service_type} state changed: {state_change}") if state_change is not ServiceStateChange.Added: return - asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) + task = asyncio.ensure_future(async_display_service_info(zeroconf, service_type, name)) + _PENDING_TASKS.add(task) + task.add_done_callback(_PENDING_TASKS.discard) -async def async_display_service_info( - zeroconf: Zeroconf, service_type: str, name: str -) -> None: +async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000) print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = [ - "%s:%d" % (addr, cast(int, info.port)) - for addr in info.parsed_scoped_addresses() - ] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Name: %s" % name) print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) @@ -66,9 +65,7 @@ async def async_run(self) -> None: services = ["_http._tcp.local.", "_hap._tcp.local."] if self.args.find: services = list( - await AsyncZeroconfServiceTypes.async_find( - aiozc=self.aiozc, ip_version=ip_version - ) + await AsyncZeroconfServiceTypes.async_find(aiozc=self.aiozc, ip_version=ip_version) ) print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) @@ -90,9 +87,7 @@ async def async_close(self) -> None: parser = argparse.ArgumentParser() parser.add_argument("--debug", action="store_true") - parser.add_argument( - "--find", action="store_true", help="Browse all available services" - ) + parser.add_argument("--find", action="store_true", help="Browse all available services") version_group = parser.add_mutually_exclusive_group() version_group.add_argument("--v6", action="store_true") version_group.add_argument("--v6-only", action="store_true") diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index fca58745e..318647566 100644 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -31,10 +31,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: for info in infos: print("Info for %s" % (info.name)) if info: - addresses = [ - "%s:%d" % (addr, cast(int, info.port)) - for addr in info.parsed_addresses() - ] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(f" Server: {info.server}") diff --git a/examples/browser.py b/examples/browser.py index 1a801a445..aebf3f5d4 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -29,10 +29,7 @@ def on_service_state_change( print("Info from zeroconf.get_service_info: %r" % (info)) if info: - addresses = [ - "%s:%d" % (addr, cast(int, info.port)) - for addr in info.parsed_scoped_addresses() - ] + addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] print(" Addresses: %s" % ", ".join(addresses)) print(" Weight: %d, priority: %d" % (info.weight, info.priority)) print(f" Server: {info.server}") @@ -52,9 +49,7 @@ def on_service_state_change( parser = argparse.ArgumentParser() parser.add_argument("--debug", action="store_true") - parser.add_argument( - "--find", action="store_true", help="Browse all available services" - ) + parser.add_argument("--find", action="store_true", help="Browse all available services") version_group = parser.add_mutually_exclusive_group() version_group.add_argument("--v6-only", action="store_true") version_group.add_argument("--v4-only", action="store_true") diff --git a/examples/self_test.py b/examples/self_test.py index 63aca4f35..35f83b062 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -34,15 +34,10 @@ r.register_service(info) print(" Registration done.") print("2. Testing query of service information...") - print( - " Getting ZOE service: %s" - % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local.")) - ) + print(" Getting ZOE service: %s" % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local."))) print(" Query done.") print("3. Testing query of own service...") - queried_info = r.get_service_info( - "_http._tcp.local.", "My Service Name._http._tcp.local." - ) + queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") assert queried_info assert set(queried_info.parsed_addresses()) == expected print(f" Getting self: {queried_info}") diff --git a/pyproject.toml b/pyproject.toml index a5e84a158..bb53a1d38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,10 +63,28 @@ cython = "^3.0.5" setuptools = "^65.6.3" pytest-timeout = "^2.1.0" -[tool.black] +[tool.ruff] +target-version = "py38" line-length = 110 -target_version = ['py37', 'py38', 'py39', 'py310', 'py311'] -skip_string_normalization = true + +[tool.ruff.lint] +ignore = [ + "S101", # use of assert + "S104", # S104 Possible binding to all interfaces + "UP031", # UP031 use f-strings -- too many to fix right now +] +select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "S", # flake8-bandit + "F", # pyflake + "E", # pycodestyle + "W", # pycodestyle + "UP", # pyupgrade + "I", # isort + "RUF", # ruff specific +] + [tool.pylint.BASIC] class-const-naming-style = "any" diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 809be9c1b..7db151171 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -119,12 +119,7 @@ def async_expire(self, now: _float) -> List[DNSRecord]: This function must be run in from event loop. """ - expired = [ - record - for records in self.cache.values() - for record in records - if record.is_expired(now) - ] + expired = [record for records in self.cache.values() for record in records if record.is_expired(now)] self.async_remove_records(expired) return expired @@ -140,9 +135,7 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details( - self, name: _str, type_: _int, class_: _int - ) -> List[DNSRecord]: + def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: """Gets all matching entries by details. This function is not thread-safe and must be called from @@ -188,9 +181,7 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: return cached_entry return None - def get_by_details( - self, name: str, type_: _int, class_: _int - ) -> Optional[DNSRecord]: + def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRecord]: """Gets the first matching entry by details. Returns None if no entries match. Calling this function is not recommended as it will only @@ -211,19 +202,13 @@ def get_by_details( return cached_entry return None - def get_all_by_details( - self, name: str, type_: _int, class_: _int - ) -> List[DNSRecord]: + def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRecord]: """Gets all matching entries by details.""" key = name.lower() records = self.cache.get(key) if records is None: return [] - return [ - entry - for entry in list(records) - if type_ == entry.type and class_ == entry.class_ - ] + return [entry for entry in list(records) if type_ == entry.type and class_ == entry.class_] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" @@ -233,9 +218,7 @@ def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" return list(self.cache.get(name.lower(), [])) - def current_entry_with_name_and_alias( - self, name: str, alias: str - ) -> Optional[DNSRecord]: + def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() for record in reversed(self.entries_with_name(name)): if ( diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 5386df634..b3ecd8519 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -173,17 +173,11 @@ def __init__( self.done = False if apple_p2p and sys.platform != "darwin": - raise RuntimeError( - "Option `apple_p2p` is not supported on non-Apple platforms." - ) + raise RuntimeError("Option `apple_p2p` is not supported on non-Apple platforms.") self.unicast = unicast - listen_socket, respond_sockets = create_sockets( - interfaces, unicast, ip_version, apple_p2p=apple_p2p - ) - log.debug( - "Listen socket %s, respond sockets %s", listen_socket, respond_sockets - ) + listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) + log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets) self.engine = AsyncEngine(self, listen_socket, respond_sockets) @@ -193,9 +187,7 @@ def __init__( self.question_history = QuestionHistory() self.out_queue = MulticastOutgoingQueue(self, 0, _AGGREGATION_DELAY) - self.out_delay_queue = MulticastOutgoingQueue( - self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY - ) + self.out_delay_queue = MulticastOutgoingQueue(self, _ONE_SECOND, _PROTECTED_AGGREGATION_DELAY) self.query_handler = QueryHandler(self) self.record_manager = RecordManager(self) @@ -209,11 +201,7 @@ def __init__( @property def started(self) -> bool: """Check if the instance has started.""" - return bool( - not self.done - and self.engine.running_event - and self.engine.running_event.is_set() - ) + return bool(not self.done and self.engine.running_event and self.engine.running_event.is_set()) def start(self) -> None: """Start Zeroconf.""" @@ -332,9 +320,7 @@ def register_service( assert self.loop is not None run_coro_with_timeout( await_awaitable( - self.async_register_service( - info, ttl, allow_name_change, cooperating_responders, strict - ) + self.async_register_service(info, ttl, allow_name_change, cooperating_responders, strict) ), self.loop, _REGISTER_TIME * _REGISTER_BROADCASTS, @@ -362,13 +348,9 @@ async def async_register_service( info.set_server_if_missing() await self.async_wait_for_start() - await self.async_check_service( - info, allow_name_change, cooperating_responders, strict - ) + await self.async_check_service(info, allow_name_change, cooperating_responders, strict) self.registry.async_add(info) - return asyncio.ensure_future( - self._async_broadcast_service(info, _REGISTER_TIME, None) - ) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) def update_service(self, info: ServiceInfo) -> None: """Registers service information to the network with a default TTL. @@ -391,9 +373,7 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: Zeroconf will then respond to requests for information for that service.""" self.registry.async_update(info) - return asyncio.ensure_future( - self._async_broadcast_service(info, _REGISTER_TIME, None) - ) + return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) async def async_get_service_info( self, @@ -427,9 +407,7 @@ async def _async_broadcast_service( for i in range(_REGISTER_BROADCASTS): if i != 0: await asyncio.sleep(millis_to_seconds(interval)) - self.async_send( - self.generate_service_broadcast(info, ttl, broadcast_addresses) - ) + self.async_send(self.generate_service_broadcast(info, ttl, broadcast_addresses)) def generate_service_broadcast( self, @@ -500,9 +478,7 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: entries = self.registry.async_get_infos_server(info.server_key) broadcast_addresses = not bool(entries) return asyncio.ensure_future( - self._async_broadcast_service( - info, _UNREGISTER_TIME, 0, broadcast_addresses - ) + self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) ) def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: @@ -595,9 +571,7 @@ def add_listener( This function is threadsafe """ assert self.loop is not None - self.loop.call_soon_threadsafe( - self.record_manager.async_add_listener, listener, question - ) + self.loop.call_soon_threadsafe(self.record_manager.async_add_listener, listener, question) def remove_listener(self, listener: RecordUpdateListener) -> None: """Removes a listener. @@ -605,9 +579,7 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: This function is threadsafe """ assert self.loop is not None - self.loop.call_soon_threadsafe( - self.record_manager.async_remove_listener, listener - ) + self.loop.call_soon_threadsafe(self.record_manager.async_remove_listener, listener) def async_add_listener( self, @@ -639,9 +611,7 @@ def send( ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None - self.loop.call_soon_threadsafe( - self.async_send, out, addr, port, v6_flow_scope, transport - ) + self.loop.call_soon_threadsafe(self.async_send, out, addr, port, v6_flow_scope, transport) def async_send( self, diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index f85969a91..15daa709b 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -33,9 +33,7 @@ _LEN_SHORT = 2 _LEN_INT = 4 -_BASE_MAX_SIZE = ( - _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT -) # type # class # ttl # length +_BASE_MAX_SIZE = _LEN_SHORT + _LEN_SHORT + _LEN_INT + _LEN_SHORT # type # class # ttl # length _NAME_COMPRESSION_MIN_SIZE = _LEN_BYTE * 2 _EXPIRE_FULL_TIME_MS = 1000 @@ -79,11 +77,7 @@ def _set_class(self, class_: _int) -> None: self.unique = (class_ & _CLASS_UNIQUE) != 0 def _dns_entry_matches(self, other) -> bool: # type: ignore[no-untyped-def] - return ( - self.key == other.key - and self.type == other.type - and self.class_ == other.class_ - ) + return self.key == other.key and self.type == other.type and self.class_ == other.class_ def __eq__(self, other: Any) -> bool: """Equality test on key (lowercase name), type, and class""" @@ -122,11 +116,7 @@ def __init__(self, name: str, type_: int, class_: int) -> None: def answered_by(self, rec: "DNSRecord") -> bool: """Returns true if the question is answered by the record""" - return ( - self.class_ == rec.class_ - and self.type in (rec.type, _TYPE_ANY) - and self.name == rec.name - ) + return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name def __hash__(self) -> int: return self._hash @@ -138,9 +128,7 @@ def __eq__(self, other: Any) -> bool: @property def max_size(self) -> int: """Maximum size of the question in the packet.""" - return ( - len(self.name.encode("utf-8")) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT - ) # type # class + return len(self.name.encode("utf-8")) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT # type # class @property def unicast(self) -> bool: @@ -328,11 +316,7 @@ def __eq__(self, other: Any) -> bool: def _eq(self, other) -> bool: # type: ignore[no-untyped-def] """Tests equality on cpu and os.""" - return ( - self.cpu == other.cpu - and self.os == other.os - and self._dns_entry_matches(other) - ) + return self.cpu == other.cpu and self.os == other.os and self._dns_entry_matches(other) def __hash__(self) -> int: """Hash to compare like DNSHinfo.""" @@ -457,9 +441,7 @@ def __init__( self.port = port self.server = server self.server_key = server.lower() - self._hash = hash( - (self.key, type_, self.class_, priority, weight, port, self.server_key) - ) + self._hash = hash((self.key, type_, self.class_, priority, weight, port, self.server_key)) def write(self, out: "DNSOutgoing") -> None: """Used in constructing an outgoing packet""" @@ -550,9 +532,7 @@ def __hash__(self) -> int: def __repr__(self) -> str: """String representation""" return self.to_string( - self.next_name - + "," - + "|".join([self.get_type(type_) for type_ in self.rdtypes]) + self.next_name + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes]) ) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index afe22f598..e807d9eff 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -110,13 +110,9 @@ async def _async_create_endpoints(self) -> None: sock=s, ) self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append( - make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) - ) + self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) if s in sender_sockets: - self.senders.append( - make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) - ) + self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" @@ -124,10 +120,7 @@ def _async_cache_cleanup(self) -> None: self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates( now, - [ - RecordUpdate(record, record) - for record in self.zc.cache.async_expire(now) - ], + [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)], ) self.zc.record_manager.async_updates_complete(False) self._async_schedule_next_cache_cleanup() @@ -136,9 +129,7 @@ def _async_schedule_next_cache_cleanup(self) -> None: """Schedule the next cache cleanup.""" loop = self.loop assert loop is not None - self._cleanup_timer = loop.call_at( - loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup - ) + self._cleanup_timer = loop.call_at(loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index 74efee2c2..bab2d7490 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -109,9 +109,7 @@ def construct_outgoing_unicast_answers( return out -def _add_answers_additionals( - out: DNSOutgoing, answers: _AnswerWithAdditionalsType -) -> None: +def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: # Find additionals and suppress any additionals that are already in answers sending: Set[DNSRecord] = set(answers) # Answers are sorted to group names together to increase the chance diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index 492425403..afcefc017 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -53,9 +53,7 @@ class MulticastOutgoingQueue: "_aggregation_delay", ) - def __init__( - self, zeroconf: "Zeroconf", additional_delay: _int, max_aggregation_delay: _int - ) -> None: + def __init__(self, zeroconf: "Zeroconf", additional_delay: _int, max_aggregation_delay: _int) -> None: self.zc = zeroconf self.queue: deque[AnswerGroup] = deque() # Additional delay is used to implement @@ -71,9 +69,7 @@ def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: loop = self.zc.loop if TYPE_CHECKING: assert loop is not None - random_int = RAND_INT( - self._multicast_delay_random_min, self._multicast_delay_random_max - ) + random_int = RAND_INT(self._multicast_delay_random_min, self._multicast_delay_random_max) random_delay = random_int + self._additional_delay send_after = now + random_delay send_before = now + self._aggregation_delay + self._additional_delay @@ -87,9 +83,7 @@ def async_add(self, now: _float, answers: _AnswerWithAdditionalsType) -> None: last_group.answers.update(answers) return else: - loop.call_at( - loop.time() + millis_to_seconds(random_delay), self.async_ready - ) + loop.call_at(loop.time() + millis_to_seconds(random_delay), self.async_ready) self.queue.append(AnswerGroup(send_after, send_before, answers)) def _remove_answers_from_queue(self, answers: _AnswerWithAdditionalsType) -> None: diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index a2f5e9f52..f2e112363 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -102,9 +102,7 @@ class _QueryResponse: "_mcast_aggregate_last_second", ) - def __init__( - self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float - ) -> None: + def __init__(self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float) -> None: """Build a query response.""" self._is_probe = is_probe self._questions = questions @@ -159,12 +157,8 @@ def answers( ucast = {r: self._additionals[r] for r in self._ucast} mcast_now = {r: self._additionals[r] for r in self._mcast_now} mcast_aggregate = {r: self._additionals[r] for r in self._mcast_aggregate} - mcast_aggregate_last_second = { - r: self._additionals[r] for r in self._mcast_aggregate_last_second - } - return QuestionAnswers( - ucast, mcast_now, mcast_aggregate, mcast_aggregate_last_second - ) + mcast_aggregate_last_second = {r: self._additionals[r] for r in self._mcast_aggregate_last_second} + return QuestionAnswers(ucast, mcast_now, mcast_aggregate, mcast_aggregate_last_second) def _has_mcast_within_one_quarter_ttl(self, record: DNSRecord) -> bool: """Check to see if a record has been mcasted recently. @@ -190,9 +184,7 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: if TYPE_CHECKING: record = cast(_UniqueRecordsType, record) maybe_entry = self._cache.async_get_unique(record) - return bool( - maybe_entry is not None and self._now - maybe_entry.created < _ONE_SECOND - ) + return bool(maybe_entry is not None and self._now - maybe_entry.created < _ONE_SECOND) class QueryHandler: @@ -278,16 +270,12 @@ def _add_address_answers( missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: if missing_types: - assert ( - service.server is not None - ), "Service server must be set for NSEC record." + assert service.server is not None, "Service server must be set for NSEC record." additionals.add(service._dns_nsec(list(missing_types), None)) for answer in answers: answer_set[answer] = additionals elif type_ in missing_types: - assert ( - service.server is not None - ), "Service server must be set for NSEC record." + assert service.server is not None, "Service server must be set for NSEC record." answer_set[service._dns_nsec(list(missing_types), None)] = set() def _answer_question( @@ -302,15 +290,11 @@ def _answer_question( answer_set: _AnswerWithAdditionalsType = {} if strategy_type == _ANSWER_STRATEGY_SERVICE_TYPE_ENUMERATION: - self._add_service_type_enumeration_query_answers( - types, answer_set, known_answers - ) + self._add_service_type_enumeration_query_answers(types, answer_set, known_answers) elif strategy_type == _ANSWER_STRATEGY_POINTER: self._add_pointer_answers(services, answer_set, known_answers) elif strategy_type == _ANSWER_STRATEGY_ADDRESS: - self._add_address_answers( - services, answer_set, known_answers, question.type - ) + self._add_address_answers(services, answer_set, known_answers, question.type) elif strategy_type == _ANSWER_STRATEGY_SERVICE: # Add recommended additional answers according to # https://tools.ietf.org/html/rfc6763#section-12.2. @@ -367,9 +351,7 @@ def async_response( # pylint: disable=unused-argument if not is_unicast: if known_answers_set is None: # pragma: no branch known_answers_set = known_answers.lookup_set() - self.question_history.add_question_at_time( - question, now, known_answers_set - ) + self.question_history.add_question_at_time(question, now, known_answers_set) answer_set = self._answer_question( question, strategy.strategy_type, @@ -415,18 +397,14 @@ def _get_answer_strategies( services = self.registry.async_get_infos_type(question_lower_name) if services: strategies.append( - _AnswerStrategy( - question, _ANSWER_STRATEGY_POINTER, _EMPTY_TYPES_LIST, services - ) + _AnswerStrategy(question, _ANSWER_STRATEGY_POINTER, _EMPTY_TYPES_LIST, services) ) if type_ in (_TYPE_A, _TYPE_AAAA, _TYPE_ANY): services = self.registry.async_get_infos_server(question_lower_name) if services: strategies.append( - _AnswerStrategy( - question, _ANSWER_STRATEGY_ADDRESS, _EMPTY_TYPES_LIST, services - ) + _AnswerStrategy(question, _ANSWER_STRATEGY_ADDRESS, _EMPTY_TYPES_LIST, services) ) if type_ in (_TYPE_SRV, _TYPE_TXT, _TYPE_ANY): @@ -477,23 +455,17 @@ def handle_assembled_query( if question_answers.ucast: questions = first_packet._questions id_ = first_packet.id - out = construct_outgoing_unicast_answers( - question_answers.ucast, ucast_source, questions, id_ - ) + out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) # When sending unicast, only send back the reply # via the same socket that it was recieved from # as we know its reachable from that socket self.zc.async_send(out, addr, port, v6_flow_scope, transport) if question_answers.mcast_now: - self.zc.async_send( - construct_outgoing_multicast_answers(question_answers.mcast_now) - ) + self.zc.async_send(construct_outgoing_multicast_answers(question_answers.mcast_now)) if question_answers.mcast_aggregate: self.out_queue.async_add(first_packet.now, question_answers.mcast_aggregate) if question_answers.mcast_aggregate_last_second: # https://datatracker.ietf.org/doc/html/rfc6762#section-14 # If we broadcast it in the last second, we have to delay # at least a second before we send it again - self.out_delay_queue.async_add( - first_packet.now, question_answers.mcast_aggregate_last_second - ) + self.out_delay_queue.async_add(first_packet.now, question_answers.mcast_aggregate_last_second) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 86286deca..8ae82ba55 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -97,11 +97,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # level of rate limit and safe guards so we use 1/4 of the recommended value. record_type = record.type record_ttl = record.ttl - if ( - record_ttl - and record_type == _TYPE_PTR - and record_ttl < _DNS_PTR_MIN_TTL - ): + if record_ttl and record_type == _TYPE_PTR and record_ttl < _DNS_PTR_MIN_TTL: log.debug( "Increasing effective ttl of %s to minimum of %s to protect against excessive refreshes.", record, @@ -132,9 +128,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: removes.add(record) if unique_types: - cache.async_mark_unique_records_older_than_1s_to_expire( - unique_types, answers, now - ) + cache.async_mark_unique_records_older_than_1s_to_expire(unique_types, answers, now) if updates: self.async_updates(now, updates) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index 2e58b14e3..aa28519c5 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -38,15 +38,11 @@ def __init__(self) -> None: """Init a new QuestionHistory.""" self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} - def add_question_at_time( - self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord] - ) -> None: + def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> None: """Remember a question with known answers.""" self._history[question] = (now, known_answers) - def suppresses( - self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord] - ) -> bool: + def suppresses(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> bool: """Check to see if a question should be suppressed. https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 2956ad528..19cca8df1 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -119,7 +119,8 @@ def _process_datagram_at_time( # Guard against duplicate packets if debug: log.debug( - "Ignoring duplicate message with no unicast questions received from %s [socket %s] (%d bytes) as [%r]", + "Ignoring duplicate message with no unicast questions" + " received from %s [socket %s] (%d bytes) as [%r]", addrs, self.sock_description, data_len, @@ -139,9 +140,7 @@ def _process_datagram_at_time( # https://github.com/python/mypy/issues/1178 addr, port, flow, scope = addrs # type: ignore if debug: # pragma: no branch - log.debug( - "IPv6 scope_id %d associated to the receiving interface", scope - ) + log.debug("IPv6 scope_id %d associated to the receiving interface", scope) v6_flow_scope = (flow, scope) addr_port = (addr, port) @@ -204,7 +203,7 @@ def handle_query_or_defer( if incoming.data == msg.data: return deferred.append(msg) - delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) # noqa: S311 loop = self.zc.loop assert loop is not None self._cancel_any_timers_for_addr(addr) @@ -237,9 +236,7 @@ def _respond_query( if msg: packets.append(msg) - self._query_handler.handle_assembled_query( - packets, addr, port, transport, v6_flow_scope - ) + self._query_handler.handle_assembled_query(packets, addr, port, transport, v6_flow_scope) def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" @@ -251,13 +248,9 @@ def error_received(self, exc: Exception) -> None: QuietLogger.log_exception_once(exc, msg_str, exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: - wrapped_transport = make_wrapped_transport( - cast(asyncio.DatagramTransport, transport) - ) + wrapped_transport = make_wrapped_transport(cast(asyncio.DatagramTransport, transport)) self.transport = wrapped_transport - self.sock_description = ( - f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" - ) + self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" def connection_lost(self, exc: Optional[Exception]) -> None: """Handle connection lost.""" diff --git a/src/zeroconf/_logger.py b/src/zeroconf/_logger.py index 9e7261070..1556522eb 100644 --- a/src/zeroconf/_logger.py +++ b/src/zeroconf/_logger.py @@ -23,7 +23,7 @@ import logging import sys -from typing import Any, Dict, Union, cast +from typing import Any, ClassVar, Dict, Union, cast log = logging.getLogger(__name__.split(".", maxsplit=1)[0]) log.addHandler(logging.NullHandler()) @@ -38,7 +38,7 @@ def set_logger_level_if_unset() -> None: class QuietLogger: - _seen_logs: Dict[str, Union[int, tuple]] = {} + _seen_logs: ClassVar[Dict[str, Union[int, tuple]]] = {} @classmethod def log_exception_warning(cls, *logger_data: Any) -> None: diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 0ad6efce4..8670b0df2 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -279,12 +279,7 @@ def _read_others(self) -> None: # ttl is an unsigned long in network order https://www.rfc-editor.org/errata/eid2130 type_ = view[offset] << 8 | view[offset + 1] class_ = view[offset + 2] << 8 | view[offset + 3] - ttl = ( - view[offset + 4] << 24 - | view[offset + 5] << 16 - | view[offset + 6] << 8 - | view[offset + 7] - ) + ttl = view[offset + 4] << 24 | view[offset + 5] << 16 | view[offset + 6] << 8 | view[offset + 7] length = view[offset + 8] << 8 | view[offset + 9] end = self.offset + length rec = None @@ -311,15 +306,11 @@ def _read_record( ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - return DNSAddress( - domain, type_, class_, ttl, self._read_string(4), None, self.now - ) + return DNSAddress(domain, type_, class_, ttl, self._read_string(4), None, self.now) if type_ in (_TYPE_CNAME, _TYPE_PTR): return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) if type_ == _TYPE_TXT: - return DNSText( - domain, type_, class_, ttl, self._read_string(length), self.now - ) + return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) if type_ == _TYPE_SRV: view = self.view offset = self.offset @@ -399,9 +390,7 @@ def _read_name(self) -> str: labels: List[str] = [] seen_pointers: Set[int] = set() original_offset = self.offset - self.offset = self._decode_labels_at_offset( - original_offset, labels, seen_pointers - ) + self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) self._name_cache[original_offset] = labels name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: @@ -410,9 +399,7 @@ def _read_name(self) -> str: ) return name - def _decode_labels_at_offset( - self, off: _int, labels: List[str], seen_pointers: Set[int] - ) -> int: + def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. view = self.view while off < self._data_len: @@ -422,9 +409,7 @@ def _decode_labels_at_offset( if length < 0x40: label_idx = off + DNS_COMPRESSION_HEADER_LEN - labels.append( - self.data[label_idx : label_idx + length].decode("utf-8", "replace") - ) + labels.append(self.data[label_idx : label_idx + length].decode("utf-8", "replace")) off += DNS_COMPRESSION_HEADER_LEN + length continue @@ -462,6 +447,4 @@ def _decode_labels_at_offset( ) return off + DNS_COMPRESSION_POINTER_LEN - raise IncomingDecodeError( - f"Corrupt packet received while decoding name from {self.source}" - ) + raise IncomingDecodeError(f"Corrupt packet received while decoding name from {self.source}") diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 66b526cca..9e9a5c870 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -151,9 +151,7 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: def add_answer_at_time(self, record: Optional[DNSRecord], now: float_) -> None: """Adds an answer if it does not expire by a certain time""" now_double = now - if record is not None and ( - now_double == 0 or not record.is_expired(now_double) - ): + if record is not None and (now_double == 0 or not record.is_expired(now_double)): self.answers.append((record, now)) def add_authorative_answer(self, record: DNSPointer) -> None: @@ -292,9 +290,7 @@ def write_name(self, name: str_) -> None: return if name_length == 0: name_length = len(name.encode("utf-8")) - self.names[partial_name] = ( - start_size + name_length - len(partial_name.encode("utf-8")) - ) + self.names[partial_name] = start_size + name_length - len(partial_name.encode("utf-8")) self._write_utf(labels[count]) # this is the end of a name @@ -349,9 +345,7 @@ def _write_record(self, record: DNSRecord_, now: float_) -> bool: self._replace_short(index, length) return self._check_data_limit_or_rollback(start_data_length, start_size) - def _check_data_limit_or_rollback( - self, start_data_length: int_, start_size: int_ - ) -> bool: + def _check_data_limit_or_rollback(self, start_data_length: int_, start_size: int_) -> bool: """Check data limit, if we go over, then rollback and return False.""" len_limit = _MAX_MSG_ABSOLUTE if self.allow_long else _MAX_MSG_TYPICAL self.allow_long = False @@ -369,9 +363,7 @@ def _check_data_limit_or_rollback( self.size = start_size start_size_int = start_size - rollback_names = [ - name for name, idx in self.names.items() if idx >= start_size_int - ] + rollback_names = [name for name, idx in self.names.items() if idx >= start_size_int] for name in rollback_names: del self.names[name] return False @@ -392,9 +384,7 @@ def _write_answers_from_offset(self, answer_offset: int_) -> int: answers_written += 1 return answers_written - def _write_records_from_offset( - self, records: Sequence[DNSRecord], offset: int_ - ) -> int: + def _write_records_from_offset(self, records: Sequence[DNSRecord], offset: int_) -> int: records_written = 0 for record in records[offset:]: if not self._write_record(record, 0): @@ -458,12 +448,8 @@ def packets(self) -> List[bytes]: questions_written = self._write_questions_from_offset(questions_offset) answers_written = self._write_answers_from_offset(answer_offset) - authorities_written = self._write_records_from_offset( - self.authorities, authority_offset - ) - additionals_written = self._write_records_from_offset( - self.additionals, additional_offset - ) + authorities_written = self._write_records_from_offset(self.authorities, authority_offset) + additionals_written = self._write_records_from_offset(self.additionals, additional_offset) made_progress = bool(self.data) diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 9812c6f36..7a6bddebb 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -66,14 +66,10 @@ class SignalRegistrationInterface: def __init__(self, handlers: List[Callable[..., None]]) -> None: self._handlers = handlers - def register_handler( - self, handler: Callable[..., None] - ) -> "SignalRegistrationInterface": + def register_handler(self, handler: Callable[..., None]) -> "SignalRegistrationInterface": self._handlers.append(handler) return self - def unregister_handler( - self, handler: Callable[..., None] - ) -> "SignalRegistrationInterface": + def unregister_handler(self, handler: Callable[..., None]) -> "SignalRegistrationInterface": self._handlers.remove(handler) return self diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 1f0524f39..303615280 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -28,7 +28,7 @@ import time import warnings from functools import partial -from types import TracebackType # noqa # used in type hints +from types import TracebackType # used in type hints from typing import ( TYPE_CHECKING, Any, @@ -197,9 +197,7 @@ def __init__(self, now_millis: float, multicast: bool) -> None: self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast) self.bytes = 0 - def add( - self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer] - ) -> None: + def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer]) -> None: """Add a new set of questions and known answers to the outgoing.""" self.out.add_question(question) for answer in answers: @@ -220,9 +218,7 @@ def group_ptr_queries_with_known_answers( so we try to keep all the known answers in the same packet as the questions. """ - return _group_ptr_queries_with_known_answers( - now, multicast, question_with_known_answers - ) + return _group_ptr_queries_with_known_answers(now, multicast, question_with_known_answers) def _group_ptr_queries_with_known_answers( @@ -237,10 +233,7 @@ def _group_ptr_queries_with_known_answers( # goal of this algorithm is to quickly bucket the query + known answers without # the overhead of actually constructing the packets. query_by_size: Dict[DNSQuestion, int] = { - question: ( - question.max_size - + sum(answer.max_size_compressed for answer in known_answers) - ) + question: (question.max_size + sum(answer.max_size_compressed for answer in known_answers)) for question, known_answers in question_with_known_answers.items() } max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN @@ -276,9 +269,7 @@ def generate_service_query( ) -> List[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} - qu_question = ( - not multicast if question_type is None else question_type is QU_QUESTION - ) + qu_question = not multicast if question_type is None else question_type is QU_QUESTION question_history = zc.question_history cache = zc.cache for type_ in types_: @@ -289,9 +280,7 @@ def generate_service_query( for record in cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now_millis) } - if not qu_question and question_history.suppresses( - question, now_millis, known_answers - ): + if not qu_question and question_history.suppresses(question, now_millis, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue if TYPE_CHECKING: @@ -302,9 +291,7 @@ def generate_service_query( if not qu_question: question_history.add_question_at_time(question, now_millis, known_answers) - return _group_ptr_queries_with_known_answers( - now_millis, multicast, questions_with_known_answers - ) + return _group_ptr_queries_with_known_answers(now_millis, multicast, questions_with_known_answers) def _on_change_dispatcher( @@ -325,9 +312,10 @@ def _service_state_changed_from_listener( assert listener is not None if not hasattr(listener, "update_service"): warnings.warn( - "%r has no update_service method. Provide one (it can be empty if you " - "don't care about the updates), it'll become mandatory." % (listener,), + f"{listener!r} has no update_service method. Provide one (it can be empty if you " + "don't care about the updates), it'll become mandatory.", FutureWarning, + stacklevel=1, ) return partial(_on_change_dispatcher, listener) @@ -379,9 +367,7 @@ def __init__( self._next_scheduled_for_alias: Dict[str, _ScheduledPTRQuery] = {} self._query_heap: list[_ScheduledPTRQuery] = [] self._next_run: Optional[asyncio.TimerHandle] = None - self._clock_resolution_millis = ( - time.get_clock_info("monotonic").resolution * 1000 - ) + self._clock_resolution_millis = time.get_clock_info("monotonic").resolution * 1000 self._question_type = question_type def start(self, loop: asyncio.AbstractEventLoop) -> None: @@ -394,9 +380,7 @@ def start(self, loop: asyncio.AbstractEventLoop) -> None: also delay the first query of the series by a randomly chosen amount in the range 20-120 ms. """ - start_delay = millis_to_seconds( - random.randint(*self._first_random_delay_interval) - ) + start_delay = millis_to_seconds(random.randint(*self._first_random_delay_interval)) # noqa: S311 self._loop = loop self._next_run = loop.call_later(start_delay, self._process_startup_queries) @@ -485,9 +469,7 @@ def _process_startup_queries(self) -> None: now_millis = current_time_millis() # At first we will send STARTUP_QUERIES queries to get the cache populated - self.async_send_ready_queries( - self._startup_queries_sent == 0, now_millis, self._types - ) + self.async_send_ready_queries(self._startup_queries_sent == 0, now_millis, self._types) self._startup_queries_sent += 1 # Once we finish sending the initial queries we will @@ -500,9 +482,7 @@ def _process_startup_queries(self) -> None: ) return - self._next_run = self._loop.call_later( - self._startup_queries_sent**2, self._process_startup_queries - ) + self._next_run = self._loop.call_later(self._startup_queries_sent**2, self._process_startup_queries) def _process_ready_types(self) -> None: """Generate a list of ready types that is due and schedule the next time.""" @@ -543,9 +523,7 @@ def _process_ready_types(self) -> None: schedule_rescue.append(query) for query in schedule_rescue: - self.schedule_rescue_query( - query, now_millis, RESCUE_RECORD_RETRY_TTL_PERCENTAGE - ) + self.schedule_rescue_query(query, now_millis, RESCUE_RECORD_RETRY_TTL_PERCENTAGE) if ready_types: self.async_send_ready_queries(False, now_millis, ready_types) @@ -557,9 +535,7 @@ def _process_ready_types(self) -> None: else: next_when_millis = next_time_millis - self._next_run = self._loop.call_at( - millis_to_seconds(next_when_millis), self._process_ready_types - ) + self._next_run = self._loop.call_at(millis_to_seconds(next_when_millis), self._process_ready_types) def async_send_ready_queries( self, first_request: bool, now_millis: float_, ready_types: Set[str] @@ -569,14 +545,8 @@ def async_send_ready_queries( # https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 since we are # just starting up and we know our cache is likely empty. This ensures # the next outgoing will be sent with the known answers list. - question_type = ( - QU_QUESTION - if self._question_type is None and first_request - else self._question_type - ) - outs = generate_service_query( - self._zc, now_millis, ready_types, self._multicast, question_type - ) + question_type = QU_QUESTION if self._question_type is None and first_request else self._question_type + outs = generate_service_query(self._zc, now_millis, ready_types, self._multicast, question_type) if outs: for out in outs: self._zc.async_send(out, self._addr, self._port) @@ -667,13 +637,9 @@ def _async_start(self) -> None: Must be called by uses of this base class after they have finished setting their properties. """ - self.zc.async_add_listener( - self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types] - ) + self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) # Only start queries after the listener is installed - self._query_sender_task = asyncio.ensure_future( - self._async_start_query_sender() - ) + self._query_sender_task = asyncio.ensure_future(self._async_start_query_sender()) @property def service_state_changed(self) -> SignalRegistrationInterface: @@ -682,9 +648,7 @@ def service_state_changed(self) -> SignalRegistrationInterface: def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: """Return the type and name for records matching the types we are browsing.""" return [ - (type_, name) - for name in names - for type_ in self.types.intersection(cached_possible_types(name)) + (type_, name) for name in names for type_ in self.types.intersection(cached_possible_types(name)) ] def _enqueue_callback( @@ -702,16 +666,11 @@ def _enqueue_callback( state_change is SERVICE_STATE_CHANGE_REMOVED and self._pending_handlers.get(key) is not SERVICE_STATE_CHANGE_ADDED ) - or ( - state_change is SERVICE_STATE_CHANGE_UPDATED - and key not in self._pending_handlers - ) + or (state_change is SERVICE_STATE_CHANGE_UPDATED and key not in self._pending_handlers) ): self._pending_handlers[key] = state_change - def async_update_records( - self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. @@ -729,18 +688,12 @@ def async_update_records( if TYPE_CHECKING: record = cast(DNSPointer, record) pointer = record - for type_ in self.types.intersection( - cached_possible_types(pointer.name) - ): + for type_ in self.types.intersection(cached_possible_types(pointer.name)): if old_record is None: - self._enqueue_callback( - SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias - ) + self._enqueue_callback(SERVICE_STATE_CHANGE_ADDED, type_, pointer.alias) self.query_scheduler.reschedule_ptr_first_refresh(pointer) elif pointer.is_expired(now): - self._enqueue_callback( - SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias - ) + self._enqueue_callback(SERVICE_STATE_CHANGE_REMOVED, type_, pointer.alias) self.query_scheduler.cancel_ptr_refresh(pointer) else: self.query_scheduler.reschedule_ptr_first_refresh(pointer) @@ -752,10 +705,7 @@ def async_update_records( if record_type in _ADDRESS_RECORD_TYPES: cache = self._cache - names = { - service.name - for service in cache.async_entries_with_server(record.name) - } + names = {service.name for service in cache.async_entries_with_server(record.name)} # Iterate through the DNSCache and callback any services that use this address for type_, name in self._names_matching_types(names): self._enqueue_callback(SERVICE_STATE_CHANGE_UPDATED, type_, name) @@ -777,9 +727,7 @@ def async_update_records_complete(self) -> None: self._fire_service_state_changed_event(pending) self._pending_handlers.clear() - def _fire_service_state_changed_event( - self, event: Tuple[Tuple[str, str], ServiceStateChange] - ) -> None: + def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: """Fire a service state changed event. When running with ServiceBrowser, this will happen in the dedicated @@ -801,9 +749,7 @@ def _async_cancel(self) -> None: self.done = True self.query_scheduler.stop() self.zc.async_remove_listener(self) - assert ( - self._query_sender_task is not None - ), "Attempted to cancel a browser that was not started" + assert self._query_sender_task is not None, "Attempted to cancel a browser that was not started" self._query_sender_task.cancel() self._query_sender_task = None @@ -836,9 +782,7 @@ def __init__( if not zc.loop.is_running(): raise RuntimeError("The event loop is not running") threading.Thread.__init__(self) - super().__init__( - zc, type_, handlers, listener, addr, port, delay, question_type - ) + super().__init__(zc, type_, handlers, listener, addr, port, delay, question_type) # Add the queue before the listener is installed in _setup # to ensure that events run in the dedicated thread and do # not block the event loop diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 66313afc8..2fc9dfc8e 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -179,9 +179,7 @@ def __init__( ) -> None: # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: - raise TypeError( - "addresses and parsed_addresses cannot be provided together" - ) + raise TypeError("addresses and parsed_addresses cannot be provided together") if not type_.endswith(service_type_name(name, strict=False)): raise BadTypeInNameException self.interface_index = interface_index @@ -251,11 +249,7 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - if ( - IPADDRESS_SUPPORTS_SCOPE_ID - and len(address) == 16 - and self.interface_index is not None - ): + if IPADDRESS_SUPPORTS_SCOPE_ID and len(address) == 16 and self.interface_index is not None: addr = ip_bytes_and_scope_to_address(address, self.interface_index) else: addr = cached_ip_addresses(address) @@ -299,9 +293,7 @@ def async_clear_cache(self) -> None: self._dns_text_cache = None self._get_address_and_nsec_records_cache = None - async def async_wait( - self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None - ) -> None: + async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: """Calling task waits for a given number of milliseconds or until notified.""" if not self._new_records_futures: self._new_records_futures = set() @@ -359,10 +351,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: This means the first address will always be the most recently added address of the given IP version. """ - return [ - str_without_scope_id(addr) - for addr in self._ip_addresses_by_version_value(version.value) - ] + return [str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local @@ -374,13 +363,9 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st This means the first address will always be the most recently added address of the given IP version. """ - return [ - str(addr) for addr in self._ip_addresses_by_version_value(version.value) - ] + return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] - def _set_properties( - self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]] - ) -> None: + def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: """Sets properties and text of this info from a dictionary""" list_: List[bytes] = [] properties_contain_str = False @@ -421,9 +406,7 @@ def _set_text(self, text: bytes) -> None: def _generate_decoded_properties(self) -> None: """Generates decoded properties from the properties""" self._decoded_properties = { - k.decode("ascii", "replace"): None - if v is None - else v.decode("utf-8", "replace") + k.decode("ascii", "replace"): None if v is None else v.decode("utf-8", "replace") for k, v in self.properties.items() } @@ -477,9 +460,7 @@ def _set_ipv6_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA), ) else: - self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo( - zc, now, _TYPE_AAAA - ) + self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: """Set IPv4 addresses from the cache.""" @@ -489,13 +470,9 @@ def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A), ) else: - self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo( - zc, now, _TYPE_A - ) + self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) - def async_update_records( - self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: """Updates service information from a DNS record. This method will be run in the event loop. @@ -507,9 +484,7 @@ def async_update_records( if updated and new_records_futures: _resolve_all_futures_to_none(new_records_futures) - def _process_record_threadsafe( - self, zc: "Zeroconf", record: DNSRecord, now: float_ - ) -> bool: + def _process_record_threadsafe(self, zc: "Zeroconf", record: DNSRecord, now: float_) -> bool: """Thread safe record updating. Returns True if a new record was added. @@ -691,15 +666,11 @@ def _dns_text(self, override_ttl: Optional[int]) -> DNSText: self._dns_text_cache = record return record - def dns_nsec( - self, missing_types: List[int], override_ttl: Optional[int] = None - ) -> DNSNsec: + def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return self._dns_nsec(missing_types, override_ttl) - def _dns_nsec( - self, missing_types: List[int], override_ttl: Optional[int] - ) -> DNSNsec: + def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -711,15 +682,11 @@ def _dns_nsec( 0.0, ) - def get_address_and_nsec_records( - self, override_ttl: Optional[int] = None - ) -> Set[DNSRecord]: + def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" return self._get_address_and_nsec_records(override_ttl) - def _get_address_and_nsec_records( - self, override_ttl: Optional[int] - ) -> Set[DNSRecord]: + def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" cacheable = override_ttl is None if self._get_address_and_nsec_records_cache is not None and cacheable: @@ -730,17 +697,13 @@ def _get_address_and_nsec_records( missing_types.discard(dns_address.type) records.add(dns_address) if missing_types: - assert ( - self.server is not None - ), "Service server must be set for NSEC record." + assert self.server is not None, "Service server must be set for NSEC record." records.add(self._dns_nsec(list(missing_types), override_ttl)) if cacheable: self._get_address_and_nsec_records_cache = records return records - def _get_address_records_from_cache_by_type( - self, zc: "Zeroconf", _type: int_ - ) -> List[DNSAddress]: + def _get_address_records_from_cache_by_type(self, zc: "Zeroconf", _type: int_) -> List[DNSAddress]: """Get the addresses from the cache.""" if self.server_key is None: return [] @@ -796,9 +759,7 @@ def _load_from_cache(self, zc: "Zeroconf", now: float_) -> bool: @property def _is_complete(self) -> bool: """The ServiceInfo has all expected properties.""" - return bool( - self.text is not None and (self._ipv4_addresses or self._ipv6_addresses) - ) + return bool(self.text is not None and (self._ipv4_addresses or self._ipv6_addresses)) def request( self, @@ -883,9 +844,7 @@ async def async_request( if last <= now: return False if next_ <= now: - this_question_type = ( - question_type or QU_QUESTION if first_request else QM_QUESTION - ) + this_question_type = question_type or QU_QUESTION if first_request else QM_QUESTION out = self._generate_request_query(zc, now, this_question_type) first_request = False if out.questions: @@ -897,10 +856,7 @@ async def async_request( zc.async_send(out, addr, port) next_ = now + delay next_ += self._get_random_delay() - if ( - this_question_type is QM_QUESTION - and delay < _DUPLICATE_QUESTION_INTERVAL - ): + if this_question_type is QM_QUESTION and delay < _DUPLICATE_QUESTION_INTERVAL: # If we just asked a QM question, we need to # wait at least the duplicate question interval # before asking another QM question otherwise @@ -929,9 +885,7 @@ def _add_question_with_known_answers( ) -> None: """Add a question with known answers if its not suppressed.""" known_answers = { - answer - for answer in cache.get_all_by_details(name, type_, class_) - if not answer.is_stale(now) + answer for answer in cache.get_all_by_details(name, type_, class_) if not answer.is_stale(now) } if skip_if_known_answers and known_answers: return diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 2d4f3f8ec..05ee14cb8 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -79,9 +79,7 @@ def async_get_infos_server(self, server: str) -> List[ServiceInfo]: """Return all ServiceInfo matching server.""" return self._async_get_by_index(self.servers, server) - def _async_get_by_index( - self, records: Dict[str, List], key: _str - ) -> List[ServiceInfo]: + def _async_get_by_index(self, records: Dict[str, List], key: _str) -> List[ServiceInfo]: """Return all ServiceInfo matching the index.""" record_list = records.get(key) if record_list is None: diff --git a/src/zeroconf/_services/types.py b/src/zeroconf/_services/types.py index 9793ae481..63b6d19a1 100644 --- a/src/zeroconf/_services/types.py +++ b/src/zeroconf/_services/types.py @@ -69,9 +69,7 @@ def find( """ local_zc = zc or Zeroconf(interfaces=interfaces, ip_version=ip_version) listener = cls() - browser = ServiceBrowser( - local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener - ) + browser = ServiceBrowser(local_zc, _SERVICE_TYPE_ENUMERATION_NAME, listener=listener) # wait for responses time.sleep(timeout) diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index eda89df4f..58be33d8c 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -47,13 +47,9 @@ def update_record( # pylint: disable=no-self-use This method is deprecated and will be removed in a future version. update_records should be implemented instead. """ - raise RuntimeError( - "update_record is deprecated and will be removed in a future version." - ) + raise RuntimeError("update_record is deprecated and will be removed in a future version.") - def async_update_records( - self, zc: "Zeroconf", now: float_, records: List[RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: """Update multiple records in one shot. All records that are received in a single packet are passed diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index c2e66277c..6d070e306 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -60,9 +60,7 @@ async def wait_for_future_set_or_timeout( """Wait for a future or timeout (in milliseconds).""" future = loop.create_future() future_set.add(future) - handle = loop.call_later( - millis_to_seconds(timeout), _set_future_none_if_not_done, future - ) + handle = loop.call_later(millis_to_seconds(timeout), _set_future_none_if_not_done, future) try: await future finally: @@ -100,9 +98,7 @@ async def await_awaitable(aw: Awaitable) -> None: await task -def run_coro_with_timeout( - aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float -) -> Any: +def run_coro_with_timeout(aw: Coroutine, loop: asyncio.AbstractEventLoop, timeout: float) -> Any: """Run a coroutine with a timeout. The timeout should only be used as a safeguard to prevent @@ -124,15 +120,13 @@ def run_coro_with_timeout( def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: """Wait for pending tasks and stop an event loop.""" pending_tasks = set( - asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result( - _GET_ALL_TASKS_TIMEOUT - ) + asyncio.run_coroutine_threadsafe(_async_get_all_tasks(loop), loop).result(_GET_ALL_TASKS_TIMEOUT) ) pending_tasks -= {task for task in pending_tasks if task.done()} if pending_tasks: - asyncio.run_coroutine_threadsafe( - _wait_for_loop_tasks(pending_tasks), loop - ).result(_WAIT_FOR_LOOP_TASKS_TIMEOUT) + asyncio.run_coroutine_threadsafe(_wait_for_loop_tasks(pending_tasks), loop).result( + _WAIT_FOR_LOOP_TASKS_TIMEOUT + ) loop.call_soon_threadsafe(loop.stop) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index d4ba708e2..6b4657be7 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -112,16 +112,12 @@ def get_ip_address_object_from_record( return cached_ip_addresses_wrapper(record.address) -def ip_bytes_and_scope_to_address( - address: bytes_, scope: int_ -) -> Optional[Union[IPv4Address, IPv6Address]]: +def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: """Convert the bytes and scope to an IP address object.""" base_address = cached_ip_addresses_wrapper(address) if base_address is not None and base_address.is_link_local: # Avoid expensive __format__ call by using PyUnicode_Join - return cached_ip_addresses_wrapper( - "".join((str(base_address), "%", str(scope))) - ) + return cached_ip_addresses_wrapper("".join((str(base_address), "%", str(scope)))) return base_address diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index 3f923cfde..cda01b28e 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -80,7 +80,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis """ if len(type_) > 256: # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 - raise BadTypeInNameException("Full name (%s) must be > 256 bytes" % type_) + raise BadTypeInNameException(f"Full name ({type_}) must be > 256 bytes") if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split(".") @@ -88,8 +88,8 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis has_protocol = True elif strict: raise BadTypeInNameException( - "Type '%s' must end with '%s' or '%s'" - % (type_, _TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER) + f"Type '{type_}' must end with " + f"'{_TCP_PROTOCOL_LOCAL_TRAILER}' or '{_NONTCP_PROTOCOL_LOCAL_TRAILER}'" ) elif type_.endswith(_LOCAL_TRAILER): remaining = type_[: -len(_LOCAL_TRAILER)].split(".") @@ -104,48 +104,39 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis raise BadTypeInNameException("No Service name found") if len(remaining) == 1 and len(remaining[0]) == 0: - raise BadTypeInNameException("Type '%s' must not start with '.'" % type_) + raise BadTypeInNameException(f"Type '{type_}' must not start with '.'") if service_name[0] != "_": - raise BadTypeInNameException( - "Service name (%s) must start with '_'" % service_name - ) + raise BadTypeInNameException(f"Service name ({service_name}) must start with '_'") test_service_name = service_name[1:] if strict and len(test_service_name) > 15: # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 - raise BadTypeInNameException( - "Service name (%s) must be <= 15 bytes" % test_service_name - ) + raise BadTypeInNameException(f"Service name ({test_service_name}) must be <= 15 bytes") if "--" in test_service_name: - raise BadTypeInNameException( - "Service name (%s) must not contain '--'" % test_service_name - ) + raise BadTypeInNameException(f"Service name ({test_service_name}) must not contain '--'") if "-" in (test_service_name[0], test_service_name[-1]): - raise BadTypeInNameException( - "Service name (%s) may not start or end with '-'" % test_service_name - ) + raise BadTypeInNameException(f"Service name ({test_service_name}) may not start or end with '-'") if not _HAS_A_TO_Z.search(test_service_name): raise BadTypeInNameException( - "Service name (%s) must contain at least one letter (eg: 'A-Z')" - % test_service_name + f"Service name ({test_service_name}) must contain at least one letter (eg: 'A-Z')" ) allowed_characters_re = ( - _HAS_ONLY_A_TO_Z_NUM_HYPHEN - if strict - else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE + _HAS_ONLY_A_TO_Z_NUM_HYPHEN if strict else _HAS_ONLY_A_TO_Z_NUM_HYPHEN_UNDERSCORE ) if not allowed_characters_re.search(test_service_name): raise BadTypeInNameException( - "Service name (%s) must contain only these characters: " - "A-Z, a-z, 0-9, hyphen ('-')%s" - % (test_service_name, "" if strict else ", underscore ('_')") + f"Service name ({test_service_name if strict else ''}) " + "must contain only these characters: " + "A-Z, a-z, 0-9, hyphen ('-')" + ", underscore ('_')" + if strict + else "" ) else: service_name = "" @@ -161,12 +152,11 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis if remaining: length = len(remaining[0].encode("utf-8")) if length > 63: - raise BadTypeInNameException("Too long: '%s'" % remaining[0]) + raise BadTypeInNameException(f"Too long: '{remaining[0]}'") if _HAS_ASCII_CONTROL_CHARS.search(remaining[0]): raise BadTypeInNameException( - "Ascii control character 0x00-0x1F and 0x7F illegal in '%s'" - % remaining[0] + f"Ascii control character 0x00-0x1F and 0x7F illegal in '{remaining[0]}'" ) return service_name + trailer diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index fbac9fe73..4cd50926a 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -40,9 +40,7 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[ - Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice -] +InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] @enum.unique @@ -73,42 +71,25 @@ def _encode_address(address: str) -> bytes: def get_all_addresses() -> List[str]: - return list( - { - addr.ip - for iface in ifaddr.get_adapters() - for addr in iface.ips - if addr.is_IPv4 - } - ) + return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( - { - (addr.ip, iface.index) - for iface in ifaddr.get_adapters() - for addr in iface.ips - if addr.is_IPv6 - } + {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} ) -def ip6_to_address_and_index( - adapters: List[Any], ip: str -) -> Tuple[Tuple[str, int, int], int]: +def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: if "%" in ip: ip = ip[: ip.index("%")] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples - if ( - isinstance(adapter_ip.ip, tuple) - and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr - ): + if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: return ( cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index), @@ -117,9 +98,7 @@ def ip6_to_address_and_index( raise RuntimeError("No adapter found for IP address %s" % ip) -def interface_index_to_ip6_address( - adapters: List[Any], index: int -) -> Tuple[str, int, int]: +def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: for adapter in adapters: if adapter.index == index: for adapter_ip in adapter.ips: @@ -175,16 +154,11 @@ def normalize_interface_choice( result.extend(get_all_addresses()) if not result: raise RuntimeError( - "No interfaces to listen on, check that any interfaces have IP version %s" - % ip_version + "No interfaces to listen on, check that any interfaces have IP version %s" % ip_version ) elif isinstance(choice, list): # First, take IPv4 addresses. - result = [ - i - for i in choice - if isinstance(i, str) and ipaddress.ip_address(i).version == 4 - ] + result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. result += ip6_addresses_to_indexes(choice) else: @@ -197,9 +171,7 @@ def disable_ipv6_only_or_raise(s: socket.socket) -> None: try: s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False) except OSError: - log.error( - "Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6" - ) + log.error("Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6") raise @@ -237,9 +209,7 @@ def set_mdns_port_socket_options_for_ip_version( s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) except OSError as e: - if ( - bind_addr[0] != "" or get_errno(e) != errno.EINVAL - ): # Fails to set on MacOS + if bind_addr[0] != "" or get_errno(e) != errno.EINVAL: # Fails to set on MacOS raise if ip_version != IPVersion.V4Only: @@ -261,9 +231,7 @@ def new_socket( apple_p2p, bind_addr, ) - socket_family = ( - socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6 - ) + socket_family = socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6 s = socket.socket(socket_family, socket.SOCK_DGRAM) if ip_version == IPVersion.All: @@ -286,8 +254,7 @@ def new_socket( except OSError as ex: if ex.errno == errno.EADDRNOTAVAIL: log.warning( - "Address not available when binding to %s, " - "it is expected to happen on some systems", + "Address not available when binding to %s, " "it is expected to happen on some systems", bind_tup, ) return None @@ -306,9 +273,7 @@ def add_multicast_member( if sys.platform == "win32": # No WSAEINVAL definition in typeshed err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member - log.debug( - "Adding %r (socket %d) to multicast group", interface, listen_socket.fileno() - ) + log.debug("Adding %r (socket %d) to multicast group", interface, listen_socket.fileno()) try: if is_v6: try: @@ -324,12 +289,8 @@ def add_multicast_member( _value = mdns_addr6_bytes + iface_bin listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value) else: - _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton( - cast(str, interface) - ) - listen_socket.setsockopt( - socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value - ) + _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface)) + listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value) except OSError as e: _errno = get_errno(e) if _errno == errno.EADDRINUSE: @@ -378,15 +339,11 @@ def new_respond_socket( respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p, - bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] - if is_v6 - else (cast(str, interface),), + bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), ) if not respond_socket: return None - log.debug( - "Configuring socket %s with multicast interface %s", respond_socket, interface - ) + log.debug("Configuring socket %s with multicast interface %s", respond_socket, interface) if is_v6: iface_bin = struct.pack("@I", cast(int, interface[1])) respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin) @@ -408,9 +365,7 @@ def create_sockets( if unicast: listen_socket = None else: - listen_socket = new_socket( - ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",) - ) + listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",)) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) @@ -461,14 +416,10 @@ def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: """Auto detect the IP version when it is not provided.""" if isinstance(interfaces, list): has_v6 = any( - isinstance(i, int) - or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - for i in interfaces - ) - has_v4 = any( - isinstance(i, str) and ipaddress.ip_address(i).version == 4 + isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) for i in interfaces ) + has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) if has_v4 and has_v6: return IPVersion.All if has_v6: diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index c2a51f940..134ea3e0f 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -22,7 +22,7 @@ import asyncio import contextlib -from types import TracebackType # noqa # used in type hints +from types import TracebackType # used in type hints from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union from ._core import Zeroconf @@ -72,9 +72,7 @@ def __init__( delay: int = _BROWSER_TIME, question_type: Optional[DNSQuestionType] = None, ) -> None: - super().__init__( - zeroconf, type_, handlers, listener, addr, port, delay, question_type - ) + super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type) self._async_start() async def async_cancel(self) -> None: @@ -249,20 +247,14 @@ async def async_get_service_info( :param timeout: milliseconds to wait for a response :param question_type: The type of questions to ask (DNSQuestionType.QM or DNSQuestionType.QU) """ - return await self.zeroconf.async_get_service_info( - type_, name, timeout, question_type - ) + return await self.zeroconf.async_get_service_info(type_, name, timeout, question_type) - async def async_add_service_listener( - self, type_: str, listener: ServiceListener - ) -> None: + async def async_add_service_listener(self, type_: str, listener: ServiceListener) -> None: """Adds a listener for a particular service type. This object will then have its add_service and remove_service methods called when services of that type become available and unavailable.""" await self.async_remove_service_listener(listener) - self.async_browsers[listener] = AsyncServiceBrowser( - self.zeroconf, type_, listener - ) + self.async_browsers[listener] = AsyncServiceBrowser(self.zeroconf, type_, listener) async def async_remove_service_listener(self, listener: ServiceListener) -> None: """Removes a listener from the set that is currently listening.""" @@ -273,10 +265,7 @@ async def async_remove_service_listener(self, listener: ServiceListener) -> None async def async_remove_all_service_listeners(self) -> None: """Removes a listener from the set that is currently listening.""" await asyncio.gather( - *( - self.async_remove_service_listener(listener) - for listener in list(self.async_browsers) - ) + *(self.async_remove_service_listener(listener) for listener in list(self.async_browsers)) ) async def __aenter__(self) -> "AsyncZeroconf": diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 6c64e144d..d84cb73ba 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -31,9 +31,7 @@ _LISTENER_TIME = 200 # ms _BROWSER_TIME = 10000 # ms _DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 # ms -_DUPLICATE_QUESTION_INTERVAL = ( - 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL -) +_DUPLICATE_QUESTION_INTERVAL = 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL _CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s _STARTUP_TIMEOUT = 9 # s must be lower than _LOADED_SYSTEM_TIMEOUT diff --git a/tests/__init__.py b/tests/__init__.py index 1feebafb4..82c09be7e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -36,9 +36,7 @@ class QuestionHistoryWithoutSuppression(QuestionHistory): - def suppresses( - self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord] - ) -> bool: + def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: return False diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 17950683d..dc9b14353 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -293,26 +293,20 @@ def mock_record_update_incoming_msg( ) generated.add_answer_at_time( - r.DNSPointer( - service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name - ), + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0, ) return r.DNSIncoming(generated.packets()[0]) zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) - service_browser = r.ServiceBrowser( - zeroconf, service_type, listener=MyServiceListener() - ) + service_browser = r.ServiceBrowser(zeroconf, service_type, listener=MyServiceListener()) try: wait_time = 3 # service added - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Added) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Added)) service_add_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 0 @@ -321,9 +315,7 @@ def mock_record_update_incoming_msg( # service SRV updated service_updated_event.clear() service_server = "ash-2.local." - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 1 @@ -332,9 +324,7 @@ def mock_record_update_incoming_msg( # service TXT updated service_updated_event.clear() service_text = b"path=/~matt2/" - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -343,9 +333,7 @@ def mock_record_update_incoming_msg( # service TXT updated - duplicate update should not trigger another service_updated service_updated_event.clear() service_text = b"path=/~matt2/" - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 2 @@ -356,9 +344,7 @@ def mock_record_update_incoming_msg( service_address = "10.0.1.3" # Verify we match on uppercase service_server = service_server.upper() - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 3 @@ -369,18 +355,14 @@ def mock_record_update_incoming_msg( service_server = "ash-3.local." service_text = b"path=/~matt3/" service_address = "10.0.1.3" - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) service_updated_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 assert service_removed_count == 0 # service removed - _inject_response( - zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Removed) - ) + _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Removed)) service_removed_event.wait(wait_time) assert service_added_count == 1 assert service_updated_count == 4 @@ -430,17 +412,13 @@ def mock_record_update_incoming_msg( ) -> r.DNSIncoming: generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( - r.DNSPointer( - service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name - ), + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0, ) return r.DNSIncoming(generated.packets()[0]) zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) - service_browser = r.ServiceBrowser( - zeroconf, service_types, listener=MyServiceListener() - ) + service_browser = r.ServiceBrowser(zeroconf, service_types, listener=MyServiceListener()) try: wait_time = 3 @@ -470,9 +448,7 @@ def _mock_get_expiration_time(self, percent): return self.created + (percent * self.ttl * 10) # Set an expire time that will force a refresh - with patch( - "zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time - ): + with patch("zeroconf.DNSRecord.get_expiration_time", new=_mock_get_expiration_time): _inject_response( zeroconf, mock_record_update_incoming_msg( @@ -570,15 +546,10 @@ def on_service_state_change(zeroconf, service_type, state_change, name): start_time = current_time_millis() browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) - time.sleep( - millis_to_seconds( - _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 - ) - ) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert ( - current_time_millis() - start_time - > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] + current_time_millis() - start_time > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] ) finally: browser.cancel() @@ -633,9 +604,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change] - ) + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) info = ServiceInfo( type_, registration_name, @@ -737,9 +706,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change] - ) + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) info = ServiceInfo( type_, registration_name, @@ -832,11 +799,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): [on_service_state_change], question_type=r.DNSQuestionType.QM, ) - await asyncio.sleep( - millis_to_seconds( - _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 - ) - ) + await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] finally: @@ -876,11 +839,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): [on_service_state_change], question_type=r.DNSQuestionType.QU, ) - await asyncio.sleep( - millis_to_seconds( - _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5 - ) - ) + await asyncio.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] finally: @@ -898,9 +857,7 @@ def test_legacy_record_update_listener(): r.RecordUpdateListener().update_record( zc, 0, - r.DNSRecord( - "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL - ), + r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL), ) updates = [] @@ -908,9 +865,7 @@ def test_legacy_record_update_listener(): class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def update_record( - self, zc: "Zeroconf", now: float, record: r.DNSRecord - ) -> None: + def update_record(self, zc: "Zeroconf", now: float, record: r.DNSRecord) -> None: nonlocal updates updates.append(record) @@ -945,15 +900,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() assert len(updates) - assert ( - len( - [ - isinstance(update, r.DNSPointer) and update.name == type_ - for update in updates - ] - ) - >= 1 - ) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 zc.remove_listener(listener) # Removing a second time should not throw @@ -985,9 +932,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) _inject_response( zc, @@ -1002,9 +947,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): ) time.sleep(0.1) - assert callbacks == [ - ("_hap._tcp.local.", ServiceStateChange.Added, "xxxyyy._hap._tcp.local.") - ] + assert callbacks == [("_hap._tcp.local.", ServiceStateChange.Added, "xxxyyy._hap._tcp.local.")] service_info = zc.get_service_info(type_, registration_name) assert service_info is not None assert service_info.port == 80 @@ -1063,9 +1006,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) _inject_response( zc, @@ -1125,9 +1066,7 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) _inject_response( zc, @@ -1166,16 +1105,12 @@ def on_service_state_change(zeroconf, service_type, state_change, name): pass zc = r.Zeroconf(interfaces=["127.0.0.1"]) - browser = ServiceBrowser( - zc, ["_tivo-videostream._tcp.local."], [on_service_state_change] - ) + browser = ServiceBrowser(zc, ["_tivo-videostream._tcp.local."], [on_service_state_change]) browser.cancel() # Still fail on completely invalid with pytest.raises(r.BadTypeInNameException): - browser = ServiceBrowser( - zc, ["tivo-videostream._tcp.local."], [on_service_state_change] - ) + browser = ServiceBrowser(zc, ["tivo-videostream._tcp.local."], [on_service_state_change]) zc.close() @@ -1184,9 +1119,7 @@ def test_group_ptr_queries_with_known_answers(): now = current_time_millis() for i in range(120): name = f"_hap{i}._tcp._local." - questions_with_known_answers[ - DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN) - ] = { + questions_with_known_answers[DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)] = { DNSPointer( name, const._TYPE_PTR, @@ -1196,9 +1129,7 @@ def test_group_ptr_queries_with_known_answers(): ) for counter in range(i) } - outs = _services_browser.group_ptr_queries_with_known_answers( - now, True, questions_with_known_answers - ) + outs = _services_browser.group_ptr_queries_with_known_answers(now, True, questions_with_known_answers) for out in outs: packets = out.packets() # If we generate multiple packets there must @@ -1228,18 +1159,14 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert zc.question_history.suppresses(question, now, other_known_answers) # The known answer list is different, do not suppress - outs = _services_browser.generate_service_query( - zc, now, {name}, multicast=True, question_type=None - ) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert outs zc.cache.async_add_records([answer]) # The known answer list contains all the asked questions in the history # we should suppress - outs = _services_browser.generate_service_query( - zc, now, {name}, multicast=True, question_type=None - ) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert not outs # We do not suppress once the question history expires @@ -1249,23 +1176,17 @@ async def test_generate_service_query_suppress_duplicate_questions(): assert outs # We do not suppress QU queries ever - outs = _services_browser.generate_service_query( - zc, now, {name}, multicast=False, question_type=None - ) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=False, question_type=None) assert outs zc.question_history.async_expire(now + 2000) # No suppression after clearing the history - outs = _services_browser.generate_service_query( - zc, now, {name}, multicast=True, question_type=None - ) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert outs # The previous query we just sent is still remembered and # the next one is suppressed - outs = _services_browser.generate_service_query( - zc, now, {name}, multicast=True, question_type=None - ) + outs = _services_browser.generate_service_query(zc, now, {name}, multicast=True, question_type=None) assert not outs await aiozc.async_close() @@ -1285,9 +1206,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): pout = r.DNSIncoming(out.packets()[0]) sends.append(pout) - query_scheduler = _services_browser.QueryScheduler( - zc, types_, None, 0, True, delay, (0, 0), None - ) + query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) loop = asyncio.get_running_loop() # patch the zeroconf send so we can capture what is being sent @@ -1316,9 +1235,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ) query_scheduler.reschedule_ptr_first_refresh(ptr_record) - expected_when_time = ptr_record.get_expiration_time( - const._EXPIRE_REFRESH_TIME_PERCENT - ) + expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( ptr_record.alias, @@ -1330,9 +1247,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert query_scheduler._query_heap == [ptr_query] query_scheduler.reschedule_ptr_first_refresh(ptr2_record) - expected_when_time = ptr2_record.get_expiration_time( - const._EXPIRE_REFRESH_TIME_PERCENT - ) + expected_when_time = ptr2_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr2_record.get_expiration_time(100) ptr2_query = _ScheduledPTRQuery( ptr2_record.alias, @@ -1384,9 +1299,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): pout = r.DNSIncoming(out.packets()[0]) sends.append(pout) - query_scheduler = _services_browser.QueryScheduler( - zc, types_, None, 0, True, delay, (0, 0), None - ) + query_scheduler = _services_browser.QueryScheduler(zc, types_, None, 0, True, delay, (0, 0), None) loop = asyncio.get_running_loop() # patch the zeroconf send so we can capture what is being sent @@ -1408,9 +1321,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): ) query_scheduler.reschedule_ptr_first_refresh(ptr_record) - expected_when_time = ptr_record.get_expiration_time( - const._EXPIRE_REFRESH_TIME_PERCENT - ) + expected_when_time = ptr_record.get_expiration_time(const._EXPIRE_REFRESH_TIME_PERCENT) expected_expire_time = ptr_record.get_expiration_time(100) ptr_query = _ScheduledPTRQuery( ptr_record.alias, @@ -1484,9 +1395,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) should_not_match = ServiceInfo( not_match_type_, not_match_registration_name, @@ -1642,15 +1551,9 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de def test_scheduled_ptr_query_dunder_methods(): - query75 = _ScheduledPTRQuery( - "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 75 - ) - query80 = _ScheduledPTRQuery( - "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 80 - ) - query75_2 = _ScheduledPTRQuery( - "zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 140, 75 - ) + query75 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 75) + query80 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 120, 80) + query75_2 = _ScheduledPTRQuery("zoomy._hap._tcp.local.", "_hap._tcp.local.", 120, 140, 75) other = object() stringified = str(query75) assert "zoomy._hap._tcp.local." in stringified @@ -1668,13 +1571,13 @@ def test_scheduled_ptr_query_dunder_methods(): assert query75 != other with pytest.raises(TypeError): - query75 < other # type: ignore[operator] + assert query75 < other # type: ignore[operator] with pytest.raises(TypeError): - query75 <= other # type: ignore[operator] + assert query75 <= other # type: ignore[operator] with pytest.raises(TypeError): - query75 > other # type: ignore[operator] + assert query75 > other # type: ignore[operator] with pytest.raises(TypeError): - query75 >= other # type: ignore[operator] + assert query75 >= other # type: ignore[operator] @pytest.mark.asyncio @@ -1712,9 +1615,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): with patch.object(zeroconf_browser, "async_send", send): service_added = asyncio.Event() - browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change] - ) + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) info = ServiceInfo( type_, registration_name, @@ -1782,9 +1683,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send so we can capture what is being sent with patch.object(zeroconf_browser, "async_send", send): service_added = asyncio.Event() - browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change] - ) + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) expected_ttl = const._DNS_OTHER_TTL info = ServiceInfo( type_, diff --git a/tests/services/test_info.py b/tests/services/test_info.py index aefef6c80..4a9b1ee2f 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -306,22 +306,10 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert ( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expect query for SRV, A, AAAA @@ -344,18 +332,9 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 3 # type: ignore[unreachable] - assert ( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expect query for A, AAAA @@ -381,14 +360,8 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 2 - assert ( - r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_server, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_server, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions last_sent = None assert service_info is None @@ -411,9 +384,7 @@ def get_service_info_helper(zc, type, name): const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, ttl, - socket.inet_pton( - socket.AF_INET6, service_address_v6_ll - ), + socket.inet_pton(socket.AF_INET6, service_address_v6_ll), scope_id=service_scope_id, ), ] @@ -471,30 +442,16 @@ def get_service_info_helper(zc, type, name): args=(zc, service_type, service_name), ) helper_thread.start() - wait_time = ( - const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 - ) / 1000 + wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5) / 1000 # Expect query for SRV, TXT, A, AAAA send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert ( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expect query for SRV only as A, AAAA, and TXT are suppressed @@ -524,16 +481,11 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time * 0.25) assert last_sent is not None assert len(last_sent.questions) == 1 # type: ignore[unreachable] - assert ( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions assert service_info is None wait_time = ( - const._DUPLICATE_QUESTION_INTERVAL - + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] - + 5 + const._DUPLICATE_QUESTION_INTERVAL + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 ) / 1000 # Expect no queries as all are suppressed by the question history last_sent = None @@ -624,22 +576,10 @@ def get_service_info_helper(zc, type, name): send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 - assert ( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) - in last_sent.questions - ) - assert ( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) - in last_sent.questions - ) + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None # Expext no further queries @@ -816,9 +756,7 @@ def test_multiple_addresses(): else ip_address(address_v6_ll), ] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] - assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ - ip_address(address) - ] + assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ip_address(address)] assert info.addresses_by_version(r.IPVersion.V6Only) == [ address_v6, address_v6_ll, @@ -842,16 +780,12 @@ def test_multiple_addresses(): assert info.parsed_scoped_addresses() == [ address_parsed, address_v6_parsed, - address_v6_ll_scoped_parsed - if ipaddress_supports_scope_id - else address_v6_ll_parsed, + address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ address_v6_parsed, - address_v6_ll_scoped_parsed - if ipaddress_supports_scope_id - else address_v6_ll_parsed, + address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, ] @@ -896,9 +830,7 @@ def test_scoped_addresses_from_cache(): info = ServiceInfo(type_, registration_name) info.load_from_cache(zeroconf) assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%12"] - assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ - ip_address("fe80::52e:c2f2:bc5f:e9c6%12") - ] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%12")] zeroconf.close() @@ -913,12 +845,8 @@ async def test_multiple_a_addresses_newest_address_first(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) cache = aiozc.zeroconf.cache host = "multahost.local." - record1 = r.DNSAddress( - host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x01" - ) - record2 = r.DNSAddress( - host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x02" - ) + record1 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x01") + record2 = r.DNSAddress(host, const._TYPE_A, const._CLASS_IN, 1000, b"\x7f\x00\x00\x02") cache.async_add_records([record1, record2]) # New kwarg way @@ -959,9 +887,7 @@ def test_filter_address_by_type_from_service_info(): registration_name = f"{name}.{type_}" ipv4 = socket.inet_aton("10.0.1.2") ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) def dns_addresses_to_addresses(dns_address: List[DNSAddress]) -> List[bytes]: return [address.address for address in dns_address] @@ -971,12 +897,8 @@ def dns_addresses_to_addresses(dns_address: List[DNSAddress]) -> List[bytes]: ipv4, ipv6, ] - assert dns_addresses_to_addresses( - info.dns_addresses(version=r.IPVersion.V4Only) - ) == [ipv4] - assert dns_addresses_to_addresses( - info.dns_addresses(version=r.IPVersion.V6Only) - ) == [ipv6] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V4Only)) == [ipv4] + assert dns_addresses_to_addresses(info.dns_addresses(version=r.IPVersion.V6Only)) == [ipv6] def test_changing_name_updates_serviceinfo_key(): @@ -1102,9 +1024,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info( - f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU - ) + zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] zeroconf.close() @@ -1128,9 +1048,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info( - f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM - ) + zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] zeroconf.close() @@ -1139,10 +1057,7 @@ def test_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) start_time = r.current_time_millis() - assert ( - zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") - is None - ) + assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") is None end_time = r.current_time_millis() zeroconf.close() # 3000ms for the default timeout @@ -1232,9 +1147,7 @@ async def test_release_wait_when_new_recorded_added(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) assert await asyncio.wait_for(task, timeout=2) assert info.addresses == [b"\x7f\x00\x00\x01"] await aiozc.async_close() @@ -1297,9 +1210,7 @@ async def test_port_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1315,9 +1226,7 @@ async def test_port_changes_are_seen(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1384,9 +1293,7 @@ async def test_port_changes_are_seen_with_directed_request(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1402,9 +1309,7 @@ async def test_port_changes_are_seen_with_directed_request(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name, 80, 10, 10, desc, host) await info.async_request(aiozc.zeroconf, timeout=200, addr="127.0.0.1", port=5353) @@ -1470,9 +1375,7 @@ async def test_ipv4_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] @@ -1488,9 +1391,7 @@ async def test_ipv4_changes_are_seen(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) @@ -1562,9 +1463,7 @@ async def test_ipv6_changes_are_seen(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V6Only) == [ @@ -1582,9 +1481,7 @@ async def test_ipv6_changes_are_seen(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) @@ -1644,15 +1541,11 @@ async def test_bad_ip_addresses_ignored_in_cache(): 0, ) # Manually add a bad record to the cache - aiozc.zeroconf.cache.async_add_records( - [DNSAddress(host, const._TYPE_A, const._CLASS_IN, 10000, b"\x00")] - ) + aiozc.zeroconf.cache.async_add_records([DNSAddress(host, const._TYPE_A, const._CLASS_IN, 10000, b"\x00")]) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] @@ -1711,9 +1604,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1733,9 +1624,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1787,9 +1676,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): ) await aiozc.zeroconf.async_wait_for_start() await asyncio.sleep(0) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1819,9 +1706,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): ), 0, ) - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) info = ServiceInfo(type_, registration_name) await info.async_request(aiozc.zeroconf, timeout=200) @@ -1843,10 +1728,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): # New kwarg way info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) - tasks = [ - asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200000)) - for _ in range(10) - ] + tasks = [asyncio.create_task(info.async_request(aiozc.zeroconf, timeout=200000)) for _ in range(10)] await asyncio.sleep(0.1) for task in tasks: assert not task.done() @@ -1898,9 +1780,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): await asyncio.sleep(0) for task in tasks: assert not task.done() - aiozc.zeroconf.record_manager.async_updates_from_response( - r.DNSIncoming(generated.packets()[0]) - ) + aiozc.zeroconf.record_manager.async_updates_from_response(r.DNSIncoming(generated.packets()[0])) _, pending = await asyncio.wait(tasks, timeout=2) assert not pending assert info.addresses == [b"\x7f\x00\x00\x01"] diff --git a/tests/services/test_types.py b/tests/services/test_types.py index d9340283a..f50ea42c1 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -112,9 +112,7 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find( - ip_version=r.IPVersion.V6Only, timeout=2 - ) + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar) service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 053ed26bb..a765a50a4 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -120,12 +120,7 @@ async def test_sync_within_event_loop_executor() -> None: def sync_code(): zc = Zeroconf(interfaces=["127.0.0.1"]) - assert ( - zc.get_service_info( - "_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10 - ) - is None - ) + assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None zc.close() await asyncio.get_event_loop().run_in_executor(None, sync_code) @@ -625,13 +620,9 @@ async def test_service_info_async_request() -> None: # Start a tasks BEFORE the registration that will keep trying # and see the registration a bit later - get_service_info_task1 = asyncio.ensure_future( - aiozc.async_get_service_info(type_, registration_name) - ) + get_service_info_task1 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) await asyncio.sleep(_LISTENER_TIME / 1000 / 2) - get_service_info_task2 = asyncio.ensure_future( - aiozc.async_get_service_info(type_, registration_name) - ) + get_service_info_task2 = asyncio.ensure_future(aiozc.async_get_service_info(type_, registration_name)) desc = {"path": "/~paulsm/"} info = ServiceInfo( @@ -916,14 +907,10 @@ async def test_async_zeroconf_service_types(): await asyncio.sleep(0.2) _clear_cache(zeroconf_registrar.zeroconf) try: - service_types = await AsyncZeroconfServiceTypes.async_find( - interfaces=["127.0.0.1"], timeout=2 - ) + service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=["127.0.0.1"], timeout=2) assert type_ in service_types _clear_cache(zeroconf_registrar.zeroconf) - service_types = await AsyncZeroconfServiceTypes.async_find( - aiozc=zeroconf_registrar, timeout=2 - ) + service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=2) assert type_ in service_types finally: @@ -935,9 +922,7 @@ async def test_guard_against_running_serviceinfo_request_event_loop() -> None: """Test that running ServiceInfo.request from the event loop throws.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) - service_info = AsyncServiceInfo( - "_hap._tcp.local.", "doesnotmatter._hap._tcp.local." - ) + service_info = AsyncServiceInfo("_hap._tcp.local.", "doesnotmatter._hap._tcp.local.") with pytest.raises(RuntimeError): service_info.request(aiozc.zeroconf, 3000) await aiozc.async_close() @@ -975,9 +960,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) zc.cache.async_add_records( [info.dns_pointer(), info.dns_service(), *info.dns_addresses(), info.dns_text()] ) @@ -1053,9 +1036,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): service_added = asyncio.Event() service_removed = asyncio.Event() - browser = AsyncServiceBrowser( - zeroconf_browser, type_, [on_service_state_change] - ) + browser = AsyncServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) info = ServiceInfo( type_, registration_name, @@ -1230,9 +1211,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) zc.cache.async_add_records( [ info.dns_pointer(), @@ -1303,12 +1282,7 @@ async def test_async_request_timeout(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() start_time = current_time_millis() - assert ( - await aiozc.async_get_service_info( - "_notfound.local.", "notthere._notfound.local." - ) - is None - ) + assert await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") is None end_time = current_time_millis() await aiozc.async_close() # 3000ms for the default timeout @@ -1322,9 +1296,7 @@ async def test_async_request_non_running_instance(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() with pytest.raises(NotRunningException): - await aiozc.async_get_service_info( - "_notfound.local.", "notthere._notfound.local." - ) + await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") @pytest.mark.asyncio diff --git a/tests/test_cache.py b/tests/test_cache.py index 4b3859bdf..363fcb0e6 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -256,10 +256,7 @@ def test_current_entry_with_name_and_alias(self): ) cache = r.DNSCache() cache.async_add_records([record1, record2]) - assert ( - cache.current_entry_with_name_and_alias("irrelevant", "x.irrelevant") - == record1 - ) + assert cache.current_entry_with_name_and_alias("irrelevant", "x.irrelevant") == record1 def test_name(self): record1 = r.DNSService( diff --git a/tests/test_core.py b/tests/test_core.py index 10545357b..fc2685fa5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -93,9 +93,7 @@ def test_close_multiple_times(self): def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() - rv = r.Zeroconf( - interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All - ) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) rv.close() @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @@ -103,21 +101,15 @@ def test_launch_and_close_v4_v6(self): def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() - rv = r.Zeroconf( - interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only - ) + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) rv.close() - @unittest.skipIf( - sys.platform == "darwin", reason="apple_p2p failure path not testable on mac" - ) + @unittest.skipIf(sys.platform == "darwin", reason="apple_p2p failure path not testable on mac") def test_launch_and_close_apple_p2p_not_mac(self): with pytest.raises(RuntimeError): r.Zeroconf(apple_p2p=True) - @unittest.skipIf( - sys.platform != "darwin", reason="apple_p2p happy path only testable on mac" - ) + @unittest.skipIf(sys.platform != "darwin", reason="apple_p2p happy path only testable on mac") def test_launch_and_close_apple_p2p_on_mac(self): rv = r.Zeroconf(apple_p2p=True) rv.close() @@ -146,9 +138,7 @@ def mock_incoming_msg( ttl = 0 generated.add_answer_at_time( - r.DNSPointer( - service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name - ), + r.DNSPointer(service_type, const._TYPE_PTR, const._CLASS_IN, ttl, service_name), 0, ) generated.add_answer_at_time( @@ -229,16 +219,10 @@ def mock_split_incoming_msg( try: # service added _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Added)) - dns_text = zeroconf.cache.get_by_details( - service_name, const._TYPE_TXT, const._CLASS_IN - ) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None - assert ( - cast(r.DNSText, dns_text).text == service_text - ) # service_text is b'path=/~paulsm/' - all_dns_text = zeroconf.cache.get_all_by_details( - service_name, const._TYPE_TXT, const._CLASS_IN - ) + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~paulsm/' + all_dns_text = zeroconf.cache.get_all_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert [dns_text] == all_dns_text # https://tools.ietf.org/html/rfc6762#section-10.2 @@ -252,35 +236,23 @@ def mock_split_incoming_msg( # service updated. currently only text record can be updated service_text = b"path=/~humingchun/" _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Updated)) - dns_text = zeroconf.cache.get_by_details( - service_name, const._TYPE_TXT, const._CLASS_IN - ) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None - assert ( - cast(r.DNSText, dns_text).text == service_text - ) # service_text is b'path=/~humingchun/' + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' time.sleep(1.1) # The split message only has a SRV and A record. # This should not evict TXT records from the cache - _inject_response( - zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated) - ) + _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) time.sleep(1.1) - dns_text = zeroconf.cache.get_by_details( - service_name, const._TYPE_TXT, const._CLASS_IN - ) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None - assert ( - cast(r.DNSText, dns_text).text == service_text - ) # service_text is b'path=/~humingchun/' + assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' # service removed _inject_response(zeroconf, mock_incoming_msg(r.ServiceStateChange.Removed)) - dns_text = zeroconf.cache.get_by_details( - service_name, const._TYPE_TXT, const._CLASS_IN - ) + dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None assert dns_text.is_expired(current_time_millis() + 1000) @@ -450,12 +422,7 @@ def test_logging_packets(caplog): def test_get_service_info_failure_path(): """Verify get_service_info return None when the underlying call returns False.""" zc = Zeroconf(interfaces=["127.0.0.1"]) - assert ( - zc.get_service_info( - "_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10 - ) - is None - ) + assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None zc.close() @@ -471,9 +438,7 @@ def test_sending_unicast(): b"path=/~paulsm/", ) generated.add_answer_at_time(entry, 0) - zc.send( - generated, "2001:db8::1", const._MDNS_PORT - ) # https://www.iana.org/go/rfc3849 + zc.send(generated, "2001:db8::1", const._MDNS_PORT) # https://www.iana.org/go/rfc3849 time.sleep(0.2) assert zc.cache.get(entry) is None @@ -783,9 +748,7 @@ def _background_register(): @pytest.mark.asyncio -@unittest.skipIf( - sys.version_info[:3][1] < 8, "Requires Python 3.8 or later to patch _async_setup" -) +@unittest.skipIf(sys.version_info[:3][1] < 8, "Requires Python 3.8 or later to patch _async_setup") @patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): diff --git a/tests/test_dns.py b/tests/test_dns.py index b4ac6f886..95d4b5532 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -48,9 +48,7 @@ def test_dns_hinfo_repr_eq(self): repr(hinfo) def test_dns_pointer_repr(self): - pointer = r.DNSPointer( - "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" - ) + pointer = r.DNSPointer("irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123") repr(pointer) @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @@ -78,9 +76,7 @@ def test_dns_address_repr(self): assert repr(address_ipv6).endswith("::1") def test_dns_question_repr(self): - question = r.DNSQuestion( - "irrelevant", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE - ) + question = r.DNSQuestion("irrelevant", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE) repr(question) assert not question != question @@ -98,9 +94,7 @@ def test_dns_service_repr(self): repr(service) def test_dns_record_abc(self): - record = r.DNSRecord( - "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL - ) + record = r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL) self.assertRaises(r.AbstractMethodException, record.__eq__, record) with pytest.raises((r.AbstractMethodException, TypeError)): record.write(None) # type: ignore[arg-type] @@ -225,12 +219,8 @@ def test_dns_record_hashablity_does_not_consider_ttl(): """Test DNSRecord are hashable.""" # Verify the TTL is not considered in the hash - record1 = r.DNSAddress( - "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same" - ) - record2 = r.DNSAddress( - "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same" - ) + record1 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same") + record2 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same") record_set = {record1, record2} assert len(record_set) == 1 @@ -238,9 +228,7 @@ def test_dns_record_hashablity_does_not_consider_ttl(): record_set.add(record1) assert len(record_set) == 1 - record3_dupe = r.DNSAddress( - "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same" - ) + record3_dupe = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same") assert record2 == record3_dupe assert record2.__hash__() == record3_dupe.__hash__() @@ -259,9 +247,7 @@ def test_dns_record_hashablity_does_not_consider_unique(): const._DNS_OTHER_TTL, b"same", ) - record2 = r.DNSAddress( - "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same" - ) + record2 = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_OTHER_TTL, b"same") assert record1.class_ == record2.class_ assert record1.__hash__() == record2.__hash__() @@ -314,12 +300,8 @@ def test_dns_hinfo_record_hashablity(): def test_dns_pointer_record_hashablity(): """Test DNSPointer are hashable.""" - ptr1 = r.DNSPointer( - "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" - ) - ptr2 = r.DNSPointer( - "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456" - ) + ptr1 = r.DNSPointer("irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123") + ptr2 = r.DNSPointer("irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456") record_set = {ptr1, ptr2} assert len(record_set) == 2 @@ -327,9 +309,7 @@ def test_dns_pointer_record_hashablity(): record_set.add(ptr1) assert len(record_set) == 2 - ptr2_dupe = r.DNSPointer( - "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456" - ) + ptr2_dupe = r.DNSPointer("irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "456") assert ptr2 == ptr2 assert ptr2.__hash__() == ptr2_dupe.__hash__() @@ -339,9 +319,7 @@ def test_dns_pointer_record_hashablity(): def test_dns_pointer_comparison_is_case_insensitive(): """Test DNSPointer comparison is case insensitive.""" - ptr1 = r.DNSPointer( - "irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123" - ) + ptr1 = r.DNSPointer("irrelevant", const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, "123") ptr2 = r.DNSPointer( "irrelevant".upper(), const._TYPE_PTR, @@ -530,12 +508,8 @@ def test_rrset_does_not_consider_ttl(): longarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 100, b"same") shortarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 10, b"same") - longaaaarec = r.DNSAddress( - "irrelevant", const._TYPE_AAAA, const._CLASS_IN, 100, b"same" - ) - shortaaaarec = r.DNSAddress( - "irrelevant", const._TYPE_AAAA, const._CLASS_IN, 10, b"same" - ) + longaaaarec = r.DNSAddress("irrelevant", const._TYPE_AAAA, const._CLASS_IN, 100, b"same") + shortaaaarec = r.DNSAddress("irrelevant", const._TYPE_AAAA, const._CLASS_IN, 10, b"same") rrset = DNSRRSet([longarec, shortaaaarec]) @@ -544,9 +518,7 @@ def test_rrset_does_not_consider_ttl(): assert not rrset.suppresses(longaaaarec) assert rrset.suppresses(shortaaaarec) - verylongarec = r.DNSAddress( - "irrelevant", const._TYPE_A, const._CLASS_IN, 1000, b"same" - ) + verylongarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 1000, b"same") longarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 100, b"same") mediumarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 60, b"same") shortarec = r.DNSAddress("irrelevant", const._TYPE_A, const._CLASS_IN, 10, b"same") diff --git a/tests/test_engine.py b/tests/test_engine.py index 7a10b48d3..88307e320 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -38,15 +38,9 @@ async def test_reaper(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf = aiozc.zeroconf cache = zeroconf.cache - original_entries = list( - itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) - ) - record_with_10s_ttl = r.DNSAddress( - "a", const._TYPE_SOA, const._CLASS_IN, 10, b"a" - ) - record_with_1s_ttl = r.DNSAddress( - "a", const._TYPE_SOA, const._CLASS_IN, 1, b"b" - ) + original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) + record_with_10s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 10, b"a") + record_with_1s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() @@ -59,22 +53,14 @@ async def test_reaper(): "known-to-other._hap._tcp.local.", ) } - zeroconf.question_history.add_question_at_time( - question, now, other_known_answers - ) + zeroconf.question_history.add_question_at_time(question, now, other_known_answers) assert zeroconf.question_history.suppresses(question, now, other_known_answers) - entries_with_cache = list( - itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) - ) + entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) await asyncio.sleep(1.2) - entries = list( - itertools.chain(*(cache.entries_with_name(name) for name in cache.names())) - ) + entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) assert zeroconf.cache.get(record_with_1s_ttl) is None await aiozc.async_close() - assert not zeroconf.question_history.suppresses( - question, now, other_known_answers - ) + assert not zeroconf.question_history.suppresses(question, now, other_known_answers) assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries @@ -87,12 +73,8 @@ async def test_reaper_aborts_when_done(): with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf = aiozc.zeroconf - record_with_10s_ttl = r.DNSAddress( - "a", const._TYPE_SOA, const._CLASS_IN, 10, b"a" - ) - record_with_1s_ttl = r.DNSAddress( - "a", const._TYPE_SOA, const._CLASS_IN, 1, b"b" - ) + record_with_10s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 10, b"a") + record_with_1s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) assert zeroconf.cache.get(record_with_10s_ttl) is not None assert zeroconf.cache.get(record_with_1s_ttl) is not None diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 33eac2d4d..1373d6c38 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -38,9 +38,7 @@ def tearDownClass(cls): del cls.browser def test_bad_service_info_name(self): - self.assertRaises( - r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not" - ) + self.assertRaises(r.BadTypeInNameException, self.browser.get_service_info, "type", "type_not") def test_bad_service_names(self): bad_names_to_try = ( @@ -85,9 +83,7 @@ def test_good_instance_names(self): assert r.service_type_name("1.2.3._mqtt._tcp.local.") == "_mqtt._tcp.local." assert r.service_type_name("x.sub._http._tcp.local.") == "_http._tcp.local." assert ( - r.service_type_name( - "6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local." - ) + r.service_type_name("6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.") == "_http._tcp.local." ) @@ -143,10 +139,7 @@ def test_good_service_names(self): for name, result in good_names_to_try: assert r.service_type_name(name) == result - assert ( - r.service_type_name("_one_two._tcp.local.", strict=False) - == "_one_two._tcp.local." - ) + assert r.service_type_name("_one_two._tcp.local.", strict=False) == "_one_two._tcp.local." def test_invalid_addresses(self): type_ = "_test-srvc-type._tcp.local." diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e2e69aea0..50816d2b7 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -77,7 +77,7 @@ def _process_outgoing_packet(out): """Sends an outgoing packet.""" nonlocal nbr_answers, nbr_additionals, nbr_authorities - for answer, time_ in out.answers: + for answer, _ in out.answers: nbr_answers += 1 assert answer.ttl == get_ttl(answer.type) for answer in out.additionals: @@ -103,16 +103,12 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question( - r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN) - ) + query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) assert question_answers - _process_outgoing_packet( - construct_outgoing_multicast_answers(question_answers.mcast_aggregate) - ) + _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # The additonals should all be suppresed since they are all in the answers section # There will be one NSEC additional to indicate the lack of AAAA record @@ -146,16 +142,12 @@ def _process_outgoing_packet(out): query.add_question(r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_SRV, const._CLASS_IN)) query.add_question(r.DNSQuestion(info.name, const._TYPE_TXT, const._CLASS_IN)) - query.add_question( - r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN) - ) + query.add_question(r.DNSQuestion(info.server or info.name, const._TYPE_A, const._CLASS_IN)) question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False ) assert question_answers - _process_outgoing_packet( - construct_outgoing_multicast_answers(question_answers.mcast_aggregate) - ) + _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) # There will be one NSEC additional to indicate the lack of AAAA record assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 @@ -315,9 +307,7 @@ def test_any_query_for_ptr(): desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) zc.registry.async_add(info) _clear_cache(zc) @@ -325,9 +315,7 @@ def test_any_query_for_ptr(): question = r.DNSQuestion(type_, const._TYPE_ANY, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers mcast_answers = list(question_answers.mcast_aggregate) assert mcast_answers[0].name == type_ @@ -348,18 +336,14 @@ def test_aaaa_query(): desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) zc.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] @@ -379,18 +363,14 @@ def test_aaaa_query_upper_case(): desc = {"path": "/~paulsm/"} server_name = "ash-2.local." ipv6_address = socket.inet_pton(socket.AF_INET6, "2001:db8::1") - info = ServiceInfo( - type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address] - ) + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, server_name, addresses=[ipv6_address]) zc.registry.async_add(info) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name.upper(), const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers mcast_answers = list(question_answers.mcast_now) assert mcast_answers[0].address == ipv6_address # type: ignore[attr-defined] @@ -431,9 +411,7 @@ def test_a_and_aaaa_record_fate_sharing(): question = r.DNSQuestion(server_name, const._TYPE_AAAA, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert aaaa_record in question_answers.mcast_now @@ -446,9 +424,7 @@ def test_a_and_aaaa_record_fate_sharing(): question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers additionals = set().union(*question_answers.mcast_now.values()) assert a_record in question_answers.mcast_now @@ -709,7 +685,8 @@ def _validate_complete_response(answers): assert not question_answers.mcast_aggregate _validate_complete_response(question_answers.mcast_now) - # With QU set and an authorative answer (probe) should respond to both unitcast and multicast since the response hasn't been seen since 75% of the ttl + # With QU set and an authorative answer (probe) should respond to both unitcast + # and multicast since the response hasn't been seen since 75% of the ttl query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) question.unicast = True # Set the QU bit @@ -725,11 +702,7 @@ def _validate_complete_response(answers): _inject_response( zc, - r.DNSIncoming( - construct_outgoing_multicast_answers(question_answers.mcast_now).packets()[ - 0 - ] - ), + r.DNSIncoming(construct_outgoing_multicast_answers(question_answers.mcast_now).packets()[0]), ) # With the cache repopulated; should respond to only unicast when the answer has been recently multicast query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -776,9 +749,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -790,9 +761,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_pointer(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -804,9 +773,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert question_answers.mcast_now @@ -819,9 +786,7 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -835,12 +800,10 @@ def test_known_answer_supression(): for dns_address in info.dns_addresses(): generated.add_answer_at_time(dns_address, now) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast - expected_nsec_record = cast(r.DNSNsec, list(question_answers.mcast_now)[0]) + expected_nsec_record = cast(r.DNSNsec, next(iter(question_answers.mcast_now))) assert const._TYPE_A not in expected_nsec_record.rdtypes assert const._TYPE_AAAA in expected_nsec_record.rdtypes assert not question_answers.mcast_aggregate @@ -851,9 +814,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert question_answers.mcast_now @@ -865,9 +826,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_service(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -879,9 +838,7 @@ def test_known_answer_supression(): question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -893,9 +850,7 @@ def test_known_answer_supression(): generated.add_question(question) generated.add_answer_at_time(info.dns_text(), now) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -970,9 +925,7 @@ def test_multi_packet_known_answer_supression(): generated.add_answer_at_time(info3.dns_pointer(), now) packets = generated.packets() assert len(packets) > 1 - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1025,14 +978,10 @@ def test_known_answer_supression_service_type_enumeration_query(): # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion( - const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN - ) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1040,9 +989,7 @@ def test_known_answer_supression_service_type_enumeration_query(): assert not question_answers.mcast_aggregate_last_second generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion( - const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN - ) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) generated.add_answer_at_time( r.DNSPointer( @@ -1065,9 +1012,7 @@ def test_known_answer_supression_service_type_enumeration_query(): now, ) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1119,14 +1064,10 @@ def test_upper_case_enumeration_query(): # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion( - const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN - ) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1142,14 +1083,10 @@ def test_enumeration_query_with_no_registered_services(): zc = Zeroconf(interfaces=["127.0.0.1"]) _clear_cache(zc) generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - question = r.DNSQuestion( - const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN - ) + question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert not question_answers # unregister zc.close() @@ -1205,9 +1142,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Add the A record to the cache with 50% ttl remaining a_record = info.dns_addresses()[0] - a_record.set_created_ttl( - current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl - ) + a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) assert not a_record.is_recent(current_time_millis()) info._dns_address_cache = None # we are mutating the record so clear the cache zc.cache.async_add_records([a_record]) @@ -1258,9 +1193,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Remove the 100% PTR record and add a 50% PTR record zc.cache.async_remove_records([ptr_record]) - ptr_record.set_created_ttl( - current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl - ) + ptr_record.set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) assert not ptr_record.is_recent(current_time_millis()) zc.cache.async_add_records([ptr_record]) # With QU should respond to only multicast since the has less @@ -1285,7 +1218,8 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): assert ptr_record in question_answers.mcast_now # Ask 2 QU questions, with info the PTR is at 50%, with info2 the PTR is at 100% - # We should get back a unicast reply for info2, but info should be multicasted since its within 75% of its TTL + # We should get back a unicast reply for info2, but info should be + # multicasted since its within 75% of its TTL # With QU should respond to only multicast since the has less # than 75% of its ttl remaining query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -1298,9 +1232,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): question.unicast = True # Set the QU bit assert question.unicast is True query.add_question(question) - zc.cache.async_add_records( - [info2.dns_pointer()] - ) # Add 100% TTL for info2 to the cache + zc.cache.async_add_records([info2.dns_pointer()]) # Add 100% TTL for info2 to the cache question_answers = zc.query_handler.async_response( [r.DNSIncoming(packet) for packet in query.packets()], False @@ -1351,9 +1283,7 @@ async def test_cache_flush_bit(): addresses=[socket.inet_aton("10.0.1.2")], ) a_record = info.dns_addresses()[0] - zc.cache.async_add_records( - [info.dns_pointer(), a_record, info.dns_text(), info.dns_service()] - ) + zc.cache.async_add_records([info.dns_pointer(), a_record, info.dns_text(), info.dns_service()]) info.addresses = [socket.inet_aton("10.0.1.5"), socket.inet_aton("10.0.1.6")] new_records = info.dns_addresses() @@ -1402,9 +1332,7 @@ async def test_cache_flush_bit(): assert cached_record is not None assert cached_record.ttl == 1 - for entry in zc.cache.async_all_by_details( - server_name, const._TYPE_A, const._CLASS_IN - ): + for entry in zc.cache.async_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): assert isinstance(entry, r.DNSAddress) if entry.address == fresh_address: assert entry.ttl > 1 @@ -1434,9 +1362,7 @@ async def test_record_update_manager_add_listener_callsback_existing_records(): class MyListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records( - self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -1457,9 +1383,7 @@ def async_update_records( ) a_record = info.dns_addresses()[0] ptr_record = info.dns_pointer() - zc.cache.async_add_records( - [ptr_record, a_record, info.dns_text(), info.dns_service()] - ) + zc.cache.async_add_records([ptr_record, a_record, info.dns_text(), info.dns_service()]) listener = MyListener() @@ -1516,9 +1440,7 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert not question_answers.ucast assert not question_answers.mcast_now @@ -1560,9 +1482,7 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): generated.add_answer_at_time(known_answer, 0) now = r.current_time_millis() packets = generated.packets() - question_answers = zc.query_handler.async_response( - [r.DNSIncoming(packet) for packet in packets], False - ) + question_answers = zc.query_handler.async_response([r.DNSIncoming(packet) for packet in packets], False) assert question_answers assert "qu._hap._tcp.local." in str(question_answers) assert not question_answers.ucast # has not multicast recently @@ -1576,7 +1496,7 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): @pytest.mark.asyncio async def test_guard_against_low_ptr_ttl(): - """Ensure we enforce a minimum for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. + """Ensure we enforce a min for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. Some poorly designed IoT devices can set excessively low PTR TTLs would will cause ServiceBrowsers to flood the network @@ -1780,9 +1700,7 @@ async def test_response_aggregation_timings(run_isolated): @pytest.mark.asyncio -async def test_response_aggregation_timings_multiple( - run_isolated, disable_duplicate_packet_suppression -): +async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): """Verify multicast responses that are aggregated do not take longer than 620ms to send. 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" @@ -1817,9 +1735,7 @@ async def test_response_aggregation_timings_multiple( with patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = ( - 0 # manually reset the last time to avoid duplicate packet suppression - ) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression await asyncio.sleep(0.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1830,9 +1746,7 @@ async def test_response_aggregation_timings_multiple( send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = ( - 0 # manually reset the last time to avoid duplicate packet suppression - ) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression await asyncio.sleep(1.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1843,13 +1757,9 @@ async def test_response_aggregation_timings_multiple( send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = ( - 0 # manually reset the last time to avoid duplicate packet suppression - ) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = ( - 0 # manually reset the last time to avoid duplicate packet suppression - ) + protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression # The delay should increase with two packets and # 900ms is beyond the maximum aggregation delay # when there is no network protection delay @@ -1958,9 +1868,7 @@ async def test_response_aggregation_random_delay(): # The third group should always be coalesced into first group since it will always come before outgoing_queue._multicast_delay_random_min = 100 outgoing_queue._multicast_delay_random_max = 200 - outgoing_queue.async_add( - now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()} - ) + outgoing_queue.async_add(now, {info3.dns_pointer(): set(), info4.dns_pointer(): set()}) assert len(outgoing_queue.queue) == 1 assert info.dns_pointer() in outgoing_queue.queue[0].answers @@ -2056,17 +1964,14 @@ async def test_add_listener_warns_when_not_using_record_update_listener(caplog): class MyListener: """A RecordUpdateListener that does not implement update_records.""" - def async_update_records( - self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.add_listener(MyListener(), None) # type: ignore[arg-type] await asyncio.sleep(0) # flush out any call soons assert ( - "listeners passed to async_add_listener must inherit from RecordUpdateListener" - in caplog.text + "listeners passed to async_add_listener must inherit from RecordUpdateListener" in caplog.text or "TypeError: Argument 'listener' has incorrect type" in caplog.text ) @@ -2091,9 +1996,7 @@ async def test_async_updates_iteration_safe(): class OtherListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records( - self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -2102,9 +2005,7 @@ def async_update_records( class ListenerThatAddsListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records( - self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate] - ) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.async_add_listener(other, None) diff --git a/tests/test_init.py b/tests/test_init.py index 3ba285d5e..d7a012245 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -173,9 +173,7 @@ def generate_many_hosts(self, zc, type_, name, number_hosts): def generate_host(out, host_name, type_): name = ".".join((host_name, type_)) out.add_answer_at_time( - r.DNSPointer( - type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name - ), + r.DNSPointer(type_, const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, name), 0, ) out.add_answer_at_time( diff --git a/tests/test_listener.py b/tests/test_listener.py index 6faab4e80..f6752af78 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -47,7 +47,7 @@ def test_guard_against_oversized_packets(): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for i in range(5000): + for _i in range(5000): generated.add_answer_at_time( r.DNSText( "packet{i}.local.", @@ -281,9 +281,7 @@ def handle_query_or_defer( _handle_query_or_defer.reset_mock() # Now call with garbage - listener._process_datagram_at_time( - False, len(b"garbage"), new_time, b"garbage", addrs - ) + listener._process_datagram_at_time(False, len(b"garbage"), new_time, b"garbage", addrs) _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() diff --git a/tests/test_protocol.py b/tests/test_protocol.py index e682a34c8..ee9ed9300 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -49,9 +49,7 @@ def test_parse_own_packet_flags(self): def test_parse_own_packet_question(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) - generated.add_question( - r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) - ) + generated.add_question(r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN)) r.DNSIncoming(generated.packets()[0]) def test_parse_own_packet_nsec(self): @@ -252,18 +250,14 @@ def test_suppress_answer(self): def test_dns_hinfo(self): generated = r.DNSOutgoing(0) - generated.add_additional_answer( - DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "os") - ) + generated.add_additional_answer(DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "os")) parsed = r.DNSIncoming(generated.packets()[0]) answer = cast(r.DNSHinfo, parsed.answers()[0]) assert answer.cpu == "cpu" assert answer.os == "os" generated = r.DNSOutgoing(0) - generated.add_additional_answer( - DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "x" * 257) - ) + generated.add_additional_answer(DNSHinfo("irrelevant", const._TYPE_HINFO, 0, 0, "cpu", "x" * 257)) self.assertRaises(r.NamePartTooLongException, generated.packets) def test_many_questions(self): @@ -271,9 +265,7 @@ def test_many_questions(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for i in range(100): - question = r.DNSQuestion( - f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN - ) + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) questions.append(question) assert len(generated.questions) == 100 @@ -293,9 +285,7 @@ def test_many_questions_with_many_known_answers(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for _ in range(30): - question = r.DNSQuestion( - "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN - ) + question = r.DNSQuestion("_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) questions.append(question) assert len(generated.questions) == 30 @@ -378,7 +368,7 @@ def test_only_one_answer_can_by_large(self): """ generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) query = r.DNSIncoming(r.DNSOutgoing(const._FLAGS_QR_QUERY).packets()[0]) - for i in range(3): + for _i in range(3): generated.add_answer( query, r.DNSText( @@ -433,9 +423,7 @@ def test_questions_do_not_end_up_every_packet(self): generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) for i in range(35): - question = r.DNSQuestion( - f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN - ) + question = r.DNSQuestion(f"testname{i}.local.", const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) answer = r.DNSService( f"testname{i}.local.", @@ -494,9 +482,7 @@ def test_response_header_bits(self): def test_numbers(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack( - "!4H", bytes[4:12] - ) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack("!4H", bytes[4:12]) assert num_questions == 0 assert num_answers == 0 assert num_authorities == 0 @@ -505,12 +491,10 @@ def test_numbers(self): def test_numbers_questions(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) question = r.DNSQuestion("testname.local.", const._TYPE_SRV, const._CLASS_IN) - for i in range(10): + for _i in range(10): generated.add_question(question) bytes = generated.packets()[0] - (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack( - "!4H", bytes[4:12] - ) + (num_questions, num_answers, num_authorities, num_additionals) = struct.unpack("!4H", bytes[4:12]) assert num_questions == 10 assert num_answers == 0 assert num_authorities == 0 @@ -551,9 +535,7 @@ def test_incoming_ipv6(self): addr = "2606:2800:220:1:248:1893:25c8:1946" # example.com packed = socket.inet_pton(socket.AF_INET6, addr) generated = r.DNSOutgoing(0) - answer = r.DNSAddress( - "domain", const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed - ) + answer = r.DNSAddress("domain", const._TYPE_AAAA, const._CLASS_IN | const._CLASS_UNIQUE, 1, packed) generated.add_additional_answer(answer) packet = generated.packets()[0] parsed = r.DNSIncoming(packet) @@ -715,9 +697,7 @@ def test_dns_compression_rollback_for_corruption(): assert incoming.valid is True assert ( len(incoming.answers()) - == incoming.num_answers - + incoming.num_authorities - + incoming.num_additionals + == incoming.num_answers + incoming.num_authorities + incoming.num_additionals ) @@ -788,16 +768,18 @@ def test_tc_bit_not_set_in_answer_packet(): assert third_packet.valid is True -# 4003 15.973052 192.168.107.68 224.0.0.251 MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question +# MDNS 76 Standard query 0xffc4 PTR _raop._tcp.local, "QM" question def test_qm_packet_parser(): """Test we can parse a query packet with the QM bit.""" - qm_packet = b"\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01" + qm_packet = ( + b"\xff\xc4\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x05_raop\x04_tcp\x05local\x00\x00\x0c\x00\x01" + ) parsed = DNSIncoming(qm_packet) assert parsed.questions[0].unicast is False assert ",QM," in str(parsed.questions[0]) -# 389951 1450.577370 192.168.107.111 224.0.0.251 MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT +# MDNS 115 Standard query 0x0000 PTR _companion-link._tcp.local, "QU" question OPT def test_qu_packet_parser(): """Test we can parse a query packet with the QU bit.""" qu_packet = ( diff --git a/tests/test_services.py b/tests/test_services.py index 8145ae60f..7cc075e78 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -9,7 +9,8 @@ import time import unittest from threading import Event -from typing import Dict, Any +from typing import Any, Dict + import pytest import zeroconf as r @@ -83,14 +84,14 @@ def update_service(self, zeroconf, type, name): zeroconf_browser = Zeroconf(interfaces=["127.0.0.1"]) zeroconf_browser.add_service_listener(type_, listener) - properties = dict( - prop_none=None, - prop_string=b"a_prop", - prop_float=1.0, - prop_blank=b"a blanked string", - prop_true=1, - prop_false=0, - ) + properties = { + "prop_none": None, + "prop_string": b"a_prop", + "prop_float": 1.0, + "prop_blank": b"a blanked string", + "prop_true": 1, + "prop_false": 0, + } zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) desc: Dict[str, Any] = {"path": "/~paulsm/"} @@ -141,9 +142,7 @@ def update_service(self, zeroconf, type, name): assert info.decoded_properties["prop_none"] is None assert info.decoded_properties["prop_string"] == b"a_prop".decode("utf-8") assert info.decoded_properties["prop_float"] == "1.0" - assert info.decoded_properties["prop_blank"] == b"a blanked string".decode( - "utf-8" - ) + assert info.decoded_properties["prop_blank"] == b"a blanked string".decode("utf-8") assert info.decoded_properties["prop_true"] == "1" assert info.decoded_properties["prop_false"] == "0" @@ -207,17 +206,13 @@ def update_service(self, zeroconf, type, name): info = zeroconf_browser.get_service_info(type_, registration_name) assert info is not None assert info.properties[b"prop_blank"] == properties["prop_blank"] - assert info.decoded_properties["prop_blank"] == b"an updated string".decode( - "utf-8" - ) + assert info.decoded_properties["prop_blank"] == b"an updated string".decode("utf-8") cached_info = ServiceInfo(subtype, registration_name) cached_info.load_from_cache(zeroconf_browser) assert cached_info.properties is not None assert cached_info.properties[b"prop_blank"] == properties["prop_blank"] - assert cached_info.decoded_properties[ - "prop_blank" - ] == b"an updated string".decode("utf-8") + assert cached_info.decoded_properties["prop_blank"] == b"an updated string".decode("utf-8") zeroconf_registrar.unregister_service(info_service) service_removed.wait(1) diff --git a/tests/test_updates.py b/tests/test_updates.py index 4cffc0f69..2ebaee89d 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -40,9 +40,7 @@ def test_legacy_record_update_listener(): r.RecordUpdateListener().update_record( zc, 0, - r.DNSRecord( - "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL - ), + r.DNSRecord("irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL), ) updates = [] @@ -50,9 +48,7 @@ def test_legacy_record_update_listener(): class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def update_record( - self, zc: "Zeroconf", now: float, record: r.DNSRecord - ) -> None: + def update_record(self, zc: "Zeroconf", now: float, record: r.DNSRecord) -> None: nonlocal updates updates.append(record) @@ -87,15 +83,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() assert len(updates) - assert ( - len( - [ - isinstance(update, r.DNSPointer) and update.name == type_ - for update in updates - ] - ) - >= 1 - ) + assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 zc.remove_listener(listener) # Removing a second time should not throw @@ -106,12 +94,8 @@ def on_service_state_change(zeroconf, service_type, state_change, name): def test_record_update_compat(): """Test a RecordUpdate can fetch by index.""" - new = r.DNSPointer( - "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "new" - ) - old = r.DNSPointer( - "irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "old" - ) + new = r.DNSPointer("irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "new") + old = r.DNSPointer("irrelevant", const._TYPE_SRV, const._CLASS_IN, const._DNS_HOST_TTL, "old") update = RecordUpdate(new, old) assert update[0] == new assert update[1] == old diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index cf4b4e8e2..7b086fbc1 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -115,9 +115,7 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): raised if something goes wrong. """ assert ( - aioutils._TASK_AWAIT_TIMEOUT - + aioutils._GET_ALL_TASKS_TIMEOUT - + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT + aioutils._TASK_AWAIT_TIMEOUT + aioutils._GET_ALL_TASKS_TIMEOUT + aioutils._WAIT_FOR_LOOP_TASKS_TIMEOUT ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT @@ -136,9 +134,7 @@ async def _saved_sleep_task(): def _run_in_loop(): aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 0.1) - with pytest.raises(EventLoopBlocked), patch.object( - aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0 - ): + with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): await loop.run_in_executor(None, _run_in_loop) assert task is not None diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 4066eba4f..35803c7ea 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -16,11 +16,7 @@ def test_cached_ip_addresses_wrapper(): assert ipaddress.cached_ip_addresses("") is None assert ipaddress.cached_ip_addresses("foo") is None assert ( - str( - ipaddress.cached_ip_addresses( - b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F" - ) - ) + str(ipaddress.cached_ip_addresses(b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F")) == "2606:2800:220:1:248:1893:25c8:1946" ) assert ipaddress.cached_ip_addresses("::1") == ipaddress.IPv6Address("::1") @@ -75,9 +71,7 @@ def test_get_ip_address_object_from_record(): scope_id=3, ) assert record.scope_id == 3 - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( - "fe80::1%3" - ) + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address("fe80::1%3") record = DNSAddress( "domain.local", const._TYPE_AAAA, @@ -86,9 +80,7 @@ def test_get_ip_address_object_from_record(): packed, ) assert record.scope_id is None - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( - "fe80::1" - ) + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address("fe80::1") record = DNSAddress( "domain.local", const._TYPE_A, @@ -99,6 +91,4 @@ def test_get_ip_address_object_from_record(): ) assert record.scope_id == 0 # Ensure scope_id of 0 is not appended to the address - assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address( - "fe80::1" - ) + assert ipaddress.get_ip_address_object_from_record(record) == ipaddress.IPv6Address("fe80::1") diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index d4c57c40f..c814e094d 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -25,9 +25,7 @@ def test_service_type_name_overlong_full_name(): with pytest.raises(BadTypeInNameException): nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.") with pytest.raises(BadTypeInNameException): - nameutils.service_type_name( - f"{long_name}._tivo-videostream._tcp.local.", strict=False - ) + nameutils.service_type_name(f"{long_name}._tivo-videostream._tcp.local.", strict=False) @pytest.mark.parametrize( @@ -69,17 +67,13 @@ def test_possible_types(): assert nameutils.possible_types(".") == set() assert nameutils.possible_types("local.") == set() assert nameutils.possible_types("_tcp.local.") == set() - assert nameutils.possible_types("_test-srvc-type._tcp.local.") == { - "_test-srvc-type._tcp.local." - } + assert nameutils.possible_types("_test-srvc-type._tcp.local.") == {"_test-srvc-type._tcp.local."} assert nameutils.possible_types("_any._tcp.local.") == {"_any._tcp.local."} assert nameutils.possible_types(".._x._tcp.local.") == {"_x._tcp.local."} assert nameutils.possible_types("x.y._http._tcp.local.") == {"_http._tcp.local."} assert nameutils.possible_types("1.2.3._mqtt._tcp.local.") == {"_mqtt._tcp.local."} assert nameutils.possible_types("x.sub._http._tcp.local.") == {"_http._tcp.local."} - assert nameutils.possible_types( - "6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local." - ) == { + assert nameutils.possible_types("6d86f882b90facee9170ad3439d72a4d6ee9f511._zget._http._tcp.local.") == { "_http._tcp.local.", "_zget._http._tcp.local.", } diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 5a229b0d8..a89ea565c 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -71,18 +71,14 @@ def test_ip6_addresses_to_indexes(): "zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert netutils.ip6_addresses_to_indexes(interfaces) == [ - (("2001:db8::", 1, 1), 1) - ] + assert netutils.ip6_addresses_to_indexes(interfaces) == [(("2001:db8::", 1, 1), 1)] interfaces_2 = ["2001:db8::"] with patch( "zeroconf._utils.net.ifaddr.get_adapters", return_value=_generate_mock_adapters(), ): - assert netutils.ip6_addresses_to_indexes(interfaces_2) == [ - (("2001:db8::", 1, 1), 1) - ] + assert netutils.ip6_addresses_to_indexes(interfaces_2) == [(("2001:db8::", 1, 1), 1)] def test_normalize_interface_choice_errors(): @@ -108,9 +104,7 @@ def test_normalize_interface_choice_errors(): def test_add_multicast_member_socket_errors(errno, expected_result): """Test we handle socket errors when adding multicast members.""" if errno: - setsockopt_mock = unittest.mock.Mock( - side_effect=OSError(errno, f"Error: {errno}") - ) + setsockopt_mock = unittest.mock.Mock(side_effect=OSError(errno, f"Error: {errno}")) else: setsockopt_mock = unittest.mock.Mock() fileno_mock = unittest.mock.PropertyMock(return_value=10) @@ -146,18 +140,14 @@ def _log_error(*args): ) -@pytest.mark.skipif( - not hasattr(socket, "SO_REUSEPORT"), reason="System does not have SO_REUSEPORT" -) +@pytest.mark.skipif(not hasattr(socket, "SO_REUSEPORT"), reason="System does not have SO_REUSEPORT") def test_set_so_reuseport_if_available_is_present(): """Test that setting socket.SO_REUSEPORT only OSError errno.ENOPROTOOPT is trapped.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError): netutils.set_so_reuseport_if_available(sock) - with patch( - "socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None) - ): + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): netutils.set_so_reuseport_if_available(sock) @@ -170,30 +160,22 @@ def test_set_so_reuseport_if_available_not_present(): def test_set_mdns_port_socket_options_for_ip_version(): - """Test OSError with errno with EINVAL and bind address '' from setsockopt IP_MULTICAST_TTL does not raise.""" + """Test OSError with errno with EINVAL and bind address ''. + + from setsockopt IP_MULTICAST_TTL does not raise.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Should raise on EPERM always - with pytest.raises(OSError), patch( - "socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None) - ): - netutils.set_mdns_port_socket_options_for_ip_version( - sock, ("",), r.IPVersion.V4Only - ) + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) # Should raise on EINVAL always when bind address is not '' - with pytest.raises(OSError), patch( - "socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None) - ): - netutils.set_mdns_port_socket_options_for_ip_version( - sock, ("127.0.0.1",), r.IPVersion.V4Only - ) + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only) # Should not raise on EINVAL when bind address is '' with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version( - sock, ("",), r.IPVersion.V4Only - ) + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) def test_add_multicast_member(): @@ -201,9 +183,7 @@ def test_add_multicast_member(): interface = "127.0.0.1" # EPERM should always raise - with pytest.raises(OSError), patch( - "socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None) - ): + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): netutils.add_multicast_member(sock, interface) # EADDRINUSE should return False @@ -211,9 +191,7 @@ def test_add_multicast_member(): assert netutils.add_multicast_member(sock, interface) is False # EADDRNOTAVAIL should return False - with patch( - "socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None) - ): + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None)): assert netutils.add_multicast_member(sock, interface) is False # EINVAL should return False @@ -221,16 +199,12 @@ def test_add_multicast_member(): assert netutils.add_multicast_member(sock, interface) is False # ENOPROTOOPT should return False - with patch( - "socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None) - ): + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): assert netutils.add_multicast_member(sock, interface) is False # ENODEV should raise for ipv4 - with pytest.raises(OSError), patch( - "socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None) - ): - netutils.add_multicast_member(sock, interface) is False + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): + assert netutils.add_multicast_member(sock, interface) is False # ENODEV should return False for ipv6 with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): From 2ca71027fd8d3a92f44874e0945029e206d986e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 15:39:53 -1000 Subject: [PATCH 1096/1433] chore: bump cython to 3.0.11 (#1402) --- poetry.lock | 128 ++++++++++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 60 deletions(-) diff --git a/poetry.lock b/poetry.lock index af80c2260..a79e019b3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -111,69 +111,77 @@ toml = ["tomli"] [[package]] name = "cython" -version = "3.0.8" +version = "3.0.11" description = "The Cython compiler for writing C extensions in the Python language." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" files = [ - {file = "Cython-3.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a846e0a38e2b24e9a5c5dc74b0e54c6e29420d88d1dafabc99e0fc0f3e338636"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45523fdc2b78d79b32834cc1cc12dc2ca8967af87e22a3ee1bff20e77c7f5520"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa0b7f3f841fe087410cab66778e2d3fb20ae2d2078a2be3dffe66c6574be39"}, - {file = "Cython-3.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e87294e33e40c289c77a135f491cd721bd089f193f956f7b8ed5aa2d0b8c558f"}, - {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a1df7a129344b1215c20096d33c00193437df1a8fcca25b71f17c23b1a44f782"}, - {file = "Cython-3.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:13c2a5e57a0358da467d97667297bf820b62a1a87ae47c5f87938b9bb593acbd"}, - {file = "Cython-3.0.8-cp310-cp310-win32.whl", hash = "sha256:96b028f044f5880e3cb18ecdcfc6c8d3ce9d0af28418d5ab464509f26d8adf12"}, - {file = "Cython-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:8140597a8b5cc4f119a1190f5a2228a84f5ca6d8d9ec386cfce24663f48b2539"}, - {file = "Cython-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aae26f9663e50caf9657148403d9874eea41770ecdd6caf381d177c2b1bb82ba"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:547eb3cdb2f8c6f48e6865d5a741d9dd051c25b3ce076fbca571727977b28ac3"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a567d4b9ba70b26db89d75b243529de9e649a2f56384287533cf91512705bee"}, - {file = "Cython-3.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d1426263b0e82fb22bda8ea60dc77a428581cc19e97741011b938445d383f1"}, - {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c26daaeccda072459b48d211415fd1e5507c06bcd976fa0d5b8b9f1063467d7b"}, - {file = "Cython-3.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:289ce7838208211cd166e975865fd73b0649bf118170b6cebaedfbdaf4a37795"}, - {file = "Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871"}, - {file = "Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02"}, - {file = "Cython-3.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d3fe31db55685d8cb97d43b0ec39ef614fcf660f83c77ed06aa670cb0e164f"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24791ddae2324e88e3c902a765595c738f19ae34ee66bfb1a6dac54b1833419"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f020fa1c0552052e0660790b8153b79e3fc9a15dbd8f1d0b841fe5d204a6ae6"}, - {file = "Cython-3.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18bfa387d7a7f77d7b2526af69a65dbd0b731b8d941aaff5becff8e21f6d7717"}, - {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fe81b339cffd87c0069c6049b4d33e28bdd1874625ee515785bf42c9fdff3658"}, - {file = "Cython-3.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80fd94c076e1e1b1ee40a309be03080b75f413e8997cddcf401a118879863388"}, - {file = "Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c"}, - {file = "Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a"}, - {file = "Cython-3.0.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:870d2a0a7e3cbd5efa65aecdb38d715ea337a904ea7bb22324036e78fb7068e7"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e8f2454128974905258d86534f4fd4f91d2f1343605657ecab779d80c9d6d5e"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1949d6aa7bc792554bee2b67a9fe41008acbfe22f4f8df7b6ec7b799613a4b3"}, - {file = "Cython-3.0.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9f2c6e1b8f3bcd6cb230bac1843f85114780bb8be8614855b1628b36bb510e0"}, - {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:05d7eddc668ae7993643f32c7661f25544e791edb745758672ea5b1a82ecffa6"}, - {file = "Cython-3.0.8-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bfabe115deef4ada5d23c87bddb11289123336dcc14347011832c07db616dd93"}, - {file = "Cython-3.0.8-cp36-cp36m-win32.whl", hash = "sha256:0c38c9f0bcce2df0c3347285863621be904ac6b64c5792d871130569d893efd7"}, - {file = "Cython-3.0.8-cp36-cp36m-win_amd64.whl", hash = "sha256:6c46939c3983217d140999de7c238c3141f56b1ea349e47ca49cae899969aa2c"}, - {file = "Cython-3.0.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:115f0a50f752da6c99941b103b5cb090da63eb206abbc7c2ad33856ffc73f064"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c0f29246734561c90f36e70ed0506b61aa3d044e4cc4cba559065a2a741fae"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ab75242869ff71e5665fe5c96f3378e79e792fa3c11762641b6c5afbbbbe026"}, - {file = "Cython-3.0.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6717c06e9cfc6c1df18543cd31a21f5d8e378a40f70c851fa2d34f0597037abc"}, - {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9d3f74388db378a3c6fd06e79a809ed98df3f56484d317b81ee762dbf3c263e0"}, - {file = "Cython-3.0.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ae7ac561fd8253a9ae96311e91d12af5f701383564edc11d6338a7b60b285a6f"}, - {file = "Cython-3.0.8-cp37-cp37m-win32.whl", hash = "sha256:97b2a45845b993304f1799664fa88da676ee19442b15fdcaa31f9da7e1acc434"}, - {file = "Cython-3.0.8-cp37-cp37m-win_amd64.whl", hash = "sha256:9e2be2b340fea46fb849d378f9b80d3c08ff2e81e2bfbcdb656e2e3cd8c6b2dc"}, - {file = "Cython-3.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2cde23c555470db3f149ede78b518e8274853745289c956a0e06ad8d982e4db9"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7990ca127e1f1beedaf8fc8bf66541d066ef4723ad7d8d47a7cbf842e0f47580"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b983c8e6803f016146c26854d9150ddad5662960c804ea7f0c752c9266752f0"}, - {file = "Cython-3.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a973268d7ca1a2bdf78575e459a94a78e1a0a9bb62a7db0c50041949a73b02ff"}, - {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:61a237bc9dd23c7faef0fcfce88c11c65d0c9bb73c74ccfa408b3a012073c20e"}, - {file = "Cython-3.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3a3d67f079598af49e90ff9655bf85bd358f093d727eb21ca2708f467c489cae"}, - {file = "Cython-3.0.8-cp38-cp38-win32.whl", hash = "sha256:17a642bb01a693e34c914106566f59844b4461665066613913463a719e0dd15d"}, - {file = "Cython-3.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:2cdfc32252f3b6dc7c94032ab744dcedb45286733443c294d8f909a4854e7f83"}, - {file = "Cython-3.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa97893d99385386925d00074654aeae3a98867f298d1e12ceaf38a9054a9bae"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05c0bf9d085c031df8f583f0d506aa3be1692023de18c45d0aaf78685bbb944"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de892422582f5758bd8de187e98ac829330ec1007bc42c661f687792999988a7"}, - {file = "Cython-3.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:314f2355a1f1d06e3c431eaad4708cf10037b5e91e4b231d89c913989d0bdafd"}, - {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:78825a3774211e7d5089730f00cdf7f473042acc9ceb8b9eeebe13ed3a5541de"}, - {file = "Cython-3.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:df8093deabc55f37028190cf5e575c26aad23fc673f34b85d5f45076bc37ce39"}, - {file = "Cython-3.0.8-cp39-cp39-win32.whl", hash = "sha256:1aca1b97e0095b3a9a6c33eada3f661a4ed0d499067d121239b193e5ba3bb4f0"}, - {file = "Cython-3.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:16873d78be63bd38ffb759da7ab82814b36f56c769ee02b1d5859560e4c3ac3c"}, - {file = "Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6"}, - {file = "Cython-3.0.8.tar.gz", hash = "sha256:8333423d8fd5765e7cceea3a9985dd1e0a5dfeb2734629e1a2ed2d6233d39de6"}, + {file = "Cython-3.0.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:44292aae17524abb4b70a25111fe7dec1a0ad718711d47e3786a211d5408fdaa"}, + {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75d45fbc20651c1b72e4111149fed3b33d270b0a4fb78328c54d965f28d55e1"}, + {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d89a82937ce4037f092e9848a7bbcc65bc8e9fc9aef2bb74f5c15e7d21a73080"}, + {file = "Cython-3.0.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a8ea2e7e2d3bc0d8630dafe6c4a5a89485598ff8a61885b74f8ed882597efd5"}, + {file = "Cython-3.0.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cee29846471ce60226b18e931d8c1c66a158db94853e3e79bc2da9bd22345008"}, + {file = "Cython-3.0.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eeb6860b0f4bfa402de8929833fe5370fa34069c7ebacb2d543cb017f21fb891"}, + {file = "Cython-3.0.11-cp310-cp310-win32.whl", hash = "sha256:3699391125ab344d8d25438074d1097d9ba0fb674d0320599316cfe7cf5f002a"}, + {file = "Cython-3.0.11-cp310-cp310-win_amd64.whl", hash = "sha256:d02f4ebe15aac7cdacce1a628e556c1983f26d140fd2e0ac5e0a090e605a2d38"}, + {file = "Cython-3.0.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75ba1c70b6deeaffbac123856b8d35f253da13552207aa969078611c197377e4"}, + {file = "Cython-3.0.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af91497dc098718e634d6ec8f91b182aea6bb3690f333fc9a7777bc70abe8810"}, + {file = "Cython-3.0.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3999fb52d3328a6a5e8c63122b0a8bd110dfcdb98dda585a3def1426b991cba7"}, + {file = "Cython-3.0.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d566a4e09b8979be8ab9f843bac0dd216c81f5e5f45661a9b25cd162ed80508c"}, + {file = "Cython-3.0.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:46aec30f217bdf096175a1a639203d44ac73a36fe7fa3dd06bd012e8f39eca0f"}, + {file = "Cython-3.0.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd1fe25af330f4e003421636746a546474e4ccd8f239f55d2898d80983d20ed"}, + {file = "Cython-3.0.11-cp311-cp311-win32.whl", hash = "sha256:221de0b48bf387f209003508e602ce839a80463522fc6f583ad3c8d5c890d2c1"}, + {file = "Cython-3.0.11-cp311-cp311-win_amd64.whl", hash = "sha256:3ff8ac1f0ecd4f505db4ab051e58e4531f5d098b6ac03b91c3b902e8d10c67b3"}, + {file = "Cython-3.0.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:11996c40c32abf843ba652a6d53cb15944c88d91f91fc4e6f0028f5df8a8f8a1"}, + {file = "Cython-3.0.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63f2c892e9f9c1698ecfee78205541623eb31cd3a1b682668be7ac12de94aa8e"}, + {file = "Cython-3.0.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b14c24f1dc4c4c9d997cca8d1b7fb01187a218aab932328247dcf5694a10102"}, + {file = "Cython-3.0.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8eed5c015685106db15dd103fd040948ddca9197b1dd02222711815ea782a27"}, + {file = "Cython-3.0.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780f89c95b8aec1e403005b3bf2f0a2afa060b3eba168c86830f079339adad89"}, + {file = "Cython-3.0.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a690f2ff460682ea985e8d38ec541be97e0977fa0544aadc21efc116ff8d7579"}, + {file = "Cython-3.0.11-cp312-cp312-win32.whl", hash = "sha256:2252b5aa57621848e310fe7fa6f7dce5f73aa452884a183d201a8bcebfa05a00"}, + {file = "Cython-3.0.11-cp312-cp312-win_amd64.whl", hash = "sha256:da394654c6da15c1d37f0b7ec5afd325c69a15ceafee2afba14b67a5df8a82c8"}, + {file = "Cython-3.0.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4341d6a64d47112884e0bcf31e6c075268220ee4cd02223047182d4dda94d637"}, + {file = "Cython-3.0.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351955559b37e6c98b48aecb178894c311be9d731b297782f2b78d111f0c9015"}, + {file = "Cython-3.0.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c02361af9bfa10ff1ccf967fc75159e56b1c8093caf565739ed77a559c1f29f"}, + {file = "Cython-3.0.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6823aef13669a32caf18bbb036de56065c485d9f558551a9b55061acf9c4c27f"}, + {file = "Cython-3.0.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6fb68cef33684f8cc97987bee6ae919eee7e18ee6a3ad7ed9516b8386ef95ae6"}, + {file = "Cython-3.0.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790263b74432cb997740d73665f4d8d00b9cd1cecbdd981d93591ddf993d4f12"}, + {file = "Cython-3.0.11-cp313-cp313-win32.whl", hash = "sha256:e6dd395d1a704e34a9fac00b25f0036dce6654c6b898be6f872ac2bb4f2eda48"}, + {file = "Cython-3.0.11-cp313-cp313-win_amd64.whl", hash = "sha256:52186101d51497519e99b60d955fd5cb3bf747c67f00d742e70ab913f1e42d31"}, + {file = "Cython-3.0.11-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c69d5cad51388522b98a99b4be1b77316de85b0c0523fa865e0ea58bbb622e0a"}, + {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8acdc87e9009110adbceb7569765eb0980129055cc954c62f99fe9f094c9505e"}, + {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dd47865f4c0a224da73acf83d113f93488d17624e2457dce1753acdfb1cc40c"}, + {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:301bde949b4f312a1c70e214b0c3bc51a3f955d466010d2f68eb042df36447b0"}, + {file = "Cython-3.0.11-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:f3953d2f504176f929862e5579cfc421860c33e9707f585d70d24e1096accdf7"}, + {file = "Cython-3.0.11-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:3f2b062f6df67e8a56c75e500ca330cf62c85ac26dd7fd006f07ef0f83aebfa3"}, + {file = "Cython-3.0.11-cp36-cp36m-win32.whl", hash = "sha256:c3d68751668c66c7a140b6023dba5d5d507f72063407bb609d3a5b0f3b8dfbe4"}, + {file = "Cython-3.0.11-cp36-cp36m-win_amd64.whl", hash = "sha256:bcd29945fafd12484cf37b1d84f12f0e7a33ba3eac5836531c6bd5283a6b3a0c"}, + {file = "Cython-3.0.11-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4e9a8d92978b15a0c7ca7f98447c6c578dc8923a0941d9d172d0b077cb69c576"}, + {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:421017466e9260aca86823974e26e158e6358622f27c0f4da9c682f3b6d2e624"}, + {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80a7232938d523c1a12f6b1794ab5efb1ae77ad3fde79de4bb558d8ab261619"}, + {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfa550d9ae39e827a6e7198076df763571cb53397084974a6948af558355e028"}, + {file = "Cython-3.0.11-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:aedceb6090a60854b31bf9571dc55f642a3fa5b91f11b62bcef167c52cac93d8"}, + {file = "Cython-3.0.11-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:473d35681d9f93ce380e6a7c8feb2d65fc6333bd7117fbc62989e404e241dbb0"}, + {file = "Cython-3.0.11-cp37-cp37m-win32.whl", hash = "sha256:3379c6521e25aa6cd7703bb7d635eaca75c0f9c7f1b0fdd6dd15a03bfac5f68d"}, + {file = "Cython-3.0.11-cp37-cp37m-win_amd64.whl", hash = "sha256:14701edb3107a5d9305a82d9d646c4f28bfecbba74b26cc1ee2f4be08f602057"}, + {file = "Cython-3.0.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598699165cfa7c6d69513ee1bffc9e1fdd63b00b624409174c388538aa217975"}, + {file = "Cython-3.0.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0583076c4152b417a3a8a5d81ec02f58c09b67d3f22d5857e64c8734ceada8c"}, + {file = "Cython-3.0.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52205347e916dd65d2400b977df4c697390c3aae0e96275a438cc4ae85dadc08"}, + {file = "Cython-3.0.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:989899a85f0d9a57cebb508bd1f194cb52f0e3f7e22ac259f33d148d6422375c"}, + {file = "Cython-3.0.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:53b6072a89049a991d07f42060f65398448365c59c9cb515c5925b9bdc9d71f8"}, + {file = "Cython-3.0.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f988f7f8164a6079c705c39e2d75dbe9967e3dacafe041420d9af7b9ee424162"}, + {file = "Cython-3.0.11-cp38-cp38-win32.whl", hash = "sha256:a1f4cbc70f6b7f0c939522118820e708e0d490edca42d852fa8004ec16780be2"}, + {file = "Cython-3.0.11-cp38-cp38-win_amd64.whl", hash = "sha256:187685e25e037320cae513b8cc4bf9dbc4465c037051aede509cbbf207524de2"}, + {file = "Cython-3.0.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0fc6fdd6fa493be7bdda22355689d5446ac944cd71286f6f44a14b0d67ee3ff5"}, + {file = "Cython-3.0.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b1d1f6f94cc5d42a4591f6d60d616786b9cd15576b112bc92a23131fcf38020"}, + {file = "Cython-3.0.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ab2b92a3e6ed552adbe9350fd2ef3aa0cc7853cf91569f9dbed0c0699bbeab"}, + {file = "Cython-3.0.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:104d6f2f2c827ccc5e9e42c80ef6773a6aa94752fe6bc5b24a4eab4306fb7f07"}, + {file = "Cython-3.0.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13062ce556a1e98d2821f7a0253b50569fdc98c36efd6653a65b21e3f8bbbf5f"}, + {file = "Cython-3.0.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:525d09b3405534763fa73bd78c8e51ac8264036ce4c16d37dfd1555a7da6d3a7"}, + {file = "Cython-3.0.11-cp39-cp39-win32.whl", hash = "sha256:b8c7e514075696ca0f60c337f9e416e61d7ccbc1aa879a56c39181ed90ec3059"}, + {file = "Cython-3.0.11-cp39-cp39-win_amd64.whl", hash = "sha256:8948802e1f5677a673ea5d22a1e7e273ca5f83e7a452786ca286eebf97cee67c"}, + {file = "Cython-3.0.11-py2.py3-none-any.whl", hash = "sha256:0e25f6425ad4a700d7f77cd468da9161e63658837d1bc34861a9861a4ef6346d"}, + {file = "cython-3.0.11.tar.gz", hash = "sha256:7146dd2af8682b4ca61331851e6aebce9fe5158e75300343f80c07ca80b1faff"}, ] [[package]] From cf1ea819f50a084596180a2ac1491b14b328525a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:43:49 -1000 Subject: [PATCH 1097/1433] chore(deps-dev): bump setuptools from 65.7.0 to 70.0.0 in the pip group (#1395) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 15 +++++++-------- pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index a79e019b3..86a21c7b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,19 +320,18 @@ pytest = ">=5.0.0" [[package]] name = "setuptools" -version = "65.7.0" +version = "70.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "setuptools-65.7.0-py3-none-any.whl", hash = "sha256:8ab4f1dbf2b4a65f7eec5ad0c620e84c34111a68d3349833494b9088212214dd"}, - {file = "setuptools-65.7.0.tar.gz", hash = "sha256:4d3c92fac8f1118bb77a22181355e29c239cabfe2b9effdaa665c66b711136d7"}, + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "tomli" @@ -348,4 +347,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "259e5ec479b559f3c02fdb7224f17b4979b66419c1f82b273d837ecd75b743ac" +content-hash = "71e11c707ebc1753e9e0f618e950bbc5b418c730eadd0a4236f1caf2b2e07d98" diff --git a/pyproject.toml b/pyproject.toml index bb53a1d38..f7c2dd21a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pytest = ">=7.2,<9.0" pytest-cov = "^4.0.0" pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" -setuptools = "^65.6.3" +setuptools = ">=65.6.3,<71.0.0" pytest-timeout = "^2.1.0" [tool.ruff] From b7c45e28ec2a6aa9e9fdd8a1954ea538776d494c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:49:42 -1000 Subject: [PATCH 1098/1433] chore(deps-dev): bump setuptools from 65.7.0 to 73.0.1 (#1398) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 13 +++++++------ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 86a21c7b7..8d4f3b62a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,18 +320,19 @@ pytest = ">=5.0.0" [[package]] name = "setuptools" -version = "70.3.0" +version = "73.0.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, - {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, + {file = "setuptools-73.0.1-py3-none-any.whl", hash = "sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e"}, + {file = "setuptools-73.0.1.tar.gz", hash = "sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193"}, ] [package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] [[package]] name = "tomli" @@ -347,4 +348,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "71e11c707ebc1753e9e0f618e950bbc5b418c730eadd0a4236f1caf2b2e07d98" +content-hash = "28cb517c0e51804b062b4993a153f4f3428287de8a5d727677559432e3efd9a4" diff --git a/pyproject.toml b/pyproject.toml index f7c2dd21a..33b499ee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pytest = ">=7.2,<9.0" pytest-cov = "^4.0.0" pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<71.0.0" +setuptools = ">=65.6.3,<74.0.0" pytest-timeout = "^2.1.0" [tool.ruff] From f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 Aug 2024 15:50:02 -1000 Subject: [PATCH 1099/1433] feat: improve performance of ip address caching (#1392) --- src/zeroconf/_utils/ipaddress.py | 35 ++++++++++++++++++++++++++++++-- tests/utils/test_ipaddress.py | 12 ++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index 6b4657be7..3346e6d7b 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -28,13 +28,24 @@ from .._dns import DNSAddress from ..const import _TYPE_AAAA +if sys.version_info >= (3, 9, 0): + from functools import cache +else: + cache = lru_cache(maxsize=None) + bytes_ = bytes int_ = int IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) class ZeroconfIPv4Address(IPv4Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") + __slots__ = ( + "_str", + "_is_link_local", + "_is_unspecified", + "_is_loopback", + "__hash__", + ) def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv4 address.""" @@ -42,6 +53,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._str = super().__str__() self._is_link_local = super().is_link_local self._is_unspecified = super().is_unspecified + self._is_loopback = super().is_loopback + self.__hash__ = cache(lambda: IPv4Address.__hash__(self)) # type: ignore[method-assign] def __str__(self) -> str: """Return the string representation of the IPv4 address.""" @@ -57,9 +70,20 @@ def is_unspecified(self) -> bool: """Return True if this is an unspecified address.""" return self._is_unspecified + @property + def is_loopback(self) -> bool: + """Return True if this is a loop back.""" + return self._is_loopback + class ZeroconfIPv6Address(IPv6Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") + __slots__ = ( + "_str", + "_is_link_local", + "_is_unspecified", + "_is_loopback", + "__hash__", + ) def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv6 address.""" @@ -67,6 +91,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._str = super().__str__() self._is_link_local = super().is_link_local self._is_unspecified = super().is_unspecified + self._is_loopback = super().is_loopback + self.__hash__ = cache(lambda: IPv6Address.__hash__(self)) # type: ignore[method-assign] def __str__(self) -> str: """Return the string representation of the IPv6 address.""" @@ -82,6 +108,11 @@ def is_unspecified(self) -> bool: """Return True if this is an unspecified address.""" return self._is_unspecified + @property + def is_loopback(self) -> bool: + """Return True if this is a loop back.""" + return self._is_loopback + @lru_cache(maxsize=512) def _cached_ip_addresses( diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 35803c7ea..ddade4867 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -19,7 +19,17 @@ def test_cached_ip_addresses_wrapper(): str(ipaddress.cached_ip_addresses(b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F")) == "2606:2800:220:1:248:1893:25c8:1946" ) - assert ipaddress.cached_ip_addresses("::1") == ipaddress.IPv6Address("::1") + loop_back_ipv6 = ipaddress.cached_ip_addresses("::1") + assert loop_back_ipv6 == ipaddress.IPv6Address("::1") + assert loop_back_ipv6.is_loopback is True + + assert hash(loop_back_ipv6) == hash(ipaddress.IPv6Address("::1")) + + loop_back_ipv4 = ipaddress.cached_ip_addresses("127.0.0.1") + assert loop_back_ipv4 == ipaddress.IPv4Address("127.0.0.1") + assert loop_back_ipv4.is_loopback is True + + assert hash(loop_back_ipv4) == hash(ipaddress.IPv4Address("127.0.0.1")) ipv4 = ipaddress.cached_ip_addresses("169.254.0.0") assert ipv4 is not None From 2ee954de379bc5b5beeb5891b8c937573ea5441b Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 27 Aug 2024 02:02:42 +0000 Subject: [PATCH 1100/1433] 0.133.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2026cbaa..ff1c5239d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.133.0 (2024-08-27) + +### Feature + +* Improve performance of ip address caching ([#1392](https://github.com/python-zeroconf/python-zeroconf/issues/1392)) ([`f7c7708`](https://github.com/python-zeroconf/python-zeroconf/commit/f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2)) +* Enable building of arm64 macOS builds ([#1384](https://github.com/python-zeroconf/python-zeroconf/issues/1384)) ([`0df2ce0`](https://github.com/python-zeroconf/python-zeroconf/commit/0df2ce0e6f7313831da6a63d477019982d5df55c)) +* Add classifier for python 3.13 ([#1393](https://github.com/python-zeroconf/python-zeroconf/issues/1393)) ([`7fb2bb2`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb2bb21421c70db0eb288fa7e73d955f58b0f5d)) +* Python 3.13 support ([#1390](https://github.com/python-zeroconf/python-zeroconf/issues/1390)) ([`98cfa83`](https://github.com/python-zeroconf/python-zeroconf/commit/98cfa83710e43880698353821bae61108b08cb2f)) + ## v0.132.2 (2024-04-13) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 33b499ee1..180d6236d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.132.2" +version = "0.133.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f3130307f..e058d06fc 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.132.2" +__version__ = "0.133.0" __license__ = "LGPL" From 89e90782f02ef2bde8738789e92160a6379457a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 21:16:00 -0500 Subject: [PATCH 1101/1433] chore(deps-dev): bump setuptools from 73.0.1 to 74.0.0 (#1403) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 14 +++++++++----- pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8d4f3b62a..189fcfb4d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,19 +320,23 @@ pytest = ">=5.0.0" [[package]] name = "setuptools" -version = "73.0.1" +version = "74.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-73.0.1-py3-none-any.whl", hash = "sha256:b208925fcb9f7af924ed2dc04708ea89791e24bde0d3020b27df0e116088b34e"}, - {file = "setuptools-73.0.1.tar.gz", hash = "sha256:d59a3e788ab7e012ab2c4baed1b376da6366883ee20d7a5fc426816e3d7b1193"}, + {file = "setuptools-74.0.0-py3-none-any.whl", hash = "sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f"}, + {file = "setuptools-74.0.0.tar.gz", hash = "sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e"}, ] [package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] [[package]] name = "tomli" @@ -348,4 +352,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "28cb517c0e51804b062b4993a153f4f3428287de8a5d727677559432e3efd9a4" +content-hash = "7cd88ed2bd45ce5dfdc9169de986d8f851d666c5f1b36c9a605dcb4efc5a6bc9" diff --git a/pyproject.toml b/pyproject.toml index 180d6236d..4c334329a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pytest = ">=7.2,<9.0" pytest-cov = "^4.0.0" pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<74.0.0" +setuptools = ">=65.6.3,<75.0.0" pytest-timeout = "^2.1.0" [tool.ruff] From dd8ce11c28a3feda89a01e70f3488d8360cb4b3d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 21:32:14 -0500 Subject: [PATCH 1102/1433] chore(pre-commit.ci): pre-commit autoupdate (#1406) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e37e5a00..7c916f81b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 30f85fdc2eed9e42b987635f95f5b025ec3bd764 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Sep 2024 21:39:30 -0500 Subject: [PATCH 1103/1433] chore(deps-dev): bump pytest-cov from 4.1.0 to 5.0.0 (#1405) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 189fcfb4d..b891449c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -288,13 +288,13 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "4.1.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] @@ -302,7 +302,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" @@ -352,4 +352,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "7cd88ed2bd45ce5dfdc9169de986d8f851d666c5f1b36c9a605dcb4efc5a6bc9" +content-hash = "501cb081442d418e6462854507575a73105dff190b3911f41837f2cb68dd6834" diff --git a/pyproject.toml b/pyproject.toml index 4c334329a..2a2849129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" -pytest-cov = "^4.0.0" +pytest-cov = ">=4,<6" pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" setuptools = ">=65.6.3,<75.0.0" From 111c91ab395a7520e477eb0e75d5924fba3c64c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Sep 2024 21:40:03 -0500 Subject: [PATCH 1104/1433] feat: improve performance when IP addresses change frequently (#1407) --- src/zeroconf/_services/info.py | 39 ++++++++++++++++++++------------ src/zeroconf/_utils/ipaddress.py | 28 ++++++++--------------- tests/services/test_info.py | 5 ++++ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 2fc9dfc8e..fef43fa02 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -23,7 +23,6 @@ import asyncio import random import sys -from ipaddress import IPv4Address, IPv6Address, _BaseAddress from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast from .._cache import DNSCache @@ -50,6 +49,8 @@ wait_for_future_set_or_timeout, ) from .._utils.ipaddress import ( + ZeroconfIPv4Address, + ZeroconfIPv6Address, cached_ip_addresses, get_ip_address_object_from_record, ip_bytes_and_scope_to_address, @@ -187,8 +188,8 @@ def __init__( self.type = type_ self._name = name self.key = name.lower() - self._ipv4_addresses: List[IPv4Address] = [] - self._ipv6_addresses: List[IPv6Address] = [] + self._ipv4_addresses: List[ZeroconfIPv4Address] = [] + self._ipv6_addresses: List[ZeroconfIPv6Address] = [] if addresses is not None: self.addresses = addresses elif parsed_addresses is not None: @@ -260,11 +261,11 @@ def addresses(self, value: List[bytes]) -> None: ) if addr.version == 4: if TYPE_CHECKING: - assert isinstance(addr, IPv4Address) + assert isinstance(addr, ZeroconfIPv4Address) self._ipv4_addresses.append(addr) else: if TYPE_CHECKING: - assert isinstance(addr, IPv6Address) + assert isinstance(addr, ZeroconfIPv6Address) self._ipv6_addresses.append(addr) @property @@ -321,7 +322,7 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: def ip_addresses_by_version( self, version: IPVersion - ) -> Union[List[IPv4Address], List[IPv6Address], List[_BaseAddress]]: + ) -> Union[List[ZeroconfIPv4Address], List[ZeroconfIPv6Address]]: """List ip_address objects matching IP version. Addresses are guaranteed to be returned in LIFO (last in, first out) @@ -334,7 +335,7 @@ def ip_addresses_by_version( def _ip_addresses_by_version_value( self, version_value: int_ - ) -> Union[List[IPv4Address], List[IPv6Address]]: + ) -> Union[List[ZeroconfIPv4Address], List[ZeroconfIPv6Address]]: """Backend for addresses_by_version that uses the raw value.""" if version_value == _IPVersion_All_value: return [*self._ipv4_addresses, *self._ipv6_addresses] # type: ignore[return-value] @@ -440,9 +441,9 @@ def get_name(self) -> str: def _get_ip_addresses_from_cache_lifo( self, zc: "Zeroconf", now: float_, type: int_ - ) -> List[Union[IPv4Address, IPv6Address]]: + ) -> List[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: """Set IPv6 addresses from the cache.""" - address_list: List[Union[IPv4Address, IPv6Address]] = [] + address_list: List[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]] = [] for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue @@ -456,7 +457,7 @@ def _set_ipv6_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: self._ipv6_addresses = cast( - "List[IPv6Address]", + "List[ZeroconfIPv6Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA), ) else: @@ -466,7 +467,7 @@ def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: """Set IPv4 addresses from the cache.""" if TYPE_CHECKING: self._ipv4_addresses = cast( - "List[IPv4Address]", + "List[ZeroconfIPv4Address]", self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A), ) else: @@ -509,24 +510,32 @@ def _process_record_threadsafe(self, zc: "Zeroconf", record: DNSRecord, now: flo if ip_addr.version == 4: if TYPE_CHECKING: - assert isinstance(ip_addr, IPv4Address) + assert isinstance(ip_addr, ZeroconfIPv4Address) ipv4_addresses = self._ipv4_addresses if ip_addr not in ipv4_addresses: ipv4_addresses.insert(0, ip_addr) return True - elif ip_addr != ipv4_addresses[0]: + # Use int() to compare the addresses as integers + # since by default IPv4Address.__eq__ compares the + # the addresses on version and int which more than + # we need here since we know the version is 4. + elif ip_addr.zc_integer != ipv4_addresses[0].zc_integer: ipv4_addresses.remove(ip_addr) ipv4_addresses.insert(0, ip_addr) return False if TYPE_CHECKING: - assert isinstance(ip_addr, IPv6Address) + assert isinstance(ip_addr, ZeroconfIPv6Address) ipv6_addresses = self._ipv6_addresses if ip_addr not in self._ipv6_addresses: ipv6_addresses.insert(0, ip_addr) return True - elif ip_addr != self._ipv6_addresses[0]: + # Use int() to compare the addresses as integers + # since by default IPv6Address.__eq__ compares the + # the addresses on version and int which more than + # we need here since we know the version is 6. + elif ip_addr.zc_integer != self._ipv6_addresses[0].zc_integer: ipv6_addresses.remove(ip_addr) ipv6_addresses.insert(0, ip_addr) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index 3346e6d7b..72bb9ce83 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -39,13 +39,7 @@ class ZeroconfIPv4Address(IPv4Address): - __slots__ = ( - "_str", - "_is_link_local", - "_is_unspecified", - "_is_loopback", - "__hash__", - ) + __slots__ = ("_str", "_is_link_local", "_is_unspecified", "_is_loopback", "__hash__", "zc_integer") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv4 address.""" @@ -55,6 +49,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._is_unspecified = super().is_unspecified self._is_loopback = super().is_loopback self.__hash__ = cache(lambda: IPv4Address.__hash__(self)) # type: ignore[method-assign] + self.zc_integer = int(self) def __str__(self) -> str: """Return the string representation of the IPv4 address.""" @@ -77,13 +72,7 @@ def is_loopback(self) -> bool: class ZeroconfIPv6Address(IPv6Address): - __slots__ = ( - "_str", - "_is_link_local", - "_is_unspecified", - "_is_loopback", - "__hash__", - ) + __slots__ = ("_str", "_is_link_local", "_is_unspecified", "_is_loopback", "__hash__", "zc_integer") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv6 address.""" @@ -93,6 +82,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._is_unspecified = super().is_unspecified self._is_loopback = super().is_loopback self.__hash__ = cache(lambda: IPv6Address.__hash__(self)) # type: ignore[method-assign] + self.zc_integer = int(self) def __str__(self) -> str: """Return the string representation of the IPv6 address.""" @@ -117,7 +107,7 @@ def is_loopback(self) -> bool: @lru_cache(maxsize=512) def _cached_ip_addresses( address: Union[str, bytes, int], -) -> Optional[Union[IPv4Address, IPv6Address]]: +) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: """Cache IP addresses.""" try: return ZeroconfIPv4Address(address) @@ -136,14 +126,16 @@ def _cached_ip_addresses( def get_ip_address_object_from_record( record: DNSAddress, -) -> Optional[Union[IPv4Address, IPv6Address]]: +) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: """Get the IP address object from the record.""" if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id: return ip_bytes_and_scope_to_address(record.address, record.scope_id) return cached_ip_addresses_wrapper(record.address) -def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Union[IPv4Address, IPv6Address]]: +def ip_bytes_and_scope_to_address( + address: bytes_, scope: int_ +) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: """Convert the bytes and scope to an IP address object.""" base_address = cached_ip_addresses_wrapper(address) if base_address is not None and base_address.is_link_local: @@ -152,7 +144,7 @@ def ip_bytes_and_scope_to_address(address: bytes_, scope: int_) -> Optional[Unio return base_address -def str_without_scope_id(addr: Union[IPv4Address, IPv6Address]) -> str: +def str_without_scope_id(addr: Union[ZeroconfIPv4Address, ZeroconfIPv6Address]) -> str: """Return the string representation of the address without the scope id.""" if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: address_str = str(addr) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 4a9b1ee2f..9d4a4958f 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1469,6 +1469,10 @@ async def test_ipv6_changes_are_seen(): assert info.addresses_by_version(IPVersion.V6Only) == [ b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ] + info.load_from_cache(aiozc.zeroconf) + assert info.addresses_by_version(IPVersion.V6Only) == [ + b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ] generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) generated.add_answer_at_time( @@ -1494,6 +1498,7 @@ async def test_ipv6_changes_are_seen(): b"\x00\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", b"\xde\xad\xbe\xef\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", ] + await aiozc.async_close() From 9262626895d354ed7376aa567043b793c37a985e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Sep 2024 21:43:20 -0500 Subject: [PATCH 1105/1433] fix: improve helpfulness of ServiceInfo.request assertions (#1408) --- src/zeroconf/_services/info.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fef43fa02..d18c84026 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -791,7 +791,8 @@ def request( :param addr: address to send the request to :param port: port to send the request to """ - assert zc.loop is not None and zc.loop.is_running() + assert zc.loop is not None, "Zeroconf instance must have a loop, was it not started?" + assert zc.loop.is_running(), "Zeroconf instance loop must be running, was it already stopped?" if zc.loop == get_running_loop(): raise RuntimeError("Use AsyncServiceInfo.async_request from the event loop") return bool( From e3bf880d73c745119171b1d13cb4761c8dbd2dbf Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 8 Sep 2024 03:04:36 +0000 Subject: [PATCH 1106/1433] 0.134.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1c5239d..910acfa6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ +## v0.134.0 (2024-09-08) + +### Feature + +* Improve performance when IP addresses change frequently ([#1407](https://github.com/python-zeroconf/python-zeroconf/issues/1407)) ([`111c91a`](https://github.com/python-zeroconf/python-zeroconf/commit/111c91ab395a7520e477eb0e75d5924fba3c64c7)) + +### Fix + +* Improve helpfulness of ServiceInfo.request assertions ([#1408](https://github.com/python-zeroconf/python-zeroconf/issues/1408)) ([`9262626`](https://github.com/python-zeroconf/python-zeroconf/commit/9262626895d354ed7376aa567043b793c37a985e)) + ## v0.133.0 (2024-08-27) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 2a2849129..19cebdc64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.133.0" +version = "0.134.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index e058d06fc..8ffaf1609 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.133.0" +__version__ = "0.134.0" __license__ = "LGPL" From a3172f87b18a4034f9e69325e06a9061b443e4f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:08:33 -0500 Subject: [PATCH 1107/1433] chore(pre-commit.ci): pre-commit autoupdate (#1410) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c916f81b..74b047764 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 7bb2cbba19d3b5d21c65ddd2e3f72f6013cf97bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:08:46 -0500 Subject: [PATCH 1108/1433] chore(deps-dev): bump pytest-timeout from 2.2.0 to 2.3.1 (#1404) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index b891449c3..feddb3143 100644 --- a/poetry.lock +++ b/poetry.lock @@ -306,17 +306,17 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" -version = "2.2.0" +version = "2.3.1" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-timeout-2.2.0.tar.gz", hash = "sha256:3b0b95dabf3cb50bac9ef5ca912fa0cfc286526af17afc806824df20c2f72c90"}, - {file = "pytest_timeout-2.2.0-py3-none-any.whl", hash = "sha256:bde531e096466f49398a59f2dde76fa78429a09a12411466f88a07213e220de2"}, + {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, + {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, ] [package.dependencies] -pytest = ">=5.0.0" +pytest = ">=7.0.0" [[package]] name = "setuptools" From 7d6c277df95fc3703f92aa36680c4f2d3474fbcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:09:00 -0500 Subject: [PATCH 1109/1433] chore(deps-dev): bump pytest from 8.3.2 to 8.3.3 (#1412) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index feddb3143..b7e5f4c5b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -248,13 +248,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] From 1827474ca4c39b9ecbdafce69c3f3ee3a79338f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:49:02 -0500 Subject: [PATCH 1110/1433] chore(deps-dev): bump setuptools from 74.0.0 to 75.1.0 (#1414) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index b7e5f4c5b..7989b098f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,18 +320,18 @@ pytest = ">=7.0.0" [[package]] name = "setuptools" -version = "74.0.0" +version = "75.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-74.0.0-py3-none-any.whl", hash = "sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f"}, - {file = "setuptools-74.0.0.tar.gz", hash = "sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e"}, + {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, + {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, ] [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] @@ -352,4 +352,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "501cb081442d418e6462854507575a73105dff190b3911f41837f2cb68dd6834" +content-hash = "778ccbd9b059daea1ccbc3a93e0186fa30737e8c5234cdc04edf505a1f71606a" diff --git a/pyproject.toml b/pyproject.toml index 19cebdc64..57099ca2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<6" pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<75.0.0" +setuptools = ">=65.6.3,<76.0.0" pytest-timeout = "^2.1.0" [tool.ruff] From 1df2e691ff11c9592e1cdad5599fb6601eb1aa3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Sep 2024 13:49:26 -0500 Subject: [PATCH 1111/1433] feat: improve performance of DNSCache backend (#1415) --- src/zeroconf/_cache.pxd | 23 +++++++++++++++++++---- src/zeroconf/_cache.py | 11 +++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index af27a1d51..d44174667 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -13,9 +13,12 @@ from ._dns cimport ( cdef object _UNIQUE_RECORD_TYPES -cdef object _TYPE_PTR +cdef unsigned int _TYPE_PTR cdef cython.uint _ONE_SECOND +@cython.locals( + record_cache=dict, +) cdef _remove_key(cython.dict cache, object key, DNSRecord record) @@ -42,7 +45,7 @@ cdef class DNSCache: records=cython.dict, record=DNSRecord, ) - cpdef list async_all_by_details(self, str name, object type_, object class_) + cpdef list async_all_by_details(self, str name, unsigned int type_, unsigned int class_) cpdef cython.dict async_entries_with_name(self, str name) @@ -51,19 +54,23 @@ cdef class DNSCache: @cython.locals( cached_entry=DNSRecord, ) - cpdef DNSRecord get_by_details(self, str name, object type_, object class_) + cpdef DNSRecord get_by_details(self, str name, unsigned int type_, unsigned int class_) @cython.locals( records=cython.dict, entry=DNSRecord, ) - cpdef cython.list get_all_by_details(self, str name, object type_, object class_) + cpdef cython.list get_all_by_details(self, str name, unsigned int type_, unsigned int class_) @cython.locals( store=cython.dict, + service_record=DNSService ) cdef bint _async_add(self, DNSRecord record) + @cython.locals( + service_record=DNSService + ) cdef void _async_remove(self, DNSRecord record) @cython.locals( @@ -71,3 +78,11 @@ cdef class DNSCache: created_double=double, ) cpdef void async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) + + cpdef entries_with_name(self, str name) + + @cython.locals( + record=DNSRecord, + now=double + ) + cpdef current_entry_with_name_and_alias(self, str name, str alias) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 7db151171..333b61962 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -49,8 +49,9 @@ def _remove_key(cache: _DNSRecordCacheType, key: _str, record: _DNSRecord) -> No This function must be run in from event loop. """ - del cache[key][record] - if not cache[key]: + record_cache = cache[key] + del record_cache[record] + if not record_cache: del cache[key] @@ -81,7 +82,8 @@ def _async_add(self, record: _DNSRecord) -> bool: new = record not in store and not isinstance(record, DNSNsec) store[record] = record if isinstance(record, DNSService): - self.service_cache.setdefault(record.server_key, {})[record] = record + service_record = record + self.service_cache.setdefault(record.server_key, {})[service_record] = service_record return new def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: @@ -103,7 +105,8 @@ def _async_remove(self, record: _DNSRecord) -> None: This function must be run in from event loop. """ if isinstance(record, DNSService): - _remove_key(self.service_cache, record.server_key, record) + service_record = record + _remove_key(self.service_cache, service_record.server_key, service_record) _remove_key(self.cache, record.key, record) def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: From 938fe214089d6eb7438f0f03ac19a3a724566d37 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 24 Sep 2024 19:13:13 +0000 Subject: [PATCH 1112/1433] 0.135.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 910acfa6b..e8c6590d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.135.0 (2024-09-24) + +### Feature + +* Improve performance of DNSCache backend ([#1415](https://github.com/python-zeroconf/python-zeroconf/issues/1415)) ([`1df2e69`](https://github.com/python-zeroconf/python-zeroconf/commit/1df2e691ff11c9592e1cdad5599fb6601eb1aa3f)) + ## v0.134.0 (2024-09-08) ### Feature diff --git a/pyproject.toml b/pyproject.toml index 57099ca2f..fed1c323d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.134.0" +version = "0.135.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 8ffaf1609..58bda33d4 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.134.0" +__version__ = "0.135.0" __license__ = "LGPL" From 119122939ef0251b771bd5361ef17665331f7078 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 16:53:59 -0500 Subject: [PATCH 1113/1433] chore(pre-commit.ci): pre-commit autoupdate (#1419) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74b047764..72f39073b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v3.29.0 + rev: v3.29.1 hooks: - id: commitizen stages: [commit-msg] @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 + rev: v0.6.8 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 9e498dff6ea218d3818b4e8faa9b250554ee352d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:48:50 -1000 Subject: [PATCH 1114/1433] chore: pre-commit autoupdate (#1421) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72f39073b..8b50394d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: "CHANGELOG.md" -default_stages: [commit] +default_stages: [pre-commit] ci: autofix_commit_msg: "chore(pre-commit.ci): auto fixes" @@ -14,7 +14,7 @@ repos: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: debug-statements - id: check-builtin-literals @@ -34,12 +34,12 @@ repos: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/asottile/pyupgrade - rev: v3.17.0 + rev: v3.18.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.7.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -53,7 +53,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.12.1 hooks: - id: mypy additional_dependencies: [] From 6441b0e467815fff82af3d4b2622f26629a136e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:49:02 -1000 Subject: [PATCH 1115/1433] chore(deps-dev): bump setuptools from 75.1.0 to 75.2.0 (#1423) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7989b098f..1c99cd116 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,13 +320,13 @@ pytest = ">=7.0.0" [[package]] name = "setuptools" -version = "75.1.0" +version = "75.2.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, - {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, + {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, + {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, ] [package.extras] From 3991b4256b8de5b37db7a6144e5112f711b2efef Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Sat, 26 Oct 2024 03:12:57 +0200 Subject: [PATCH 1116/1433] fix: correct typos (#1422) --- src/zeroconf/_cache.py | 2 +- src/zeroconf/_core.py | 4 ++-- src/zeroconf/_handlers/query_handler.py | 4 ++-- src/zeroconf/_handlers/record_manager.py | 2 +- src/zeroconf/_listener.py | 2 +- src/zeroconf/_protocol/incoming.py | 2 +- src/zeroconf/_services/info.py | 2 +- src/zeroconf/_utils/time.py | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 333b61962..f34c4c16b 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -172,7 +172,7 @@ def async_entries_with_server(self, name: str) -> Dict[DNSRecord, DNSRecord]: # The below functions are threadsafe and do not need to be run in the # event loop, however they all make copies so they significantly - # inefficent + # inefficient. def get(self, entry: DNSEntry) -> Optional[DNSRecord]: """Gets an entry by key. Will return None if there is no diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index b3ecd8519..68cb8a9ac 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -84,10 +84,10 @@ _UNREGISTER_TIME, ) -# The maximum amont of time to delay a multicast +# The maximum amount of time to delay a multicast # response in order to aggregate answers _AGGREGATION_DELAY = 500 # ms -# The maximum amont of time to delay a multicast +# The maximum amount of time to delay a multicast # response in order to aggregate answers after # it has already been delayed to protect the network # from excessive traffic. We use a shorter time diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index f2e112363..3acb1b445 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -441,7 +441,7 @@ def handle_assembled_query( ) -> None: """Respond to a (re)assembled query. - If the protocol recieved packets with the TC bit set, it will + If the protocol received packets with the TC bit set, it will wait a bit for the rest of the packets and only call handle_assembled_query once it has a complete set of packets or the timer expires. If the TC bit is not set, a single @@ -457,7 +457,7 @@ def handle_assembled_query( id_ = first_packet.id out = construct_outgoing_unicast_answers(question_answers.ucast, ucast_source, questions, id_) # When sending unicast, only send back the reply - # via the same socket that it was recieved from + # via the same socket that it was received from # as we know its reachable from that socket self.zc.async_send(out, addr, port, v6_flow_scope, transport) if question_answers.mcast_now: diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 8ae82ba55..53ab3ed11 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -146,7 +146,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: # that any ServiceBrowser that is going to call # zc.get_service_info will see the cached value # but ONLY after all the record updates have been - # processsed. + # processed. new = False if other_adds or address_adds: new = cache.async_add_records(address_adds) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 19cca8df1..4490965f7 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -241,7 +241,7 @@ def _respond_query( def error_received(self, exc: Exception) -> None: """Likely socket closed or IPv6.""" # We preformat the message string with the socket as we want - # log_exception_once to log a warrning message once PER EACH + # log_exception_once to log a warning message once PER EACH # different socket in case there are problems with multiple # sockets msg_str = f"Error with socket {self.sock_description}): %s" diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 8670b0df2..f7b1d773e 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -171,7 +171,7 @@ def num_additionals(self) -> int: return self._num_additionals def _initial_parse(self) -> None: - """Parse the data needed to initalize the packet object.""" + """Parse the data needed to initialize the packet object.""" self._read_header() self._read_questions() if not self._num_questions: diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index d18c84026..8a85ad103 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -221,7 +221,7 @@ def name(self) -> str: @name.setter def name(self, name: str) -> None: - """Replace the the name and reset the key.""" + """Replace the name and reset the key.""" self._name = name self.key = name.lower() self._dns_service_cache = None diff --git a/src/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py index 2ed8ca925..055e0658a 100644 --- a/src/zeroconf/_utils/time.py +++ b/src/zeroconf/_utils/time.py @@ -28,7 +28,7 @@ def current_time_millis() -> _float: """Current time in milliseconds. - The current implemention uses `time.monotonic` + The current implementation uses `time.monotonic` but may change in the future. The design requires the time to match asyncio.loop.time() From 6535963b5b789ce445e77bb728a5b7ee4263e582 Mon Sep 17 00:00:00 2001 From: Amir Date: Fri, 25 Oct 2024 18:13:26 -0700 Subject: [PATCH 1117/1433] fix: add ignore for .c file for wheels (#1424) --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fed1c323d..0874aca30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ include = [ { path = "docs", format = "sdist" }, { path = "tests", format = "sdist" }, ] +# Make sure we don't package temporary C files generated by the build process +exclude = [ "**/*.c" ] [tool.poetry.urls] "Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" From 1596145452721e0de4e2a724b055e8e290792d3e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 26 Oct 2024 03:14:04 +0200 Subject: [PATCH 1118/1433] feat: use SPDX license identifier (#1425) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0874aca30..ec49e728b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "zeroconf" version = "0.135.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] -license = "LGPL" +license = "LGPL-2.1-or-later" readme = "README.rst" repository = "https://github.com/python-zeroconf/python-zeroconf" documentation = "https://python-zeroconf.readthedocs.io" @@ -11,7 +11,6 @@ classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', 'Operating System :: POSIX', 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', From 2f201558d0ab089cdfebb18d2d7bb5785b2cce16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Oct 2024 15:40:18 -1000 Subject: [PATCH 1119/1433] fix: update python-semantic-release to fix release process (#1426) --- .github/workflows/ci.yml | 54 +++++++++++++++++++++++++++------------- pyproject.toml | 22 ++++++++++++++-- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 217cc11cb..2359c4202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,36 +93,54 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} release: - runs-on: ubuntu-latest - environment: release - if: github.ref == 'refs/heads/master' needs: - test - lint - commitlint + runs-on: ubuntu-latest + environment: release + concurrency: release + permissions: + id-token: write + contents: write + outputs: + released: ${{ steps.release.outputs.released }} + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.head_ref || github.ref_name }} - # Run semantic release: - # - Update CHANGELOG.md - # - Update version in code - # - Create git tag - # - Create GitHub release - # - Publish to PyPI - - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.34.6 - # env: - # REPOSITORY_URL: https://test.pypi.org/legacy/ - # TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/ + # Do a dry run of PSR + - name: Test release + uses: python-semantic-release/python-semantic-release@v9.12.0 + if: github.ref_name != 'master' + with: + root_options: --noop + + # On main branch: actual PSR + upload to PyPI & GitHub + - name: Release + uses: python-semantic-release/python-semantic-release@v9.12.0 + id: release + if: github.ref_name == 'master' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + if: steps.release.outputs.released == 'true' + + - name: Publish package distributions to GitHub Releases + uses: python-semantic-release/upload-to-gh-release@main + if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} - pypi_token: ${{ secrets.PYPI_TOKEN }} build_wheels: needs: [release] + if: needs.release.outputs.released == 'true' name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -139,6 +157,8 @@ jobs: # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@v5 + with: + python-version: "3.11" - name: Install python-semantic-release run: pipx install python-semantic-release==7.34.6 @@ -161,7 +181,7 @@ jobs: platforms: arm64 - name: Build wheels - uses: pypa/cibuildwheel@v2.20.0 + uses: pypa/cibuildwheel@v2.21.3 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 cp38-*_arm64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 diff --git a/pyproject.toml b/pyproject.toml index ec49e728b..7bd2960ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,11 +46,29 @@ script = "build_ext.py" [tool.semantic_release] branch = "master" -version_toml = "pyproject.toml:tool.poetry.version" -version_variable = "src/zeroconf/__init__.py:__version__" +version_toml = ["pyproject.toml:tool.poetry.version"] +version_variables = [ + "src/zeroconf/__init__.py:__version__" +] build_command = "pip install poetry && poetry build" tag_format = "{version}" +[tool.semantic_release.changelog] +exclude_commit_patterns = [ + "chore*", + "ci*", +] + +[tool.semantic_release.changelog.environment] +keep_trailing_newline = true + +[tool.semantic_release.branches.master] +match = "master" + +[tool.semantic_release.branches.noop] +match = "(?!master$)" +prerelease = true + [tool.poetry.dependencies] python = "^3.8" async-timeout = {version = ">=3.0.0", python = "<3.11"} From 8eac029bd8376abb2a2bdcc32be2edfcb5a8bf7b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 26 Oct 2024 02:05:32 +0000 Subject: [PATCH 1120/1433] 0.136.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 6169 +++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 4837 insertions(+), 1336 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c6590d4..a15e049a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1980 +1,5481 @@ -# Changelog +# CHANGELOG + + +## v0.136.0 (2024-10-26) + +### Bug Fixes + +* fix: update python-semantic-release to fix release process (#1426) ([`2f20155`](https://github.com/python-zeroconf/python-zeroconf/commit/2f201558d0ab089cdfebb18d2d7bb5785b2cce16)) + +* fix: add ignore for .c file for wheels (#1424) ([`6535963`](https://github.com/python-zeroconf/python-zeroconf/commit/6535963b5b789ce445e77bb728a5b7ee4263e582)) + +* fix: correct typos (#1422) ([`3991b42`](https://github.com/python-zeroconf/python-zeroconf/commit/3991b4256b8de5b37db7a6144e5112f711b2efef)) + +### Features + +* feat: use SPDX license identifier (#1425) ([`1596145`](https://github.com/python-zeroconf/python-zeroconf/commit/1596145452721e0de4e2a724b055e8e290792d3e)) - ## v0.135.0 (2024-09-24) -### Feature +### Features + +* feat: improve performance of DNSCache backend (#1415) ([`1df2e69`](https://github.com/python-zeroconf/python-zeroconf/commit/1df2e691ff11c9592e1cdad5599fb6601eb1aa3f)) -* Improve performance of DNSCache backend ([#1415](https://github.com/python-zeroconf/python-zeroconf/issues/1415)) ([`1df2e69`](https://github.com/python-zeroconf/python-zeroconf/commit/1df2e691ff11c9592e1cdad5599fb6601eb1aa3f)) ## v0.134.0 (2024-09-08) -### Feature +### Bug Fixes + +* fix: improve helpfulness of ServiceInfo.request assertions (#1408) ([`9262626`](https://github.com/python-zeroconf/python-zeroconf/commit/9262626895d354ed7376aa567043b793c37a985e)) -* Improve performance when IP addresses change frequently ([#1407](https://github.com/python-zeroconf/python-zeroconf/issues/1407)) ([`111c91a`](https://github.com/python-zeroconf/python-zeroconf/commit/111c91ab395a7520e477eb0e75d5924fba3c64c7)) +### Features -### Fix +* feat: improve performance when IP addresses change frequently (#1407) ([`111c91a`](https://github.com/python-zeroconf/python-zeroconf/commit/111c91ab395a7520e477eb0e75d5924fba3c64c7)) -* Improve helpfulness of ServiceInfo.request assertions ([#1408](https://github.com/python-zeroconf/python-zeroconf/issues/1408)) ([`9262626`](https://github.com/python-zeroconf/python-zeroconf/commit/9262626895d354ed7376aa567043b793c37a985e)) ## v0.133.0 (2024-08-27) -### Feature +### Features + +* feat: improve performance of ip address caching (#1392) ([`f7c7708`](https://github.com/python-zeroconf/python-zeroconf/commit/f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2)) + +* feat: enable building of arm64 macOS builds (#1384) + +Co-authored-by: Alex Ciobanu +Co-authored-by: J. Nick Koston ([`0df2ce0`](https://github.com/python-zeroconf/python-zeroconf/commit/0df2ce0e6f7313831da6a63d477019982d5df55c)) + +* feat: add classifier for python 3.13 (#1393) ([`7fb2bb2`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb2bb21421c70db0eb288fa7e73d955f58b0f5d)) + +* feat: python 3.13 support (#1390) ([`98cfa83`](https://github.com/python-zeroconf/python-zeroconf/commit/98cfa83710e43880698353821bae61108b08cb2f)) -* Improve performance of ip address caching ([#1392](https://github.com/python-zeroconf/python-zeroconf/issues/1392)) ([`f7c7708`](https://github.com/python-zeroconf/python-zeroconf/commit/f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2)) -* Enable building of arm64 macOS builds ([#1384](https://github.com/python-zeroconf/python-zeroconf/issues/1384)) ([`0df2ce0`](https://github.com/python-zeroconf/python-zeroconf/commit/0df2ce0e6f7313831da6a63d477019982d5df55c)) -* Add classifier for python 3.13 ([#1393](https://github.com/python-zeroconf/python-zeroconf/issues/1393)) ([`7fb2bb2`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb2bb21421c70db0eb288fa7e73d955f58b0f5d)) -* Python 3.13 support ([#1390](https://github.com/python-zeroconf/python-zeroconf/issues/1390)) ([`98cfa83`](https://github.com/python-zeroconf/python-zeroconf/commit/98cfa83710e43880698353821bae61108b08cb2f)) ## v0.132.2 (2024-04-13) -### Fix +### Bug Fixes + +* fix: update references to minimum-supported python version of 3.8 (#1369) ([`599524a`](https://github.com/python-zeroconf/python-zeroconf/commit/599524a5ce1e4c1731519dd89377c2a852e59935)) + +* fix: bump cibuildwheel to fix wheel builds (#1371) ([`83e4ce3`](https://github.com/python-zeroconf/python-zeroconf/commit/83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210)) -* Update references to minimum-supported python version of 3.8 ([#1369](https://github.com/python-zeroconf/python-zeroconf/issues/1369)) ([`599524a`](https://github.com/python-zeroconf/python-zeroconf/commit/599524a5ce1e4c1731519dd89377c2a852e59935)) -* Bump cibuildwheel to fix wheel builds ([#1371](https://github.com/python-zeroconf/python-zeroconf/issues/1371)) ([`83e4ce3`](https://github.com/python-zeroconf/python-zeroconf/commit/83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210)) ## v0.132.1 (2024-04-12) -### Fix +### Bug Fixes + +* fix: set change during iteration when dispatching listeners (#1370) ([`e9f8aa5`](https://github.com/python-zeroconf/python-zeroconf/commit/e9f8aa5741ae2d490c33a562b459f0af1014dbb0)) -* Set change during iteration when dispatching listeners ([#1370](https://github.com/python-zeroconf/python-zeroconf/issues/1370)) ([`e9f8aa5`](https://github.com/python-zeroconf/python-zeroconf/commit/e9f8aa5741ae2d490c33a562b459f0af1014dbb0)) ## v0.132.0 (2024-04-01) -### Feature +### Bug Fixes + +* fix: avoid including scope_id in IPv6Address object if its zero (#1367) ([`edc4a55`](https://github.com/python-zeroconf/python-zeroconf/commit/edc4a556819956c238a11332052000dcbcb07e3d)) + +### Features -* Make async_get_service_info available on the Zeroconf object ([#1366](https://github.com/python-zeroconf/python-zeroconf/issues/1366)) ([`c4c2dee`](https://github.com/python-zeroconf/python-zeroconf/commit/c4c2deeb05279ddbb0eba1330c7ae58795fea001)) -* Drop python 3.7 support ([#1359](https://github.com/python-zeroconf/python-zeroconf/issues/1359)) ([`4877829`](https://github.com/python-zeroconf/python-zeroconf/commit/4877829e6442de5426db152d11827b1ba85dbf59)) +* feat: make async_get_service_info available on the Zeroconf object (#1366) ([`c4c2dee`](https://github.com/python-zeroconf/python-zeroconf/commit/c4c2deeb05279ddbb0eba1330c7ae58795fea001)) -### Fix +* feat: drop python 3.7 support (#1359) ([`4877829`](https://github.com/python-zeroconf/python-zeroconf/commit/4877829e6442de5426db152d11827b1ba85dbf59)) -* Avoid including scope_id in IPv6Address object if its zero ([#1367](https://github.com/python-zeroconf/python-zeroconf/issues/1367)) ([`edc4a55`](https://github.com/python-zeroconf/python-zeroconf/commit/edc4a556819956c238a11332052000dcbcb07e3d)) ## v0.131.0 (2023-12-19) -### Feature +### Features + +* feat: small speed up to constructing outgoing packets (#1354) ([`517d7d0`](https://github.com/python-zeroconf/python-zeroconf/commit/517d7d00ca7738c770077738125aec0e4824c000)) + +* feat: speed up processing incoming packets (#1352) ([`6c15325`](https://github.com/python-zeroconf/python-zeroconf/commit/6c153258a995cf9459a6f23267b7e379b5e2550f)) + +* feat: speed up the query handler (#1350) ([`9eac0a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb)) -* Small speed up to constructing outgoing packets ([#1354](https://github.com/python-zeroconf/python-zeroconf/issues/1354)) ([`517d7d0`](https://github.com/python-zeroconf/python-zeroconf/commit/517d7d00ca7738c770077738125aec0e4824c000)) -* Speed up processing incoming packets ([#1352](https://github.com/python-zeroconf/python-zeroconf/issues/1352)) ([`6c15325`](https://github.com/python-zeroconf/python-zeroconf/commit/6c153258a995cf9459a6f23267b7e379b5e2550f)) -* Speed up the query handler ([#1350](https://github.com/python-zeroconf/python-zeroconf/issues/1350)) ([`9eac0a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb)) ## v0.130.0 (2023-12-16) -### Feature +### Bug Fixes + +* fix: scheduling race with the QueryScheduler (#1347) ([`cf40470`](https://github.com/python-zeroconf/python-zeroconf/commit/cf40470b89f918d3c24d7889d3536f3ffa44846c)) + +* fix: ensure question history suppresses duplicates (#1338) ([`6f23656`](https://github.com/python-zeroconf/python-zeroconf/commit/6f23656576daa04e3de44e100f3ddd60ee4c560d)) + +* fix: microsecond precision loss in the query handler (#1339) ([`6560fad`](https://github.com/python-zeroconf/python-zeroconf/commit/6560fad584e0d392962c9a9248759f17c416620e)) + +* fix: ensure IPv6 scoped address construction uses the string cache (#1336) ([`f78a196`](https://github.com/python-zeroconf/python-zeroconf/commit/f78a196db632c4fe017a34f1af8a58903c15a575)) + +### Features + +* feat: make ServiceInfo aware of question history (#1348) ([`b9aae1d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9aae1de07bf1491e873bc314f8a1d7996127ad3)) + +* feat: small speed up to ServiceInfo construction (#1346) ([`b329d99`](https://github.com/python-zeroconf/python-zeroconf/commit/b329d99917bb731b4c70bf20c7c010eeb85ad9fd)) + +* feat: significantly improve efficiency of the ServiceBrowser scheduler (#1335) ([`c65d869`](https://github.com/python-zeroconf/python-zeroconf/commit/c65d869aec731b803484871e9d242a984f9f5848)) + +* feat: small speed up to processing incoming records (#1345) ([`7de655b`](https://github.com/python-zeroconf/python-zeroconf/commit/7de655b6f05012f20a3671e0bcdd44a1913d7b52)) -* Make ServiceInfo aware of question history ([#1348](https://github.com/python-zeroconf/python-zeroconf/issues/1348)) ([`b9aae1d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9aae1de07bf1491e873bc314f8a1d7996127ad3)) -* Small speed up to ServiceInfo construction ([#1346](https://github.com/python-zeroconf/python-zeroconf/issues/1346)) ([`b329d99`](https://github.com/python-zeroconf/python-zeroconf/commit/b329d99917bb731b4c70bf20c7c010eeb85ad9fd)) -* Significantly improve efficiency of the ServiceBrowser scheduler ([#1335](https://github.com/python-zeroconf/python-zeroconf/issues/1335)) ([`c65d869`](https://github.com/python-zeroconf/python-zeroconf/commit/c65d869aec731b803484871e9d242a984f9f5848)) -* Small speed up to processing incoming records ([#1345](https://github.com/python-zeroconf/python-zeroconf/issues/1345)) ([`7de655b`](https://github.com/python-zeroconf/python-zeroconf/commit/7de655b6f05012f20a3671e0bcdd44a1913d7b52)) -* Small performance improvement for converting time ([#1342](https://github.com/python-zeroconf/python-zeroconf/issues/1342)) ([`73d3ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/73d3ab90dd3b59caab771235dd6dbedf05bfe0b3)) -* Small performance improvement for ServiceInfo asking questions ([#1341](https://github.com/python-zeroconf/python-zeroconf/issues/1341)) ([`810a309`](https://github.com/python-zeroconf/python-zeroconf/commit/810a3093c5a9411ee97740b468bd706bdf4a95de)) -* Small performance improvement constructing outgoing questions ([#1340](https://github.com/python-zeroconf/python-zeroconf/issues/1340)) ([`157185f`](https://github.com/python-zeroconf/python-zeroconf/commit/157185f28bf1e83e6811e2a5cd1fa9b38966f780)) +* feat: small performance improvement for converting time (#1342) ([`73d3ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/73d3ab90dd3b59caab771235dd6dbedf05bfe0b3)) -### Fix +* feat: small performance improvement for ServiceInfo asking questions (#1341) ([`810a309`](https://github.com/python-zeroconf/python-zeroconf/commit/810a3093c5a9411ee97740b468bd706bdf4a95de)) + +* feat: small performance improvement constructing outgoing questions (#1340) ([`157185f`](https://github.com/python-zeroconf/python-zeroconf/commit/157185f28bf1e83e6811e2a5cd1fa9b38966f780)) -* Scheduling race with the QueryScheduler ([#1347](https://github.com/python-zeroconf/python-zeroconf/issues/1347)) ([`cf40470`](https://github.com/python-zeroconf/python-zeroconf/commit/cf40470b89f918d3c24d7889d3536f3ffa44846c)) -* Ensure question history suppresses duplicates ([#1338](https://github.com/python-zeroconf/python-zeroconf/issues/1338)) ([`6f23656`](https://github.com/python-zeroconf/python-zeroconf/commit/6f23656576daa04e3de44e100f3ddd60ee4c560d)) -* Microsecond precision loss in the query handler ([#1339](https://github.com/python-zeroconf/python-zeroconf/issues/1339)) ([`6560fad`](https://github.com/python-zeroconf/python-zeroconf/commit/6560fad584e0d392962c9a9248759f17c416620e)) -* Ensure IPv6 scoped address construction uses the string cache ([#1336](https://github.com/python-zeroconf/python-zeroconf/issues/1336)) ([`f78a196`](https://github.com/python-zeroconf/python-zeroconf/commit/f78a196db632c4fe017a34f1af8a58903c15a575)) ## v0.129.0 (2023-12-13) -### Feature +### Features + +* feat: add decoded_properties method to ServiceInfo (#1332) ([`9b595a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9b595a1dcacf109c699953219d70fe36296c7318)) -* Add decoded_properties method to ServiceInfo ([#1332](https://github.com/python-zeroconf/python-zeroconf/issues/1332)) ([`9b595a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9b595a1dcacf109c699953219d70fe36296c7318)) -* Ensure ServiceInfo.properties always returns bytes ([#1333](https://github.com/python-zeroconf/python-zeroconf/issues/1333)) ([`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) -* Cache is_unspecified for zeroconf ip address objects ([#1331](https://github.com/python-zeroconf/python-zeroconf/issues/1331)) ([`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) +* feat: ensure ServiceInfo.properties always returns bytes (#1333) ([`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) -### Technically breaking change +* feat: cache is_unspecified for zeroconf ip address objects (#1331) ([`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) -* `ServiceInfo.properties` always returns a dictionary with type `dict[bytes, bytes | None]` instead of a mix `str` and `bytes`. It was only possible to get a mixed dictionary if it was manually passed in when `ServiceInfo` was constructed. ## v0.128.5 (2023-12-13) -### Fix +### Bug Fixes + +* fix: performance regression with ServiceInfo IPv6Addresses (#1330) ([`e2f9f81`](https://github.com/python-zeroconf/python-zeroconf/commit/e2f9f81dbc54c3dd527eeb3298897d63f99d33f4)) -* Performance regression with ServiceInfo IPv6Addresses ([#1330](https://github.com/python-zeroconf/python-zeroconf/issues/1330)) ([`e2f9f81`](https://github.com/python-zeroconf/python-zeroconf/commit/e2f9f81dbc54c3dd527eeb3298897d63f99d33f4)) ## v0.128.4 (2023-12-10) -### Fix +### Bug Fixes + +* fix: re-expose ServiceInfo._set_properties for backwards compat (#1327) ([`39c4005`](https://github.com/python-zeroconf/python-zeroconf/commit/39c40051d7a63bdc63a3e2dfa20bd944fee4e761)) -* Re-expose ServiceInfo._set_properties for backwards compat ([#1327](https://github.com/python-zeroconf/python-zeroconf/issues/1327)) ([`39c4005`](https://github.com/python-zeroconf/python-zeroconf/commit/39c40051d7a63bdc63a3e2dfa20bd944fee4e761)) ## v0.128.3 (2023-12-10) -### Fix +### Bug Fixes + +* fix: correct nsec record writing (#1326) ([`cd7a16a`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7a16a32c37b2f7a2e90d3c749525a5393bad57)) -* Correct nsec record writing ([#1326](https://github.com/python-zeroconf/python-zeroconf/issues/1326)) ([`cd7a16a`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7a16a32c37b2f7a2e90d3c749525a5393bad57)) ## v0.128.2 (2023-12-10) -### Fix +### Bug Fixes + +* fix: timestamps missing double precision (#1324) ([`ecea4e4`](https://github.com/python-zeroconf/python-zeroconf/commit/ecea4e4217892ca8cf763074ac3e5d1b898acd21)) + +* fix: match cython version for dev deps to build deps (#1325) ([`a0dac46`](https://github.com/python-zeroconf/python-zeroconf/commit/a0dac46c01202b3d5a0823ac1928fc1d75332522)) -* Timestamps missing double precision ([#1324](https://github.com/python-zeroconf/python-zeroconf/issues/1324)) ([`ecea4e4`](https://github.com/python-zeroconf/python-zeroconf/commit/ecea4e4217892ca8cf763074ac3e5d1b898acd21)) -* Match cython version for dev deps to build deps ([#1325](https://github.com/python-zeroconf/python-zeroconf/issues/1325)) ([`a0dac46`](https://github.com/python-zeroconf/python-zeroconf/commit/a0dac46c01202b3d5a0823ac1928fc1d75332522)) ## v0.128.1 (2023-12-10) -### Fix +### Bug Fixes + +* fix: correct handling of IPv6 addresses with scope_id in ServiceInfo (#1322) ([`1682991`](https://github.com/python-zeroconf/python-zeroconf/commit/1682991b985b1f7b2bf0cff1a7eb7793070e7cb1)) -* Correct handling of IPv6 addresses with scope_id in ServiceInfo ([#1322](https://github.com/python-zeroconf/python-zeroconf/issues/1322)) ([`1682991`](https://github.com/python-zeroconf/python-zeroconf/commit/1682991b985b1f7b2bf0cff1a7eb7793070e7cb1)) ## v0.128.0 (2023-12-02) -### Feature +### Features + +* feat: speed up unpacking TXT record data in ServiceInfo (#1318) ([`a200842`](https://github.com/python-zeroconf/python-zeroconf/commit/a20084281e66bdb9c37183a5eb992435f5b866ac)) -* Speed up unpacking TXT record data in ServiceInfo ([#1318](https://github.com/python-zeroconf/python-zeroconf/issues/1318)) ([`a200842`](https://github.com/python-zeroconf/python-zeroconf/commit/a20084281e66bdb9c37183a5eb992435f5b866ac)) ## v0.127.0 (2023-11-15) -### Feature +### Features + +* feat: small speed up to writing outgoing packets (#1316) ([`cd28476`](https://github.com/python-zeroconf/python-zeroconf/commit/cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65)) + +* feat: speed up incoming packet reader (#1314) ([`0d60b61`](https://github.com/python-zeroconf/python-zeroconf/commit/0d60b61538a5d4b6f44b2369333b6e916a0a55b4)) + +* feat: small speed up to processing incoming dns records (#1315) ([`bfe4c24`](https://github.com/python-zeroconf/python-zeroconf/commit/bfe4c24881a7259713425df5ab00ffe487518841)) -* Small speed up to writing outgoing packets ([#1316](https://github.com/python-zeroconf/python-zeroconf/issues/1316)) ([`cd28476`](https://github.com/python-zeroconf/python-zeroconf/commit/cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65)) -* Speed up incoming packet reader ([#1314](https://github.com/python-zeroconf/python-zeroconf/issues/1314)) ([`0d60b61`](https://github.com/python-zeroconf/python-zeroconf/commit/0d60b61538a5d4b6f44b2369333b6e916a0a55b4)) -* Small speed up to processing incoming dns records ([#1315](https://github.com/python-zeroconf/python-zeroconf/issues/1315)) ([`bfe4c24`](https://github.com/python-zeroconf/python-zeroconf/commit/bfe4c24881a7259713425df5ab00ffe487518841)) ## v0.126.0 (2023-11-13) -### Feature +### Features + +* feat: speed up outgoing packet writer (#1313) ([`55cf4cc`](https://github.com/python-zeroconf/python-zeroconf/commit/55cf4ccdff886a136db4e2133d3e6cdd001a8bd6)) + +* feat: speed up writing name compression for outgoing packets (#1312) ([`9caeabb`](https://github.com/python-zeroconf/python-zeroconf/commit/9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b)) -* Speed up outgoing packet writer ([#1313](https://github.com/python-zeroconf/python-zeroconf/issues/1313)) ([`55cf4cc`](https://github.com/python-zeroconf/python-zeroconf/commit/55cf4ccdff886a136db4e2133d3e6cdd001a8bd6)) -* Speed up writing name compression for outgoing packets ([#1312](https://github.com/python-zeroconf/python-zeroconf/issues/1312)) ([`9caeabb`](https://github.com/python-zeroconf/python-zeroconf/commit/9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b)) ## v0.125.0 (2023-11-12) -### Feature +### Features + +* feat: speed up service browser queries when browsing many types (#1311) ([`d192d33`](https://github.com/python-zeroconf/python-zeroconf/commit/d192d33b1f05aa95a89965e86210aec086673a17)) -* Speed up service browser queries when browsing many types ([#1311](https://github.com/python-zeroconf/python-zeroconf/issues/1311)) ([`d192d33`](https://github.com/python-zeroconf/python-zeroconf/commit/d192d33b1f05aa95a89965e86210aec086673a17)) ## v0.124.0 (2023-11-12) -### Feature +### Features + +* feat: avoid decoding known answers if we have no answers to give (#1308) ([`605dc9c`](https://github.com/python-zeroconf/python-zeroconf/commit/605dc9ccd843a535802031f051b3d93310186ad1)) + +* feat: small speed up to process incoming packets (#1309) ([`56ef908`](https://github.com/python-zeroconf/python-zeroconf/commit/56ef90865189c01d2207abcc5e2efe3a7a022fa1)) -* Avoid decoding known answers if we have no answers to give ([#1308](https://github.com/python-zeroconf/python-zeroconf/issues/1308)) ([`605dc9c`](https://github.com/python-zeroconf/python-zeroconf/commit/605dc9ccd843a535802031f051b3d93310186ad1)) -* Small speed up to process incoming packets ([#1309](https://github.com/python-zeroconf/python-zeroconf/issues/1309)) ([`56ef908`](https://github.com/python-zeroconf/python-zeroconf/commit/56ef90865189c01d2207abcc5e2efe3a7a022fa1)) ## v0.123.0 (2023-11-12) -### Feature +### Features + +* feat: speed up instances only used to lookup answers (#1307) ([`0701b8a`](https://github.com/python-zeroconf/python-zeroconf/commit/0701b8ab6009891cbaddaa1d17116d31fd1b2f78)) -* Speed up instances only used to lookup answers ([#1307](https://github.com/python-zeroconf/python-zeroconf/issues/1307)) ([`0701b8a`](https://github.com/python-zeroconf/python-zeroconf/commit/0701b8ab6009891cbaddaa1d17116d31fd1b2f78)) ## v0.122.3 (2023-11-09) -### Fix +### Bug Fixes + +* fix: do not build musllinux aarch64 wheels to reduce release time (#1306) ([`79aafb0`](https://github.com/python-zeroconf/python-zeroconf/commit/79aafb0acf7ca6b17976be7ede748008deada27b)) -* Do not build musllinux aarch64 wheels to reduce release time ([#1306](https://github.com/python-zeroconf/python-zeroconf/issues/1306)) ([`79aafb0`](https://github.com/python-zeroconf/python-zeroconf/commit/79aafb0acf7ca6b17976be7ede748008deada27b)) ## v0.122.2 (2023-11-09) -### Fix +### Bug Fixes + +* fix: do not build aarch64 wheels for PyPy (#1305) ([`7e884db`](https://github.com/python-zeroconf/python-zeroconf/commit/7e884db4d958459e64257aba860dba2450db0687)) -* Do not build aarch64 wheels for PyPy ([#1305](https://github.com/python-zeroconf/python-zeroconf/issues/1305)) ([`7e884db`](https://github.com/python-zeroconf/python-zeroconf/commit/7e884db4d958459e64257aba860dba2450db0687)) ## v0.122.1 (2023-11-09) -### Fix +### Bug Fixes + +* fix: skip wheel builds for eol python and older python with aarch64 (#1304) ([`6c8f5a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6c8f5a5dec2072aa6a8f889c5d8a4623ab392234)) -* Skip wheel builds for eol python and older python with aarch64 ([#1304](https://github.com/python-zeroconf/python-zeroconf/issues/1304)) ([`6c8f5a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6c8f5a5dec2072aa6a8f889c5d8a4623ab392234)) ## v0.122.0 (2023-11-08) -### Feature +### Features + +* feat: build aarch64 wheels (#1302) ([`4fe58e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83)) -* Build aarch64 wheels ([#1302](https://github.com/python-zeroconf/python-zeroconf/issues/1302)) ([`4fe58e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83)) ## v0.121.0 (2023-11-08) -### Feature +### Features + +* feat: speed up record updates (#1301) ([`d2af6a0`](https://github.com/python-zeroconf/python-zeroconf/commit/d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4)) -* Speed up record updates ([#1301](https://github.com/python-zeroconf/python-zeroconf/issues/1301)) ([`d2af6a0`](https://github.com/python-zeroconf/python-zeroconf/commit/d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4)) ## v0.120.0 (2023-11-05) -### Feature +### Features + +* feat: speed up incoming packet processing with a memory view (#1290) ([`f1f0a25`](https://github.com/python-zeroconf/python-zeroconf/commit/f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7)) + +* feat: speed up decoding labels from incoming data (#1291) ([`c37ead4`](https://github.com/python-zeroconf/python-zeroconf/commit/c37ead4d7000607e81706a97b4cdffd80cf8cf99)) + +* feat: speed up ServiceBrowsers with a pxd for the signal interface (#1289) ([`8a17f20`](https://github.com/python-zeroconf/python-zeroconf/commit/8a17f2053a89db4beca9e8c1de4640faf27726b4)) -* Speed up incoming packet processing with a memory view ([#1290](https://github.com/python-zeroconf/python-zeroconf/issues/1290)) ([`f1f0a25`](https://github.com/python-zeroconf/python-zeroconf/commit/f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7)) -* Speed up decoding labels from incoming data ([#1291](https://github.com/python-zeroconf/python-zeroconf/issues/1291)) ([`c37ead4`](https://github.com/python-zeroconf/python-zeroconf/commit/c37ead4d7000607e81706a97b4cdffd80cf8cf99)) -* Speed up ServiceBrowsers with a pxd for the signal interface ([#1289](https://github.com/python-zeroconf/python-zeroconf/issues/1289)) ([`8a17f20`](https://github.com/python-zeroconf/python-zeroconf/commit/8a17f2053a89db4beca9e8c1de4640faf27726b4)) ## v0.119.0 (2023-10-18) -### Feature +### Features + +* feat: update cibuildwheel to build wheels on latest cython final release (#1285) ([`e8c9083`](https://github.com/python-zeroconf/python-zeroconf/commit/e8c9083bb118764a85b12fac9055152a2f62a212)) -* Update cibuildwheel to build wheels on latest cython final release ([#1285](https://github.com/python-zeroconf/python-zeroconf/issues/1285)) ([`e8c9083`](https://github.com/python-zeroconf/python-zeroconf/commit/e8c9083bb118764a85b12fac9055152a2f62a212)) ## v0.118.1 (2023-10-18) -### Fix +### Bug Fixes + +* fix: reduce size of wheels by excluding generated .c files (#1284) ([`b6afa4b`](https://github.com/python-zeroconf/python-zeroconf/commit/b6afa4b2775a1fdb090145eccdc5711c98e7147a)) -* Reduce size of wheels by excluding generated .c files ([#1284](https://github.com/python-zeroconf/python-zeroconf/issues/1284)) ([`b6afa4b`](https://github.com/python-zeroconf/python-zeroconf/commit/b6afa4b2775a1fdb090145eccdc5711c98e7147a)) ## v0.118.0 (2023-10-14) -### Feature +### Features + +* feat: small improvements to ServiceBrowser performance (#1283) ([`0fc031b`](https://github.com/python-zeroconf/python-zeroconf/commit/0fc031b1e7bf1766d5a1d39d70d300b86e36715e)) -* Small improvements to ServiceBrowser performance ([#1283](https://github.com/python-zeroconf/python-zeroconf/issues/1283)) ([`0fc031b`](https://github.com/python-zeroconf/python-zeroconf/commit/0fc031b1e7bf1766d5a1d39d70d300b86e36715e)) ## v0.117.0 (2023-10-14) -### Feature +### Features + +* feat: small cleanups to incoming data handlers (#1282) ([`4f4bd9f`](https://github.com/python-zeroconf/python-zeroconf/commit/4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9)) -* Small cleanups to incoming data handlers ([#1282](https://github.com/python-zeroconf/python-zeroconf/issues/1282)) ([`4f4bd9f`](https://github.com/python-zeroconf/python-zeroconf/commit/4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9)) ## v0.116.0 (2023-10-13) -### Feature +### Features + +* feat: reduce type checking overhead at run time (#1281) ([`8f30099`](https://github.com/python-zeroconf/python-zeroconf/commit/8f300996e5bd4316b2237f0502791dd0d6a855fe)) -* Reduce type checking overhead at run time ([#1281](https://github.com/python-zeroconf/python-zeroconf/issues/1281)) ([`8f30099`](https://github.com/python-zeroconf/python-zeroconf/commit/8f300996e5bd4316b2237f0502791dd0d6a855fe)) ## v0.115.2 (2023-10-05) -### Fix +### Bug Fixes + +* fix: ensure ServiceInfo cache is cleared when adding to the registry (#1279) + +* There were production use cases that mutated the service info and re-registered it that need to be accounted for ([`2060eb2`](https://github.com/python-zeroconf/python-zeroconf/commit/2060eb2cc43489c34bea08924c3f40b875d5a498)) -* Ensure ServiceInfo cache is cleared when adding to the registry ([#1279](https://github.com/python-zeroconf/python-zeroconf/issues/1279)) ([`2060eb2`](https://github.com/python-zeroconf/python-zeroconf/commit/2060eb2cc43489c34bea08924c3f40b875d5a498)) ## v0.115.1 (2023-10-01) -### Fix +### Bug Fixes + +* fix: add missing python definition for addresses_by_version (#1278) ([`52ee02b`](https://github.com/python-zeroconf/python-zeroconf/commit/52ee02b16860e344c402124f4b2e2869536ec839)) -* Add missing python definition for addresses_by_version ([#1278](https://github.com/python-zeroconf/python-zeroconf/issues/1278)) ([`52ee02b`](https://github.com/python-zeroconf/python-zeroconf/commit/52ee02b16860e344c402124f4b2e2869536ec839)) ## v0.115.0 (2023-09-26) -### Feature +### Features + +* feat: speed up outgoing multicast queue (#1277) ([`a13fd49`](https://github.com/python-zeroconf/python-zeroconf/commit/a13fd49d77474fd5858de809e48cbab1ccf89173)) -* Speed up outgoing multicast queue ([#1277](https://github.com/python-zeroconf/python-zeroconf/issues/1277)) ([`a13fd49`](https://github.com/python-zeroconf/python-zeroconf/commit/a13fd49d77474fd5858de809e48cbab1ccf89173)) ## v0.114.0 (2023-09-25) -### Feature +### Features + +* feat: speed up responding to queries (#1275) ([`3c6b18c`](https://github.com/python-zeroconf/python-zeroconf/commit/3c6b18cdf4c94773ad6f4497df98feb337939ee9)) -* Speed up responding to queries ([#1275](https://github.com/python-zeroconf/python-zeroconf/issues/1275)) ([`3c6b18c`](https://github.com/python-zeroconf/python-zeroconf/commit/3c6b18cdf4c94773ad6f4497df98feb337939ee9)) ## v0.113.0 (2023-09-24) -### Feature +### Features + +* feat: improve performance of loading records from cache in ServiceInfo (#1274) ([`6257d49`](https://github.com/python-zeroconf/python-zeroconf/commit/6257d49952e02107f800f4ad4894716508edfcda)) -* Improve performance of loading records from cache in ServiceInfo ([#1274](https://github.com/python-zeroconf/python-zeroconf/issues/1274)) ([`6257d49`](https://github.com/python-zeroconf/python-zeroconf/commit/6257d49952e02107f800f4ad4894716508edfcda)) ## v0.112.0 (2023-09-14) -### Feature +### Features + +* feat: improve AsyncServiceBrowser performance (#1273) ([`0c88ecf`](https://github.com/python-zeroconf/python-zeroconf/commit/0c88ecf5ef6b9b256f991e7a630048de640999a6)) -* Improve AsyncServiceBrowser performance ([#1273](https://github.com/python-zeroconf/python-zeroconf/issues/1273)) ([`0c88ecf`](https://github.com/python-zeroconf/python-zeroconf/commit/0c88ecf5ef6b9b256f991e7a630048de640999a6)) ## v0.111.0 (2023-09-14) -### Feature +### Features + +* feat: speed up question and answer internals (#1272) ([`d24722b`](https://github.com/python-zeroconf/python-zeroconf/commit/d24722bfa4201d48ab482d35b0ef004f070ada80)) -* Speed up question and answer internals ([#1272](https://github.com/python-zeroconf/python-zeroconf/issues/1272)) ([`d24722b`](https://github.com/python-zeroconf/python-zeroconf/commit/d24722bfa4201d48ab482d35b0ef004f070ada80)) ## v0.110.0 (2023-09-14) -### Feature +### Features + +* feat: small speed ups to ServiceBrowser (#1271) ([`22c433d`](https://github.com/python-zeroconf/python-zeroconf/commit/22c433ddaea3049ac49933325ba938fd87a529c0)) -* Small speed ups to ServiceBrowser ([#1271](https://github.com/python-zeroconf/python-zeroconf/issues/1271)) ([`22c433d`](https://github.com/python-zeroconf/python-zeroconf/commit/22c433ddaea3049ac49933325ba938fd87a529c0)) ## v0.109.0 (2023-09-14) -### Feature +### Features + +* feat: speed up ServiceBrowsers with a cython pxd (#1270) ([`4837876`](https://github.com/python-zeroconf/python-zeroconf/commit/48378769c3887b5746ca00de30067a4c0851765c)) -* Speed up ServiceBrowsers with a cython pxd ([#1270](https://github.com/python-zeroconf/python-zeroconf/issues/1270)) ([`4837876`](https://github.com/python-zeroconf/python-zeroconf/commit/48378769c3887b5746ca00de30067a4c0851765c)) ## v0.108.0 (2023-09-11) -### Feature +### Features + +* feat: improve performance of constructing outgoing queries (#1267) ([`00c439a`](https://github.com/python-zeroconf/python-zeroconf/commit/00c439a6400b7850ef9fdd75bc8d82d4e64b1da0)) -* Improve performance of constructing outgoing queries ([#1267](https://github.com/python-zeroconf/python-zeroconf/issues/1267)) ([`00c439a`](https://github.com/python-zeroconf/python-zeroconf/commit/00c439a6400b7850ef9fdd75bc8d82d4e64b1da0)) ## v0.107.0 (2023-09-11) -### Feature +### Features + +* feat: speed up responding to queries (#1266) ([`24a0a00`](https://github.com/python-zeroconf/python-zeroconf/commit/24a0a00b3e457979e279a2eeadc8fad2ab09e125)) -* Speed up responding to queries ([#1266](https://github.com/python-zeroconf/python-zeroconf/issues/1266)) ([`24a0a00`](https://github.com/python-zeroconf/python-zeroconf/commit/24a0a00b3e457979e279a2eeadc8fad2ab09e125)) ## v0.106.0 (2023-09-11) -### Feature +### Features + +* feat: speed up answering questions (#1265) ([`37bfaf2`](https://github.com/python-zeroconf/python-zeroconf/commit/37bfaf2f630358e8c68652f3b3120931a6f94910)) -* Speed up answering questions ([#1265](https://github.com/python-zeroconf/python-zeroconf/issues/1265)) ([`37bfaf2`](https://github.com/python-zeroconf/python-zeroconf/commit/37bfaf2f630358e8c68652f3b3120931a6f94910)) ## v0.105.0 (2023-09-10) -### Feature +### Features + +* feat: speed up ServiceInfo with a cython pxd (#1264) ([`7ca690a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ca690ac3fa75e7474d3412944bbd5056cb313dd)) -* Speed up ServiceInfo with a cython pxd ([#1264](https://github.com/python-zeroconf/python-zeroconf/issues/1264)) ([`7ca690a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ca690ac3fa75e7474d3412944bbd5056cb313dd)) ## v0.104.0 (2023-09-10) -### Feature +### Features + +* feat: speed up generating answers (#1262) ([`50a8f06`](https://github.com/python-zeroconf/python-zeroconf/commit/50a8f066b6ab90bc9e3300f81cf9332550b720df)) -* Speed up generating answers ([#1262](https://github.com/python-zeroconf/python-zeroconf/issues/1262)) ([`50a8f06`](https://github.com/python-zeroconf/python-zeroconf/commit/50a8f066b6ab90bc9e3300f81cf9332550b720df)) ## v0.103.0 (2023-09-09) -### Feature +### Features + +* feat: avoid calling get_running_loop when resolving ServiceInfo (#1261) ([`33a2714`](https://github.com/python-zeroconf/python-zeroconf/commit/33a2714cadff96edf016b869cc63b0661d16ef2c)) -* Avoid calling get_running_loop when resolving ServiceInfo ([#1261](https://github.com/python-zeroconf/python-zeroconf/issues/1261)) ([`33a2714`](https://github.com/python-zeroconf/python-zeroconf/commit/33a2714cadff96edf016b869cc63b0661d16ef2c)) ## v0.102.0 (2023-09-07) -### Feature +### Features + +* feat: significantly speed up writing outgoing dns records (#1260) ([`bf2f366`](https://github.com/python-zeroconf/python-zeroconf/commit/bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077)) -* Significantly speed up writing outgoing dns records ([#1260](https://github.com/python-zeroconf/python-zeroconf/issues/1260)) ([`bf2f366`](https://github.com/python-zeroconf/python-zeroconf/commit/bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077)) ## v0.101.0 (2023-09-07) -### Feature +### Features + +* feat: speed up writing outgoing dns records (#1259) ([`248655f`](https://github.com/python-zeroconf/python-zeroconf/commit/248655f0276223b089373c70ec13a0385dfaa4d6)) -* Speed up writing outgoing dns records ([#1259](https://github.com/python-zeroconf/python-zeroconf/issues/1259)) ([`248655f`](https://github.com/python-zeroconf/python-zeroconf/commit/248655f0276223b089373c70ec13a0385dfaa4d6)) ## v0.100.0 (2023-09-07) -### Feature +### Features + +* feat: small speed up to writing outgoing dns records (#1258) ([`1ed6bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/1ed6bd2ec4db0612b71384f923ffff1efd3ce878)) -* Small speed up to writing outgoing dns records ([#1258](https://github.com/python-zeroconf/python-zeroconf/issues/1258)) ([`1ed6bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/1ed6bd2ec4db0612b71384f923ffff1efd3ce878)) ## v0.99.0 (2023-09-06) -### Feature +### Features + +* feat: reduce IP Address parsing overhead in ServiceInfo (#1257) ([`83d0b7f`](https://github.com/python-zeroconf/python-zeroconf/commit/83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb)) -* Reduce IP Address parsing overhead in ServiceInfo ([#1257](https://github.com/python-zeroconf/python-zeroconf/issues/1257)) ([`83d0b7f`](https://github.com/python-zeroconf/python-zeroconf/commit/83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb)) ## v0.98.0 (2023-09-06) -### Feature +### Features + +* feat: speed up decoding incoming packets (#1256) ([`ac081cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac081cf00addde1ceea2c076f73905fdb293de3a)) -* Speed up decoding incoming packets ([#1256](https://github.com/python-zeroconf/python-zeroconf/issues/1256)) ([`ac081cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac081cf00addde1ceea2c076f73905fdb293de3a)) ## v0.97.0 (2023-09-03) -### Feature +### Features + +* feat: speed up answering queries (#1255) ([`2d3aed3`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3aed36e24c73013fcf4acc90803fc1737d0917)) -* Speed up answering queries ([#1255](https://github.com/python-zeroconf/python-zeroconf/issues/1255)) ([`2d3aed3`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3aed36e24c73013fcf4acc90803fc1737d0917)) ## v0.96.0 (2023-09-03) -### Feature +### Features + +* feat: optimize DNSCache.get_by_details (#1254) + +* feat: optimize DNSCache.get_by_details + +This is one of the most called functions since ServiceInfo.load_from_cache calls +it + +* fix: make get_all_by_details thread-safe + +* fix: remove unneeded key checks ([`ce59787`](https://github.com/python-zeroconf/python-zeroconf/commit/ce59787a170781ffdaa22425018d288b395ac081)) -* Optimize DNSCache.get_by_details ([#1254](https://github.com/python-zeroconf/python-zeroconf/issues/1254)) ([`ce59787`](https://github.com/python-zeroconf/python-zeroconf/commit/ce59787a170781ffdaa22425018d288b395ac081)) ## v0.95.0 (2023-09-03) -### Feature +### Features + +* feat: speed up adding and removing RecordUpdateListeners (#1253) ([`22e4a29`](https://github.com/python-zeroconf/python-zeroconf/commit/22e4a296d440b3038c0ff5ed6fc8878304ec4937)) -* Speed up adding and removing RecordUpdateListeners ([#1253](https://github.com/python-zeroconf/python-zeroconf/issues/1253)) ([`22e4a29`](https://github.com/python-zeroconf/python-zeroconf/commit/22e4a296d440b3038c0ff5ed6fc8878304ec4937)) ## v0.94.0 (2023-09-03) -### Feature +### Features + +* feat: optimize cache implementation (#1252) ([`8d3ec79`](https://github.com/python-zeroconf/python-zeroconf/commit/8d3ec792277aaf7ef790318b5b35ab00839ca3b3)) -* Optimize cache implementation ([#1252](https://github.com/python-zeroconf/python-zeroconf/issues/1252)) ([`8d3ec79`](https://github.com/python-zeroconf/python-zeroconf/commit/8d3ec792277aaf7ef790318b5b35ab00839ca3b3)) ## v0.93.1 (2023-09-03) -### Fix +### Bug Fixes + +* fix: no change re-release due to unrecoverable failed CI run (#1251) ([`730921b`](https://github.com/python-zeroconf/python-zeroconf/commit/730921b155dfb9c62251c8c643b1302e807aff3b)) -* No change re-release due to unrecoverable failed CI run ([#1251](https://github.com/python-zeroconf/python-zeroconf/issues/1251)) ([`730921b`](https://github.com/python-zeroconf/python-zeroconf/commit/730921b155dfb9c62251c8c643b1302e807aff3b)) ## v0.93.0 (2023-09-02) -### Feature +### Features + +* feat: reduce overhead to answer questions (#1250) ([`7cb8da0`](https://github.com/python-zeroconf/python-zeroconf/commit/7cb8da0c6c5c944588009fe36012c1197c422668)) -* Reduce overhead to answer questions ([#1250](https://github.com/python-zeroconf/python-zeroconf/issues/1250)) ([`7cb8da0`](https://github.com/python-zeroconf/python-zeroconf/commit/7cb8da0c6c5c944588009fe36012c1197c422668)) ## v0.92.0 (2023-09-02) -### Feature +### Features + +* feat: cache construction of records used to answer queries from the service registry (#1243) ([`0890f62`](https://github.com/python-zeroconf/python-zeroconf/commit/0890f628dbbd577fb77d3e6f2e267052b2b2b515)) -* Cache construction of records used to answer queries from the service registry ([#1243](https://github.com/python-zeroconf/python-zeroconf/issues/1243)) ([`0890f62`](https://github.com/python-zeroconf/python-zeroconf/commit/0890f628dbbd577fb77d3e6f2e267052b2b2b515)) ## v0.91.1 (2023-09-02) -### Fix +### Bug Fixes + +* fix: remove useless calls in ServiceInfo (#1248) ([`4e40fae`](https://github.com/python-zeroconf/python-zeroconf/commit/4e40fae20bf50b4608e28fad4a360c4ed48ac86b)) -* Remove useless calls in ServiceInfo ([#1248](https://github.com/python-zeroconf/python-zeroconf/issues/1248)) ([`4e40fae`](https://github.com/python-zeroconf/python-zeroconf/commit/4e40fae20bf50b4608e28fad4a360c4ed48ac86b)) ## v0.91.0 (2023-09-02) -### Feature +### Features + +* feat: reduce overhead to process incoming updates by avoiding the handle_response shim (#1247) ([`5e31f0a`](https://github.com/python-zeroconf/python-zeroconf/commit/5e31f0afe4c341fbdbbbe50348a829ea553cbda0)) -* Reduce overhead to process incoming updates by avoiding the handle_response shim ([#1247](https://github.com/python-zeroconf/python-zeroconf/issues/1247)) ([`5e31f0a`](https://github.com/python-zeroconf/python-zeroconf/commit/5e31f0afe4c341fbdbbbe50348a829ea553cbda0)) ## v0.90.0 (2023-09-02) -### Feature +### Features + +* feat: avoid python float conversion in listener hot path (#1245) ([`816ad4d`](https://github.com/python-zeroconf/python-zeroconf/commit/816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a)) + +### Refactoring + +* refactor: reduce duplicate code in engine.py (#1246) ([`36ae505`](https://github.com/python-zeroconf/python-zeroconf/commit/36ae505dc9f95b59fdfb632960845a45ba8575b8)) -* Avoid python float conversion in listener hot path ([#1245](https://github.com/python-zeroconf/python-zeroconf/issues/1245)) ([`816ad4d`](https://github.com/python-zeroconf/python-zeroconf/commit/816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a)) ## v0.89.0 (2023-09-02) -### Feature +### Features + +* feat: reduce overhead to process incoming questions (#1244) ([`18b65d1`](https://github.com/python-zeroconf/python-zeroconf/commit/18b65d1c75622869b0c29258215d3db3ae520d6c)) -* Reduce overhead to process incoming questions ([#1244](https://github.com/python-zeroconf/python-zeroconf/issues/1244)) ([`18b65d1`](https://github.com/python-zeroconf/python-zeroconf/commit/18b65d1c75622869b0c29258215d3db3ae520d6c)) ## v0.88.0 (2023-08-29) -### Feature +### Features + +* feat: speed up RecordManager with additional cython defs (#1242) ([`5a76fc5`](https://github.com/python-zeroconf/python-zeroconf/commit/5a76fc5ff74f2941ffbf7570e45390f35e0b7e01)) -* Speed up RecordManager with additional cython defs ([#1242](https://github.com/python-zeroconf/python-zeroconf/issues/1242)) ([`5a76fc5`](https://github.com/python-zeroconf/python-zeroconf/commit/5a76fc5ff74f2941ffbf7570e45390f35e0b7e01)) ## v0.87.0 (2023-08-29) -### Feature +### Features + +* feat: improve performance by adding cython pxd for RecordManager (#1241) ([`a7dad3d`](https://github.com/python-zeroconf/python-zeroconf/commit/a7dad3d9743586f352e21eea1e129c6875f9a713)) -* Improve performance by adding cython pxd for RecordManager ([#1241](https://github.com/python-zeroconf/python-zeroconf/issues/1241)) ([`a7dad3d`](https://github.com/python-zeroconf/python-zeroconf/commit/a7dad3d9743586f352e21eea1e129c6875f9a713)) ## v0.86.0 (2023-08-28) -### Feature +### Features + +* feat: build wheels for cpython 3.12 (#1239) ([`58bc154`](https://github.com/python-zeroconf/python-zeroconf/commit/58bc154f55b06b4ddfc4a141592488abe76f062a)) + +* feat: use server_key when processing DNSService records (#1238) ([`cc8feb1`](https://github.com/python-zeroconf/python-zeroconf/commit/cc8feb110fefc3fb714fd482a52f16e2b620e8c4)) -* Build wheels for cpython 3.12 ([#1239](https://github.com/python-zeroconf/python-zeroconf/issues/1239)) ([`58bc154`](https://github.com/python-zeroconf/python-zeroconf/commit/58bc154f55b06b4ddfc4a141592488abe76f062a)) -* Use server_key when processing DNSService records ([#1238](https://github.com/python-zeroconf/python-zeroconf/issues/1238)) ([`cc8feb1`](https://github.com/python-zeroconf/python-zeroconf/commit/cc8feb110fefc3fb714fd482a52f16e2b620e8c4)) ## v0.85.0 (2023-08-27) -### Feature +### Features + +* feat: simplify code to unpack properties (#1237) ([`68d9998`](https://github.com/python-zeroconf/python-zeroconf/commit/68d99985a0e9d2c72ff670b2e2af92271a6fe934)) -* Simplify code to unpack properties ([#1237](https://github.com/python-zeroconf/python-zeroconf/issues/1237)) ([`68d9998`](https://github.com/python-zeroconf/python-zeroconf/commit/68d99985a0e9d2c72ff670b2e2af92271a6fe934)) ## v0.84.0 (2023-08-27) -### Feature +### Features + +* feat: context managers in ServiceBrowser and AsyncServiceBrowser (#1233) + +Co-authored-by: J. Nick Koston ([`bd8d846`](https://github.com/python-zeroconf/python-zeroconf/commit/bd8d8467dec2a39a0b525043ea1051259100fded)) -* Context managers in ServiceBrowser and AsyncServiceBrowser ([#1233](https://github.com/python-zeroconf/python-zeroconf/issues/1233)) ([`bd8d846`](https://github.com/python-zeroconf/python-zeroconf/commit/bd8d8467dec2a39a0b525043ea1051259100fded)) ## v0.83.1 (2023-08-27) -### Fix +### Bug Fixes + +* fix: rebuild wheels with cython 3.0.2 (#1236) ([`dd637fb`](https://github.com/python-zeroconf/python-zeroconf/commit/dd637fb2e5a87ba283750e69d116e124bef54e7c)) -* Rebuild wheels with cython 3.0.2 ([#1236](https://github.com/python-zeroconf/python-zeroconf/issues/1236)) ([`dd637fb`](https://github.com/python-zeroconf/python-zeroconf/commit/dd637fb2e5a87ba283750e69d116e124bef54e7c)) ## v0.83.0 (2023-08-26) -### Feature +### Features + +* feat: speed up question and answer history with a cython pxd (#1234) ([`703ecb2`](https://github.com/python-zeroconf/python-zeroconf/commit/703ecb2901b2150fb72fac3deed61d7302561298)) -* Speed up question and answer history with a cython pxd ([#1234](https://github.com/python-zeroconf/python-zeroconf/issues/1234)) ([`703ecb2`](https://github.com/python-zeroconf/python-zeroconf/commit/703ecb2901b2150fb72fac3deed61d7302561298)) ## v0.82.1 (2023-08-22) -### Fix +### Bug Fixes + +* fix: build failures with older cython 0.29 series (#1232) ([`30c3ad9`](https://github.com/python-zeroconf/python-zeroconf/commit/30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced)) -* Build failures with older cython 0.29 series ([#1232](https://github.com/python-zeroconf/python-zeroconf/issues/1232)) ([`30c3ad9`](https://github.com/python-zeroconf/python-zeroconf/commit/30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced)) ## v0.82.0 (2023-08-22) -### Feature +### Features + +* feat: optimize processing of records in RecordUpdateListener subclasses (#1231) ([`3e89294`](https://github.com/python-zeroconf/python-zeroconf/commit/3e89294ea0ecee1122e1c1ffdc78925add8ca40e)) -* Optimize processing of records in RecordUpdateListener subclasses ([#1231](https://github.com/python-zeroconf/python-zeroconf/issues/1231)) ([`3e89294`](https://github.com/python-zeroconf/python-zeroconf/commit/3e89294ea0ecee1122e1c1ffdc78925add8ca40e)) ## v0.81.0 (2023-08-22) -### Feature +### Features + +* feat: speed up the service registry with a cython pxd (#1226) ([`47d3c7a`](https://github.com/python-zeroconf/python-zeroconf/commit/47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e)) + +* feat: optimizing sending answers to questions (#1227) ([`cd7b56b`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7b56b2aa0c8ee429da430e9a36abd515512011)) -* Speed up the service registry with a cython pxd ([#1226](https://github.com/python-zeroconf/python-zeroconf/issues/1226)) ([`47d3c7a`](https://github.com/python-zeroconf/python-zeroconf/commit/47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e)) -* Optimizing sending answers to questions ([#1227](https://github.com/python-zeroconf/python-zeroconf/issues/1227)) ([`cd7b56b`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7b56b2aa0c8ee429da430e9a36abd515512011)) ## v0.80.0 (2023-08-15) -### Feature +### Features + +* feat: optimize unpacking properties in ServiceInfo (#1225) ([`1492e41`](https://github.com/python-zeroconf/python-zeroconf/commit/1492e41b3d5cba5598cc9dd6bd2bc7d238f13555)) -* Optimize unpacking properties in ServiceInfo ([#1225](https://github.com/python-zeroconf/python-zeroconf/issues/1225)) ([`1492e41`](https://github.com/python-zeroconf/python-zeroconf/commit/1492e41b3d5cba5598cc9dd6bd2bc7d238f13555)) ## v0.79.0 (2023-08-14) -### Feature +### Features + +* feat: refactor notify implementation to reduce overhead of adding and removing listeners (#1224) ([`ceb92cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb92cfe42d885dbb38cee7aaeebf685d97627a9)) -* Refactor notify implementation to reduce overhead of adding and removing listeners ([#1224](https://github.com/python-zeroconf/python-zeroconf/issues/1224)) ([`ceb92cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb92cfe42d885dbb38cee7aaeebf685d97627a9)) ## v0.78.0 (2023-08-14) -### Feature +### Features + +* feat: add cython pxd file for _listener.py to improve incoming message processing performance (#1221) ([`f459856`](https://github.com/python-zeroconf/python-zeroconf/commit/f459856a0a61b8afa8a541926d7e15d51f8e4aea)) -* Add cython pxd file for _listener.py to improve incoming message processing performance ([#1221](https://github.com/python-zeroconf/python-zeroconf/issues/1221)) ([`f459856`](https://github.com/python-zeroconf/python-zeroconf/commit/f459856a0a61b8afa8a541926d7e15d51f8e4aea)) ## v0.77.0 (2023-08-14) -### Feature +### Features + +* feat: cythonize _listener.py to improve incoming message processing performance (#1220) ([`9efde8c`](https://github.com/python-zeroconf/python-zeroconf/commit/9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9)) -* Cythonize _listener.py to improve incoming message processing performance ([#1220](https://github.com/python-zeroconf/python-zeroconf/issues/1220)) ([`9efde8c`](https://github.com/python-zeroconf/python-zeroconf/commit/9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9)) ## v0.76.0 (2023-08-14) -### Feature +### Features + +* feat: improve performance responding to queries (#1217) ([`69b33be`](https://github.com/python-zeroconf/python-zeroconf/commit/69b33be3b2f9d4a27ef5154cae94afca048efffa)) -* Improve performance responding to queries ([#1217](https://github.com/python-zeroconf/python-zeroconf/issues/1217)) ([`69b33be`](https://github.com/python-zeroconf/python-zeroconf/commit/69b33be3b2f9d4a27ef5154cae94afca048efffa)) ## v0.75.0 (2023-08-13) -### Feature +### Features + +* feat: expose flag to disable strict name checking in service registration (#1215) ([`5df8a57`](https://github.com/python-zeroconf/python-zeroconf/commit/5df8a57a14d59687a3c22ea8ee063e265031e278)) + +* feat: speed up processing incoming records (#1216) ([`aff625d`](https://github.com/python-zeroconf/python-zeroconf/commit/aff625dc6a5e816dad519644c4adac4f96980c04)) -* Expose flag to disable strict name checking in service registration ([#1215](https://github.com/python-zeroconf/python-zeroconf/issues/1215)) ([`5df8a57`](https://github.com/python-zeroconf/python-zeroconf/commit/5df8a57a14d59687a3c22ea8ee063e265031e278)) -* Speed up processing incoming records ([#1216](https://github.com/python-zeroconf/python-zeroconf/issues/1216)) ([`aff625d`](https://github.com/python-zeroconf/python-zeroconf/commit/aff625dc6a5e816dad519644c4adac4f96980c04)) ## v0.74.0 (2023-08-04) -### Feature +### Bug Fixes + +* fix: remove typing on reset_ttl for cython compat (#1213) ([`0094e26`](https://github.com/python-zeroconf/python-zeroconf/commit/0094e2684344c6b7edd7948924f093f1b4c19901)) -* Speed up unpacking text records in ServiceInfo ([#1212](https://github.com/python-zeroconf/python-zeroconf/issues/1212)) ([`99a6f98`](https://github.com/python-zeroconf/python-zeroconf/commit/99a6f98e44a1287ba537eabb852b1b69923402f0)) +### Features -### Fix +* feat: speed up unpacking text records in ServiceInfo (#1212) ([`99a6f98`](https://github.com/python-zeroconf/python-zeroconf/commit/99a6f98e44a1287ba537eabb852b1b69923402f0)) -* Remove typing on reset_ttl for cython compat ([#1213](https://github.com/python-zeroconf/python-zeroconf/issues/1213)) ([`0094e26`](https://github.com/python-zeroconf/python-zeroconf/commit/0094e2684344c6b7edd7948924f093f1b4c19901)) ## v0.73.0 (2023-08-03) -### Feature +### Features + +* feat: add a cache to service_type_name (#1211) ([`53a694f`](https://github.com/python-zeroconf/python-zeroconf/commit/53a694f60e675ae0560e727be6b721b401c2b68f)) -* Add a cache to service_type_name ([#1211](https://github.com/python-zeroconf/python-zeroconf/issues/1211)) ([`53a694f`](https://github.com/python-zeroconf/python-zeroconf/commit/53a694f60e675ae0560e727be6b721b401c2b68f)) ## v0.72.3 (2023-08-03) -### Fix +### Bug Fixes + +* fix: revert adding typing to DNSRecord.suppressed_by (#1210) ([`3dba5ae`](https://github.com/python-zeroconf/python-zeroconf/commit/3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a)) -* Revert adding typing to DNSRecord.suppressed_by ([#1210](https://github.com/python-zeroconf/python-zeroconf/issues/1210)) ([`3dba5ae`](https://github.com/python-zeroconf/python-zeroconf/commit/3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a)) ## v0.72.2 (2023-08-03) -### Fix +### Bug Fixes + +* fix: revert DNSIncoming cimport in _dns.pxd (#1209) ([`5f14b6d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1)) -* Revert DNSIncoming cimport in _dns.pxd ([#1209](https://github.com/python-zeroconf/python-zeroconf/issues/1209)) ([`5f14b6d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1)) ## v0.72.1 (2023-08-03) -### Fix +### Bug Fixes + +* fix: race with InvalidStateError when async_request times out (#1208) ([`2233b6b`](https://github.com/python-zeroconf/python-zeroconf/commit/2233b6bc4ceeee5524d2ee88ecae8234173feb5f)) -* Race with InvalidStateError when async_request times out ([#1208](https://github.com/python-zeroconf/python-zeroconf/issues/1208)) ([`2233b6b`](https://github.com/python-zeroconf/python-zeroconf/commit/2233b6bc4ceeee5524d2ee88ecae8234173feb5f)) ## v0.72.0 (2023-08-02) -### Feature +### Features + +* feat: speed up processing incoming records (#1206) ([`126849c`](https://github.com/python-zeroconf/python-zeroconf/commit/126849c92be8cec9253fba9faa591029d992fcc3)) -* Speed up processing incoming records ([#1206](https://github.com/python-zeroconf/python-zeroconf/issues/1206)) ([`126849c`](https://github.com/python-zeroconf/python-zeroconf/commit/126849c92be8cec9253fba9faa591029d992fcc3)) ## v0.71.5 (2023-08-02) -### Fix +### Bug Fixes + +* fix: improve performance of ServiceInfo.async_request (#1205) ([`8019a73`](https://github.com/python-zeroconf/python-zeroconf/commit/8019a73c952f2fc4c88d849aab970fafedb316d8)) -* Improve performance of ServiceInfo.async_request ([#1205](https://github.com/python-zeroconf/python-zeroconf/issues/1205)) ([`8019a73`](https://github.com/python-zeroconf/python-zeroconf/commit/8019a73c952f2fc4c88d849aab970fafedb316d8)) ## v0.71.4 (2023-07-24) -### Fix +### Bug Fixes + +* fix: cleanup naming from previous refactoring in ServiceInfo (#1202) ([`b272d75`](https://github.com/python-zeroconf/python-zeroconf/commit/b272d75abd982f3be1f4b20f683cac38011cc6f4)) -* Cleanup naming from previous refactoring in ServiceInfo ([#1202](https://github.com/python-zeroconf/python-zeroconf/issues/1202)) ([`b272d75`](https://github.com/python-zeroconf/python-zeroconf/commit/b272d75abd982f3be1f4b20f683cac38011cc6f4)) ## v0.71.3 (2023-07-23) -### Fix +### Bug Fixes + +* fix: pin python-semantic-release to fix release process (#1200) ([`c145a23`](https://github.com/python-zeroconf/python-zeroconf/commit/c145a238d768aa17c3aebe120c20a46bfbec6b99)) -* Pin python-semantic-release to fix release process ([#1200](https://github.com/python-zeroconf/python-zeroconf/issues/1200)) ([`c145a23`](https://github.com/python-zeroconf/python-zeroconf/commit/c145a238d768aa17c3aebe120c20a46bfbec6b99)) ## v0.71.2 (2023-07-23) -### Fix +### Bug Fixes + +* fix: no change re-release to fix wheel builds (#1199) ([`8c3a4c8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c3a4c80c221bea7401c12e1c6a525e75b7ffea2)) -* No change re-release to fix wheel builds ([#1199](https://github.com/python-zeroconf/python-zeroconf/issues/1199)) ([`8c3a4c8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c3a4c80c221bea7401c12e1c6a525e75b7ffea2)) ## v0.71.1 (2023-07-23) -### Fix +### Bug Fixes + +* fix: add missing if TYPE_CHECKING guard to generate_service_query (#1198) ([`ac53adf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac53adf7e71db14c1a0f9adbfd1d74033df36898)) -* Add missing if TYPE_CHECKING guard to generate_service_query ([#1198](https://github.com/python-zeroconf/python-zeroconf/issues/1198)) ([`ac53adf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac53adf7e71db14c1a0f9adbfd1d74033df36898)) ## v0.71.0 (2023-07-08) -### Feature +### Features + +* feat: improve incoming data processing performance (#1194) ([`a56c776`](https://github.com/python-zeroconf/python-zeroconf/commit/a56c776008ef86f99db78f5997e45a57551be725)) -* Improve incoming data processing performance ([#1194](https://github.com/python-zeroconf/python-zeroconf/issues/1194)) ([`a56c776`](https://github.com/python-zeroconf/python-zeroconf/commit/a56c776008ef86f99db78f5997e45a57551be725)) ## v0.70.0 (2023-07-02) -### Feature +### Features + +* feat: add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and `ServiceInfo.request` (#1192) ([`405f547`](https://github.com/python-zeroconf/python-zeroconf/commit/405f54762d3f61e97de9c1787e837e953de31412)) -* Add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and `ServiceInfo.request` ([#1192](https://github.com/python-zeroconf/python-zeroconf/issues/1192)) ([`405f547`](https://github.com/python-zeroconf/python-zeroconf/commit/405f54762d3f61e97de9c1787e837e953de31412)) ## v0.69.0 (2023-06-18) -### Feature +### Features + +* feat: cython3 support (#1190) ([`8ae8ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d)) + +* feat: reorder incoming data handler to reduce overhead (#1189) ([`32756ff`](https://github.com/python-zeroconf/python-zeroconf/commit/32756ff113f675b7a9cf16d3c0ab840ba733e5e4)) -* Cython3 support ([#1190](https://github.com/python-zeroconf/python-zeroconf/issues/1190)) ([`8ae8ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d)) -* Reorder incoming data handler to reduce overhead ([#1189](https://github.com/python-zeroconf/python-zeroconf/issues/1189)) ([`32756ff`](https://github.com/python-zeroconf/python-zeroconf/commit/32756ff113f675b7a9cf16d3c0ab840ba733e5e4)) ## v0.68.1 (2023-06-18) -### Fix +### Bug Fixes + +* fix: reduce debug logging overhead by adding missing checks to datagram_received (#1188) ([`ac5c50a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3)) -* Reduce debug logging overhead by adding missing checks to datagram_received ([#1188](https://github.com/python-zeroconf/python-zeroconf/issues/1188)) ([`ac5c50a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3)) ## v0.68.0 (2023-06-17) -### Feature +### Features + +* feat: reduce overhead to handle queries and responses (#1184) + +- adds slots to handler classes + +- avoid any expression overhead and inline instead ([`81126b7`](https://github.com/python-zeroconf/python-zeroconf/commit/81126b7600f94848ef8c58b70bac0c6ab993c6ae)) -* Reduce overhead to handle queries and responses ([#1184](https://github.com/python-zeroconf/python-zeroconf/issues/1184)) ([`81126b7`](https://github.com/python-zeroconf/python-zeroconf/commit/81126b7600f94848ef8c58b70bac0c6ab993c6ae)) ## v0.67.0 (2023-06-17) -### Feature +### Features + +* feat: speed up answering incoming questions (#1186) ([`8f37665`](https://github.com/python-zeroconf/python-zeroconf/commit/8f376658d2a3bef0353646e6fddfda15626b73a9)) -* Speed up answering incoming questions ([#1186](https://github.com/python-zeroconf/python-zeroconf/issues/1186)) ([`8f37665`](https://github.com/python-zeroconf/python-zeroconf/commit/8f376658d2a3bef0353646e6fddfda15626b73a9)) ## v0.66.0 (2023-06-13) -### Feature -* Optimize construction of outgoing dns records ([#1182](https://github.com/python-zeroconf/python-zeroconf/issues/1182)) ([`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) + +### Features + +* feat: optimize construction of outgoing dns records (#1182) ([`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) + ## v0.65.0 (2023-06-13) -### Feature -* Reduce overhead to enumerate ip addresses in ServiceInfo ([#1181](https://github.com/python-zeroconf/python-zeroconf/issues/1181)) ([`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) + +### Features + +* feat: reduce overhead to enumerate ip addresses in ServiceInfo (#1181) ([`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) + ## v0.64.1 (2023-06-05) -### Fix -* Small internal typing cleanups ([#1180](https://github.com/python-zeroconf/python-zeroconf/issues/1180)) ([`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) + +### Bug Fixes + +* fix: small internal typing cleanups (#1180) ([`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) + ## v0.64.0 (2023-06-05) -### Feature -* Speed up processing incoming records ([#1179](https://github.com/python-zeroconf/python-zeroconf/issues/1179)) ([`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) -### Fix -* Always answer QU questions when the exact same packet is received from different sources in sequence ([#1178](https://github.com/python-zeroconf/python-zeroconf/issues/1178)) ([`74d7ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/74d7ba1aeeae56be087ee8142ee6ca1219744baa)) +### Bug Fixes + +* fix: always answer QU questions when the exact same packet is received from different sources in sequence (#1178) + +If the exact same packet with a QU question is asked from two different sources in a 1s window we end up ignoring the second one as a duplicate. We should still respond in this case because the client wants a unicast response and the question may not be answered by the previous packet since the response may not be multicast. + +fix: include NSEC records in initial broadcast when registering a new service + +This also revealed that we do not send NSEC records in the initial broadcast. This needed to be fixed in this PR as well for everything to work as expected since all the tests would fail with 2 updates otherwise. ([`74d7ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/74d7ba1aeeae56be087ee8142ee6ca1219744baa)) + +### Features + +* feat: speed up processing incoming records (#1179) ([`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) + ## v0.63.0 (2023-05-25) -### Feature -* Small speed up to fetch dns addresses from ServiceInfo ([#1176](https://github.com/python-zeroconf/python-zeroconf/issues/1176)) ([`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) -* Speed up the service registry ([#1174](https://github.com/python-zeroconf/python-zeroconf/issues/1174)) ([`360ceb2`](https://github.com/python-zeroconf/python-zeroconf/commit/360ceb2548c4c4974ff798aac43a6fff9803ea0e)) -* Improve dns cache performance ([#1172](https://github.com/python-zeroconf/python-zeroconf/issues/1172)) ([`bb496a1`](https://github.com/python-zeroconf/python-zeroconf/commit/bb496a1dd5fa3562c0412cb064d14639a542592e)) + +### Features + +* feat: small speed up to fetch dns addresses from ServiceInfo (#1176) ([`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) + +* feat: speed up the service registry (#1174) ([`360ceb2`](https://github.com/python-zeroconf/python-zeroconf/commit/360ceb2548c4c4974ff798aac43a6fff9803ea0e)) + +* feat: improve dns cache performance (#1172) ([`bb496a1`](https://github.com/python-zeroconf/python-zeroconf/commit/bb496a1dd5fa3562c0412cb064d14639a542592e)) + ## v0.62.0 (2023-05-04) -### Feature -* Improve performance of ServiceBrowser outgoing query scheduler ([#1170](https://github.com/python-zeroconf/python-zeroconf/issues/1170)) ([`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) + +### Features + +* feat: improve performance of ServiceBrowser outgoing query scheduler (#1170) ([`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) + ## v0.61.0 (2023-05-03) -### Feature -* Speed up parsing NSEC records ([#1169](https://github.com/python-zeroconf/python-zeroconf/issues/1169)) ([`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) + +### Features + +* feat: speed up parsing NSEC records (#1169) ([`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) + ## v0.60.0 (2023-05-01) -### Feature -* Speed up processing incoming data ([#1167](https://github.com/python-zeroconf/python-zeroconf/issues/1167)) ([`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) + +### Features + +* feat: speed up processing incoming data (#1167) ([`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) + ## v0.59.0 (2023-05-01) -### Feature -* Speed up decoding dns questions when processing incoming data ([#1168](https://github.com/python-zeroconf/python-zeroconf/issues/1168)) ([`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) + +### Features + +* feat: speed up decoding dns questions when processing incoming data (#1168) ([`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) + ## v0.58.2 (2023-04-26) -### Fix -* Re-release to rebuild failed wheels ([#1165](https://github.com/python-zeroconf/python-zeroconf/issues/1165)) ([`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) + +### Bug Fixes + +* fix: re-release to rebuild failed wheels (#1165) ([`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) + ## v0.58.1 (2023-04-26) -### Fix -* Reduce cast calls in service browser ([#1164](https://github.com/python-zeroconf/python-zeroconf/issues/1164)) ([`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) + +### Bug Fixes + +* fix: reduce cast calls in service browser (#1164) ([`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) + ## v0.58.0 (2023-04-23) -### Feature -* Speed up incoming parser ([#1163](https://github.com/python-zeroconf/python-zeroconf/issues/1163)) ([`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) + +### Features + +* feat: speed up incoming parser (#1163) ([`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) + ## v0.57.0 (2023-04-23) -### Feature -* Speed up incoming data parser ([#1161](https://github.com/python-zeroconf/python-zeroconf/issues/1161)) ([`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) + +### Features + +* feat: speed up incoming data parser (#1161) ([`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) + ## v0.56.0 (2023-04-07) -### Feature -* Reduce denial of service protection overhead ([#1157](https://github.com/python-zeroconf/python-zeroconf/issues/1157)) ([`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) + +### Features + +* feat: reduce denial of service protection overhead (#1157) ([`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) + ## v0.55.0 (2023-04-07) -### Feature -* Improve performance of processing incoming records ([#1155](https://github.com/python-zeroconf/python-zeroconf/issues/1155)) ([`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) + +### Features + +* feat: improve performance of processing incoming records (#1155) ([`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) + ## v0.54.0 (2023-04-03) -### Feature -* Avoid waking async_request when record updates are not relevant ([#1153](https://github.com/python-zeroconf/python-zeroconf/issues/1153)) ([`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) + +### Features + +* feat: avoid waking async_request when record updates are not relevant (#1153) ([`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) + ## v0.53.1 (2023-04-03) -### Fix -* Addresses incorrect after server name change ([#1154](https://github.com/python-zeroconf/python-zeroconf/issues/1154)) ([`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) + +### Bug Fixes + +* fix: addresses incorrect after server name change (#1154) ([`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) + ## v0.53.0 (2023-04-02) -### Feature -* Improve ServiceBrowser performance by removing OrderedDict ([#1148](https://github.com/python-zeroconf/python-zeroconf/issues/1148)) ([`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) -### Fix -* Make parsed_scoped_addresses return addresses in the same order as all other methods ([#1150](https://github.com/python-zeroconf/python-zeroconf/issues/1150)) ([`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) +### Bug Fixes + +* fix: make parsed_scoped_addresses return addresses in the same order as all other methods (#1150) ([`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) + +### Features + +* feat: improve ServiceBrowser performance by removing OrderedDict (#1148) ([`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) -### Technically breaking change -* IP Addresses returned from `ServiceInfo.parsed_addresses` are now stringified using the python `ipaddress` library which may format them differently than `socket.inet_ntop` depending on the operating system. It is recommended to use `ServiceInfo.ip_addresses_by_version` instead going forward as it offers a stronger guarantee since it returns `ipaddress` objects. ## v0.52.0 (2023-04-02) -### Feature -* Small cleanups to cache cleanup interval ([#1146](https://github.com/python-zeroconf/python-zeroconf/issues/1146)) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) -* Add ip_addresses_by_version to ServiceInfo ([#1145](https://github.com/python-zeroconf/python-zeroconf/issues/1145)) ([`524494e`](https://github.com/python-zeroconf/python-zeroconf/commit/524494edd49bd049726b19ae8ac8f6eea69a3943)) -* Speed up processing records in the ServiceBrowser ([#1143](https://github.com/python-zeroconf/python-zeroconf/issues/1143)) ([`6a327d0`](https://github.com/python-zeroconf/python-zeroconf/commit/6a327d00ffb81de55b7c5b599893c789996680c1)) -* Speed up matching types in the ServiceBrowser ([#1144](https://github.com/python-zeroconf/python-zeroconf/issues/1144)) ([`68871c3`](https://github.com/python-zeroconf/python-zeroconf/commit/68871c3b5569e41740a66b7d3d7fa5cc41514ea5)) -* Include tests and docs in sdist archives ([#1142](https://github.com/python-zeroconf/python-zeroconf/issues/1142)) ([`da10a3b`](https://github.com/python-zeroconf/python-zeroconf/commit/da10a3b2827cee0719d3bb9152ae897f061c6e2e)) + +### Features + +* feat: small cleanups to cache cleanup interval (#1146) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) + +* feat: add ip_addresses_by_version to ServiceInfo (#1145) ([`524494e`](https://github.com/python-zeroconf/python-zeroconf/commit/524494edd49bd049726b19ae8ac8f6eea69a3943)) + +* feat: speed up processing records in the ServiceBrowser (#1143) ([`6a327d0`](https://github.com/python-zeroconf/python-zeroconf/commit/6a327d00ffb81de55b7c5b599893c789996680c1)) + +* feat: speed up matching types in the ServiceBrowser (#1144) ([`68871c3`](https://github.com/python-zeroconf/python-zeroconf/commit/68871c3b5569e41740a66b7d3d7fa5cc41514ea5)) + +* feat: include tests and docs in sdist archives (#1142) + +feat: Include tests and docs in sdist archives + +Include documentation and test files in source distributions, in order +to make them more useful for packagers (Linux distributions, Conda). +Testing is an important part of packaging process, and at least Gentoo +users have requested offline documentation for Python packages. +Furthermore, the COPYING file was missing from sdist, even though it was +referenced in README. ([`da10a3b`](https://github.com/python-zeroconf/python-zeroconf/commit/da10a3b2827cee0719d3bb9152ae897f061c6e2e)) + ## v0.51.0 (2023-04-01) -### Feature -* Improve performance of constructing ServiceInfo ([#1141](https://github.com/python-zeroconf/python-zeroconf/issues/1141)) ([`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) + +### Features + +* feat: improve performance of constructing ServiceInfo (#1141) ([`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) + ## v0.50.0 (2023-04-01) -### Feature -* Small speed up to handler dispatch ([#1140](https://github.com/python-zeroconf/python-zeroconf/issues/1140)) ([`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) + +### Features + +* feat: small speed up to handler dispatch (#1140) ([`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) + ## v0.49.0 (2023-04-01) -### Feature -* Speed up processing incoming records ([#1139](https://github.com/python-zeroconf/python-zeroconf/issues/1139)) ([`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) + +### Features + +* feat: speed up processing incoming records (#1139) ([`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) + ## v0.48.0 (2023-04-01) -### Feature -* Reduce overhead to send responses ([#1135](https://github.com/python-zeroconf/python-zeroconf/issues/1135)) ([`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) + +### Features + +* feat: reduce overhead to send responses (#1135) ([`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) + ## v0.47.4 (2023-03-20) -### Fix -* Correct duplicate record entries in windows wheels by updating poetry-core ([#1134](https://github.com/python-zeroconf/python-zeroconf/issues/1134)) ([`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) + +### Bug Fixes + +* fix: correct duplicate record entries in windows wheels by updating poetry-core (#1134) ([`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) + ## v0.47.3 (2023-02-14) -### Fix -* Hold a strong reference to the query sender start task ([#1128](https://github.com/python-zeroconf/python-zeroconf/issues/1128)) ([`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) + +### Bug Fixes + +* fix: hold a strong reference to the query sender start task (#1128) ([`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) + ## v0.47.2 (2023-02-14) -### Fix -* Missing c extensions with newer poetry ([#1129](https://github.com/python-zeroconf/python-zeroconf/issues/1129)) ([`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) + +### Bug Fixes + +* fix: missing c extensions with newer poetry (#1129) ([`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) + ## v0.47.1 (2022-12-24) -### Fix -* The equality checks for DNSPointer and DNSService should be case insensitive ([#1122](https://github.com/python-zeroconf/python-zeroconf/issues/1122)) ([`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) + +### Bug Fixes + +* fix: the equality checks for DNSPointer and DNSService should be case insensitive (#1122) ([`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) + ## v0.47.0 (2022-12-22) -### Feature -* Optimize equality checks for DNS records ([#1120](https://github.com/python-zeroconf/python-zeroconf/issues/1120)) ([`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) + +### Features + +* feat: optimize equality checks for DNS records (#1120) ([`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) + ## v0.46.0 (2022-12-21) -### Feature -* Optimize the dns cache ([#1119](https://github.com/python-zeroconf/python-zeroconf/issues/1119)) ([`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) + +### Features + +* feat: optimize the dns cache (#1119) ([`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) + ## v0.45.0 (2022-12-20) -### Feature -* Optimize construction of outgoing packets ([#1118](https://github.com/python-zeroconf/python-zeroconf/issues/1118)) ([`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) + +### Features + +* feat: optimize construction of outgoing packets (#1118) ([`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) + ## v0.44.0 (2022-12-18) -### Feature -* Optimize dns objects by adding pxd files ([#1113](https://github.com/python-zeroconf/python-zeroconf/issues/1113)) ([`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) + +### Features + +* feat: optimize dns objects by adding pxd files (#1113) ([`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) + ## v0.43.0 (2022-12-18) -### Feature -* Optimize incoming parser by reducing call stack ([#1116](https://github.com/python-zeroconf/python-zeroconf/issues/1116)) ([`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) + +### Features + +* feat: optimize incoming parser by reducing call stack (#1116) ([`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) + ## v0.42.0 (2022-12-18) -### Feature -* Optimize incoming parser by using unpack_from ([#1115](https://github.com/python-zeroconf/python-zeroconf/issues/1115)) ([`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) + +### Features + +* feat: optimize incoming parser by using unpack_from (#1115) ([`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) + ## v0.41.0 (2022-12-18) -### Feature -* Optimize incoming parser by adding pxd files ([#1111](https://github.com/python-zeroconf/python-zeroconf/issues/1111)) ([`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) + +### Features + +* feat: optimize incoming parser by adding pxd files (#1111) ([`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) + ## v0.40.1 (2022-12-18) -### Fix -* Fix project name in pyproject.toml ([#1112](https://github.com/python-zeroconf/python-zeroconf/issues/1112)) ([`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) -## v0.40.0 (2022-12-17) -### Feature -* Drop async_timeout requirement for python 3.11+ ([#1107](https://github.com/python-zeroconf/python-zeroconf/issues/1107)) ([`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) +### Bug Fixes + +* fix: fix project name in pyproject.toml (#1112) ([`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) + + +## v0.40.0 (2022-12-17) + +### Features + +* feat: drop async_timeout requirement for python 3.11+ (#1107) ([`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) + + +## v0.39.5 (2022-12-17) + +### Unknown + +* 0.39.5 ([`2be6fbf`](https://github.com/python-zeroconf/python-zeroconf/commit/2be6fbfe3d10b185096814d2d0de322733d273cf)) + + +## v0.39.4 (2022-10-31) + +### Unknown + +* Bump version: 0.39.3 → 0.39.4 ([`e620f2a`](https://github.com/python-zeroconf/python-zeroconf/commit/e620f2a1d4f381feb99b639c6ab17845396ba7ea)) + +* Update changelog for 0.39.4 (#1103) ([`03821b6`](https://github.com/python-zeroconf/python-zeroconf/commit/03821b6f4d9fdc40d94d1070f69553649d18909b)) + +* Fix IP changes being missed by ServiceInfo (#1102) ([`524ae89`](https://github.com/python-zeroconf/python-zeroconf/commit/524ae89966d9300e78642a91434ad55643277a48)) + + +## v0.39.3 (2022-10-26) + +### Unknown + +* Bump version: 0.39.2 → 0.39.3 ([`aee3165`](https://github.com/python-zeroconf/python-zeroconf/commit/aee316539b0778eaf2b8878f78d9ead373760cfb)) + +* Update changelog for 0.39.3 (#1101) ([`39c9842`](https://github.com/python-zeroconf/python-zeroconf/commit/39c9842b80ac7d978e8c7ffef0ad836b3b4700f6)) + +* Fix port changes not being seen by ServiceInfo (#1100) ([`c96f5f6`](https://github.com/python-zeroconf/python-zeroconf/commit/c96f5f69d8e68672bb6760b1e40a0de51b62efd6)) + +* Update CI to use released python 3.11 (#1099) ([`6976980`](https://github.com/python-zeroconf/python-zeroconf/commit/6976980b4874dd65ee533d43be57694bb3b7d0fc)) + + +## v0.39.2 (2022-10-20) + +### Unknown + +* Bump version: 0.39.1 → 0.39.2 ([`785e475`](https://github.com/python-zeroconf/python-zeroconf/commit/785e475467225ddc4930d5302f130781223fd298)) + +* Update changelog for 0.39.2 (#1098) ([`b197344`](https://github.com/python-zeroconf/python-zeroconf/commit/b19734484b4c5eebb86fe6897a26ad082b07bed5)) + +* Improve cache of decode labels at offset (#1097) ([`d3c475f`](https://github.com/python-zeroconf/python-zeroconf/commit/d3c475f3e2590ae5a3056d85c29a66dc71ae3bdf)) + +* Only reprocess address records if the server changes (#1095) ([`0989336`](https://github.com/python-zeroconf/python-zeroconf/commit/0989336d79bc4dd0ef3b26e8d0f9529fca81c1fb)) + +* Prepare for python 3.11 support by adding rc2 to the CI (#1085) ([`7430ce1`](https://github.com/python-zeroconf/python-zeroconf/commit/7430ce1c462be0dd210712b4f7b3675efd3a6963)) + + +## v0.39.1 (2022-09-05) + +### Unknown + +* Bump version: 0.39.0 → 0.39.1 ([`6f90896`](https://github.com/python-zeroconf/python-zeroconf/commit/6f90896a590d6d60db75688a1ba753c333c8faab)) + +* Update changelog for 0.39.1 (#1091) ([`cad3963`](https://github.com/python-zeroconf/python-zeroconf/commit/cad3963e566a7bb2dd188088c11e7a0abb6b3924)) + +* Replace pack with to_bytes (#1090) ([`5968b76`](https://github.com/python-zeroconf/python-zeroconf/commit/5968b76ac2ffe6e41b8961c59bdcc5a48ba410eb)) + + +## v0.39.0 (2022-08-05) + +### Unknown + +* Bump version: 0.38.7 → 0.39.0 ([`60167b0`](https://github.com/python-zeroconf/python-zeroconf/commit/60167b05227ec33668aac5b960a8bc5ba5b833de)) + +* 0.39.0 changelog (#1087) ([`946890a`](https://github.com/python-zeroconf/python-zeroconf/commit/946890aca540bbae95abe8a6ffe66db56fa9e986)) + +* Remove coveralls from dev requirements (#1086) ([`087914d`](https://github.com/python-zeroconf/python-zeroconf/commit/087914da2e914275dd0fff1e4466b3c51ae0c6d3)) + +* Fix run_coro_with_timeout test not running in the CI (#1082) ([`b7a24fe`](https://github.com/python-zeroconf/python-zeroconf/commit/b7a24fef05fc6c166b25cfd4235e59c5cbb96a4c)) + +* Fix flakey service_browser_expire_callbacks test (#1084) ([`d5032b7`](https://github.com/python-zeroconf/python-zeroconf/commit/d5032b70b6ebc5c221a43f778f4d897a1d891f91)) + +* Fix flakey test_sending_unicast on windows (#1083) ([`389658d`](https://github.com/python-zeroconf/python-zeroconf/commit/389658d998a23deecd96023794d3672e51189a35)) + +* Replace wait_event_or_timeout internals with async_timeout (#1081) + +Its unlikely that https://bugs.python.org/issue39032 and +https://github.com/python/cpython/issues/83213 will be fixed +soon. While we moved away from an asyncio.Condition, we still +has a similar problem with waiting for an asyncio.Event which +wait_event_or_timeout played well with. async_timeout avoids +creating a task so its a bit more efficient. Since we call +these when resolving ServiceInfo, avoiding task creation +will resolve a performance problem when ServiceBrowsers +startup as they tend to create task storms when coupled +with ServiceInfo lookups. ([`7ffea9f`](https://github.com/python-zeroconf/python-zeroconf/commit/7ffea9f93e758f75a0eeb9997ff8d9c9d47ec31a)) + +* Update stale docstrings in AsyncZeroconf (#1079) ([`88323d0`](https://github.com/python-zeroconf/python-zeroconf/commit/88323d0c7866f78edde063080c63a72c6e875772)) + + +## v0.38.7 (2022-06-14) + +### Unknown + +* Bump version: 0.38.6 → 0.38.7 ([`f3a9f80`](https://github.com/python-zeroconf/python-zeroconf/commit/f3a9f804914fec37e961f80f347c4e706c4bae33)) + +* Update changelog for 0.38.7 (#1078) ([`5f7ba0d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f7ba0d7dc9a5a6b2cf3a321b7b2f448d4332de9)) + +* Speed up unpacking incoming packet data (#1076) ([`533ad10`](https://github.com/python-zeroconf/python-zeroconf/commit/533ad10121739997a4925d90792cbe9e00a5ac4f)) + + +## v0.38.6 (2022-05-06) + +### Unknown + +* Bump version: 0.38.5 → 0.38.6 ([`1aa7842`](https://github.com/python-zeroconf/python-zeroconf/commit/1aa7842ae0f914c10465ae977551698046406d55)) + +* Update changelog for 0.38.6 (#1073) ([`dfd3222`](https://github.com/python-zeroconf/python-zeroconf/commit/dfd3222405f0123a849d376d8be466be46bdb557)) + +* Always return `started` as False once Zeroconf has been marked as done (#1072) ([`ed02e5d`](https://github.com/python-zeroconf/python-zeroconf/commit/ed02e5d92768d1fc41163f59e303a76843bfd9fd)) + +* Avoid waking up ServiceInfo listeners when there is no new data (#1068) ([`59624a6`](https://github.com/python-zeroconf/python-zeroconf/commit/59624a6cfb1839b2654a6021a7317a1bdad179e9)) + +* Remove left-in debug print (#1071) ([`5fb0954`](https://github.com/python-zeroconf/python-zeroconf/commit/5fb0954cf2c6040704c3db1d2b0fece389425e5b)) + +* Use unique name in test_service_browser_expire_callbacks test (#1069) ([`89c9022`](https://github.com/python-zeroconf/python-zeroconf/commit/89c9022f87d3a83cc586b153fb7d5ea3af69ae3b)) + +* Fix CI failures (#1070) ([`f9b2816`](https://github.com/python-zeroconf/python-zeroconf/commit/f9b2816e15b0459f8051079f77b70e983769cd44)) + + +## v0.38.5 (2022-05-01) + +### Unknown + +* Bump version: 0.38.4 → 0.38.5 ([`3c55388`](https://github.com/python-zeroconf/python-zeroconf/commit/3c5538899b8974e99c9a279ce3ac46971ab5d91c)) + +* Update changelog for 0.38.5 (#1066) ([`ae3635b`](https://github.com/python-zeroconf/python-zeroconf/commit/ae3635b9ee73edeaabe2cbc027b8fb8bd7cd97da)) + +* Fix ServiceBrowsers not getting `ServiceStateChange.Removed` callbacks on PTR record expire (#1064) ([`10ee205`](https://github.com/python-zeroconf/python-zeroconf/commit/10ee2053a80f7c7221b4fa1475d66b01abd21b11)) + +* Fix ci trying to run mypy on pypy (#1065) ([`31662b7`](https://github.com/python-zeroconf/python-zeroconf/commit/31662b7a0bba65bea1fbfc09c70cd2970160c5c6)) + +* Force minimum version of 3.7 and update example (#1060) + +Co-authored-by: J. Nick Koston ([`6e842f2`](https://github.com/python-zeroconf/python-zeroconf/commit/6e842f238b3e1f3b738ed058e0fa4068115f041b)) + +* Fix mypy error in zeroconf._service.info (#1062) ([`e9d25f7`](https://github.com/python-zeroconf/python-zeroconf/commit/e9d25f7749778979b7449464153163587583bf8d)) + +* Refactor to fix mypy error (#1061) ([`6c451f6`](https://github.com/python-zeroconf/python-zeroconf/commit/6c451f64e7cbeaa0bb77f66790936afda2d058ef)) + + +## v0.38.4 (2022-02-28) + +### Unknown + +* Bump version: 0.38.3 → 0.38.4 ([`5c40e89`](https://github.com/python-zeroconf/python-zeroconf/commit/5c40e89420255b5b978bff4682b21f0820fb4682)) + +* Update changelog for 0.38.4 (#1058) ([`3736348`](https://github.com/python-zeroconf/python-zeroconf/commit/3736348da30ee4b7c50713936f2ae919e5446ffa)) + +* Fix IP Address updates when hostname is uppercase (#1057) ([`79d067b`](https://github.com/python-zeroconf/python-zeroconf/commit/79d067b88f9108259a44f33801e26bd3a25ca759)) + + +## v0.38.3 (2022-01-31) + +### Unknown + +* Bump version: 0.38.2 → 0.38.3 ([`e42549c`](https://github.com/python-zeroconf/python-zeroconf/commit/e42549cb70796d0577c97be96a09bca0056a5755)) + +* Update changelog for 0.38.2/3 (#1053) ([`d99c7ff`](https://github.com/python-zeroconf/python-zeroconf/commit/d99c7ffea37fd27c315115133dab08445aa417d1)) + + +## v0.38.2 (2022-01-31) + +### Unknown + +* Bump version: 0.38.1 → 0.38.2 ([`50cd12d`](https://github.com/python-zeroconf/python-zeroconf/commit/50cd12d8c2ced166da8f4852120ba8a28b13cba0)) + +* Make decode errors more helpful in finding the source of the bad data (#1052) ([`25e6123`](https://github.com/python-zeroconf/python-zeroconf/commit/25e6123a07a9560e978a04d5e285bfa74ee41e64)) + + +## v0.38.1 (2021-12-23) + +### Unknown + +* Bump version: 0.38.0 → 0.38.1 ([`6a11f24`](https://github.com/python-zeroconf/python-zeroconf/commit/6a11f24e1fc9d73f0dbb62efd834f17a9bd451c4)) + +* Update changelog for 0.38.1 (#1045) ([`670d4ac`](https://github.com/python-zeroconf/python-zeroconf/commit/670d4ac3be7e32d02afe85b72264a241b5a25ba8)) + +* Avoid linear type searches in ServiceBrowsers (#1044) ([`ff76634`](https://github.com/python-zeroconf/python-zeroconf/commit/ff766345461a82547abe462b5d690621c755d480)) + +* Improve performance of query scheduler (#1043) ([`27e50ff`](https://github.com/python-zeroconf/python-zeroconf/commit/27e50ff95625d128f71864138b8e5d871503adf0)) + + +## v0.38.0 (2021-12-23) + +### Unknown + +* Bump version: 0.37.0 → 0.38.0 ([`95ee5dc`](https://github.com/python-zeroconf/python-zeroconf/commit/95ee5dc031c9c512f99536186d1d89a99e4af37f)) + +* Update changelog for 0.38.0 (#1042) ([`de14202`](https://github.com/python-zeroconf/python-zeroconf/commit/de1420213cd7e3bd8f57e727ff1031c7b10cf7a0)) + +* Handle Service types that end with another service type (#1041) + +Co-authored-by: J. Nick Koston ([`a4d619a`](https://github.com/python-zeroconf/python-zeroconf/commit/a4d619a9f094682d9dcfc7f8fa293f17bcae88f2)) + +* Add tests for instance names containing dot(s) (#1039) + +Co-authored-by: J. Nick Koston ([`22ed08c`](https://github.com/python-zeroconf/python-zeroconf/commit/22ed08c7e5403a788b1c177a1bb9558419bce2b1)) + +* Drop python 3.6 support (#1009) ([`631a6f7`](https://github.com/python-zeroconf/python-zeroconf/commit/631a6f7c7863897336a9d6ca4bd1736cc7cc97af)) + + +## v0.37.0 (2021-11-18) + +### Unknown + +* Bump version: 0.36.13 → 0.37.0 ([`2996e64`](https://github.com/python-zeroconf/python-zeroconf/commit/2996e642f6b1abba1dbb8242ccca4cd4b96696f6)) + +* Update changelog for 0.37.0 (#1035) ([`61a7e3f`](https://github.com/python-zeroconf/python-zeroconf/commit/61a7e3fb65d99db7d51f1df42b286b55710a2e99)) + +* Log an error when listeners are added that do not inherit from RecordUpdateListener (#1034) ([`ee071a1`](https://github.com/python-zeroconf/python-zeroconf/commit/ee071a12f31f7010110eef5ccef80c6cdf469d87)) + +* Throw NotRunningException when Zeroconf is not running (#1033) + +- Before this change the consumer would get a timeout or an EventLoopBlocked + exception when calling `ServiceInfo.*request` when the instance had already been shutdown. + This was quite a confusing result. ([`28938d2`](https://github.com/python-zeroconf/python-zeroconf/commit/28938d20bb62ae0d9aa2f94929f60434fb346704)) + +* Throw EventLoopBlocked instead of concurrent.futures.TimeoutError (#1032) ([`21bd107`](https://github.com/python-zeroconf/python-zeroconf/commit/21bd10762a89ca3f4ca89f598c9d93684a02f51b)) + + +## v0.36.13 (2021-11-13) + +### Unknown + +* Bump version: 0.36.12 → 0.36.13 ([`4241c76`](https://github.com/python-zeroconf/python-zeroconf/commit/4241c76550130469aecbe88cc1a7cdc13505f8ba)) + +* Update changelog for 0.36.13 (#1030) ([`106cf27`](https://github.com/python-zeroconf/python-zeroconf/commit/106cf27478bb0c1e6e5a7194661ff52947d61c96)) + +* Downgrade incoming corrupt packet logging to debug (#1029) + +- Warning about network traffic we have no control over + is confusing to users as they think there is + something wrong with zeroconf ([`73c52d0`](https://github.com/python-zeroconf/python-zeroconf/commit/73c52d04a140bc744669777a0f353eefc6623ff9)) + +* Skip unavailable interfaces during socket bind (#1028) + +- We already skip these when adding multicast members. + Apply the same logic to the socket bind call ([`aa59998`](https://github.com/python-zeroconf/python-zeroconf/commit/aa59998182ce29c55f8c3dde9a058ce36ac2bb2d)) + + +## v0.36.12 (2021-11-05) + +### Unknown + +* Bump version: 0.36.11 → 0.36.12 ([`8b0dc48`](https://github.com/python-zeroconf/python-zeroconf/commit/8b0dc48ed42d8edc78750122eb5685a50c3cdc11)) + +* Update changelog for 0.36.12 (#1027) ([`51bf364`](https://github.com/python-zeroconf/python-zeroconf/commit/51bf364b364ecaad16503df4a4c4c3bb5ead2775)) + +* Account for intricacies of floating-point arithmetic in service browser tests (#1026) ([`3c70808`](https://github.com/python-zeroconf/python-zeroconf/commit/3c708080b3e42a02930ad17c96a2cf0dcb06f441)) + +* Prevent service lookups from deadlocking if time abruptly moves backwards (#1006) + +- The typical reason time moves backwards is via an ntp update ([`38380a5`](https://github.com/python-zeroconf/python-zeroconf/commit/38380a58a64f563f105cecc610f194c20056b2b6)) + + +## v0.36.11 (2021-10-30) + +### Unknown + +* Bump version: 0.36.10 → 0.36.11 ([`3d8f50d`](https://github.com/python-zeroconf/python-zeroconf/commit/3d8f50de74f7b3941d9b35b6ae6e42ba02be9361)) + +* Update changelog for 0.36.11 (#1024) ([`69a9b8e`](https://github.com/python-zeroconf/python-zeroconf/commit/69a9b8e060ae8a596050d393c0a5c8b43beadc8e)) + +* Add readme check to the CI (#1023) ([`c966976`](https://github.com/python-zeroconf/python-zeroconf/commit/c966976531ac9222460763d647d0a3b75459e275)) + + +## v0.36.10 (2021-10-30) + +### Unknown + +* Bump version: 0.36.9 → 0.36.10 ([`e0b340a`](https://github.com/python-zeroconf/python-zeroconf/commit/e0b340afbfd25ae9d05a59a577938b062287c8b6)) + +* Update changelog for 0.36.10 (#1021) ([`69ce817`](https://github.com/python-zeroconf/python-zeroconf/commit/69ce817a68d65f2db0bfe6d4790d3a6a356ac83f)) + +* Fix test failure when has_working_ipv6 generates an exception (#1022) ([`cd8984d`](https://github.com/python-zeroconf/python-zeroconf/commit/cd8984d3e95bffe6fd32b97eae9844bf5afed4de)) + +* Strip scope_id from IPv6 address if given. (#1020) ([`686febd`](https://github.com/python-zeroconf/python-zeroconf/commit/686febdd181c837fa6a41afce91edeeded731fbe)) + +* Optimize decoding labels from incoming packets (#1019) + +- decode is a bit faster vs str() + +``` +>>> ts = Timer("s.decode('utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") +>>> ts.timeit() +0.09910525000003645 +>>> ts = Timer("str(s, 'utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") +>>> ts.timeit() +0.1304596250000145 +``` ([`4b9a6c3`](https://github.com/python-zeroconf/python-zeroconf/commit/4b9a6c3fd4aec920597e7e63e82e935df68804f4)) + +* Fix typo in changelog (#1017) ([`0fdcd51`](https://github.com/python-zeroconf/python-zeroconf/commit/0fdcd5146264b37daa7cc35bda883519175e362f)) + + +## v0.36.9 (2021-10-22) + +### Unknown + +* Bump version: 0.36.8 → 0.36.9 ([`d92d3d0`](https://github.com/python-zeroconf/python-zeroconf/commit/d92d3d030558c1b81b2e35f701b585f4b48fa99a)) + +* Update changelog for 0.36.9 (#1016) ([`1427ba7`](https://github.com/python-zeroconf/python-zeroconf/commit/1427ba75a8f7e2962aa0b3105d3c856002134790)) + +* Ensure ServiceInfo orders newest addresess first (#1012) ([`87a4d8f`](https://github.com/python-zeroconf/python-zeroconf/commit/87a4d8f4d5c8365425c2ee969032205f916f80c1)) + + +## v0.36.8 (2021-10-10) + +### Unknown + +* Bump version: 0.36.7 → 0.36.8 ([`61275ef`](https://github.com/python-zeroconf/python-zeroconf/commit/61275efd05688a61d656b43125b01a5d588f1dba)) + +* Update changelog for 0.36.8 (#1010) ([`1551618`](https://github.com/python-zeroconf/python-zeroconf/commit/15516188f346c70f64a923bb587804b9bf948873)) + +* Fix ServiceBrowser infinite looping when zeroconf is closed before its canceled (#1008) ([`b0e8c8a`](https://github.com/python-zeroconf/python-zeroconf/commit/b0e8c8a21fd721e60adbac4dbf7a03959fc3f641)) + +* Update CI to use python 3.10, pypy 3.7 (#1007) ([`fec9f3d`](https://github.com/python-zeroconf/python-zeroconf/commit/fec9f3dc9626be08eccdf1263dbf4d1686fd27b2)) + +* Cleanup typing in zeroconf._protocol.outgoing (#1000) ([`543558d`](https://github.com/python-zeroconf/python-zeroconf/commit/543558d0498ed03eb9dc4597c4c40484e16ee4e6)) + +* Breakout functions with no self-use in zeroconf._handlers (#1003) ([`af4d082`](https://github.com/python-zeroconf/python-zeroconf/commit/af4d082240a545ba3014eb7f1056c3b32ce2cb70)) + +* Use more f-strings in zeroconf._dns (#1002) ([`d3ed691`](https://github.com/python-zeroconf/python-zeroconf/commit/d3ed69107330f1a29f45d174caafdec1e894f666)) + +* Remove unused code in zeroconf._core (#1001) + +- Breakout functions without self-use ([`8e45ea9`](https://github.com/python-zeroconf/python-zeroconf/commit/8e45ea943be6490b2217f0eb01501e12a5221c16)) + + +## v0.36.7 (2021-09-22) + +### Unknown + +* Bump version: 0.36.6 → 0.36.7 ([`f44b40e`](https://github.com/python-zeroconf/python-zeroconf/commit/f44b40e26ea8872151ea9ee4762b95ca25790089)) + +* Update changelog for 0.36.7 (#999) ([`d2853c3`](https://github.com/python-zeroconf/python-zeroconf/commit/d2853c31db9ece28fb258c4146ba61cf0e6a6592)) + +* Improve log message when receiving an invalid or corrupt packet (#998) ([`b637846`](https://github.com/python-zeroconf/python-zeroconf/commit/b637846e7df3292d6dcdd38a8eb77b6fa3287c51)) + +* Reduce logging overhead (#994) ([`7df7e4a`](https://github.com/python-zeroconf/python-zeroconf/commit/7df7e4a68e33c3e3a5bddf0168e248a4542a788f)) + +* Reduce overhead to compare dns records (#997) ([`7fa51de`](https://github.com/python-zeroconf/python-zeroconf/commit/7fa51de5b71d03470643a83004b9f6f8d4017214)) + +* Refactor service registry to avoid use of getattr (#996) ([`7622365`](https://github.com/python-zeroconf/python-zeroconf/commit/762236547d4838f2b6a94cfa20221dfdd03e9b94)) + +* Flush CI cache (#995) ([`93ddf7c`](https://github.com/python-zeroconf/python-zeroconf/commit/93ddf7cf9b47d7ff1e341b6c2875254b6f00eef1)) + + +## v0.36.6 (2021-09-19) + +### Unknown + +* Bump version: 0.36.5 → 0.36.6 ([`0327a06`](https://github.com/python-zeroconf/python-zeroconf/commit/0327a068250c85f3ff84d3f0b809b51f83321c47)) + +* Fix tense of 0.36.6 changelog (#992) ([`29f995f`](https://github.com/python-zeroconf/python-zeroconf/commit/29f995fd3c09604f37980e74f2785b1a451da089)) + +* Update changelog for 0.36.6 (#991) ([`92f5f4a`](https://github.com/python-zeroconf/python-zeroconf/commit/92f5f4a80b8a8e50df5ca06e3cc45480dc39b504)) + +* Simplify the can_send_to check (#990) ([`1887c55`](https://github.com/python-zeroconf/python-zeroconf/commit/1887c554b3f9d0b90a1c01798d7f06a7e4de6900)) + + +## v0.36.5 (2021-09-18) + +### Unknown + +* Bump version: 0.36.4 → 0.36.5 ([`34f4a26`](https://github.com/python-zeroconf/python-zeroconf/commit/34f4a26c9254d6002bdccb1a003d9822a8798c04)) + +* Update changelog for 0.36.5 (#989) ([`aebabe9`](https://github.com/python-zeroconf/python-zeroconf/commit/aebabe95c59e34f703307340e087b3eab5339a06)) + +* Seperate zeroconf._protocol into an incoming and outgoing modules (#988) ([`87b6a32`](https://github.com/python-zeroconf/python-zeroconf/commit/87b6a32fb77d9bdcea9d2d7ffba189abc5371b50)) + +* Reduce dns protocol attributes and add slots (#987) ([`f4665fc`](https://github.com/python-zeroconf/python-zeroconf/commit/f4665fc67cd762c4ab66271a550d75640d3bffca)) + +* Fix typo in changelog (#986) ([`4398538`](https://github.com/python-zeroconf/python-zeroconf/commit/43985380b9e995d9790d71486aed258326ad86e4)) + + +## v0.36.4 (2021-09-16) + +### Unknown + +* Bump version: 0.36.3 → 0.36.4 ([`a23f6d2`](https://github.com/python-zeroconf/python-zeroconf/commit/a23f6d2cc40ea696410c3c31b73760065c36f0bf)) + +* Update changelog for 0.36.4 (#985) ([`f4d4164`](https://github.com/python-zeroconf/python-zeroconf/commit/f4d4164989931adbac0e5907b7bf276da1d0d7d7)) + +* Defer decoding known answers until needed (#983) ([`88b9875`](https://github.com/python-zeroconf/python-zeroconf/commit/88b987551cb98757c2df2540ba390f320d46fa7b)) + +* Collapse _GLOBAL_DONE into done (#984) ([`05c4329`](https://github.com/python-zeroconf/python-zeroconf/commit/05c4329d7647c381783ead086c2ed4f3b6b44262)) + +* Remove flake8 requirement restriction as its no longer needed (#981) ([`bc64d63`](https://github.com/python-zeroconf/python-zeroconf/commit/bc64d63ef73e643e71634957fd79e6f6597373d4)) + +* Reduce duplicate code to write records (#979) ([`acf6457`](https://github.com/python-zeroconf/python-zeroconf/commit/acf6457b3c6742c92e9112b0a39a387b33cea4db)) + +* Force CI cache clear (#982) ([`d9ea918`](https://github.com/python-zeroconf/python-zeroconf/commit/d9ea9189def07531d126e01c7397b2596d9a8695)) + +* Reduce name compression overhead and complexity (#978) ([`f1d6fc3`](https://github.com/python-zeroconf/python-zeroconf/commit/f1d6fc3f60e685ff63b1a1cb820cfc3ca5268fcb)) + + +## v0.36.3 (2021-09-14) + +### Unknown + +* Bump version: 0.36.2 → 0.36.3 ([`769b397`](https://github.com/python-zeroconf/python-zeroconf/commit/769b3973835ebc6f5a34e236a01cb2cd935e81de)) + +* Update changelog for 0.36.3 (#977) ([`84f16bf`](https://github.com/python-zeroconf/python-zeroconf/commit/84f16bff6df41f1907e060e7bd4ce24d173d51c4)) + +* Reduce DNSIncoming parsing overhead (#975) + +- Parsing incoming packets is the most expensive operation + zeroconf performs on networks with high mDNS volume ([`78f9cd5`](https://github.com/python-zeroconf/python-zeroconf/commit/78f9cd5123d0e3c582aba05bd61388419d4dc01e)) + + +## v0.36.2 (2021-08-30) + +### Unknown + +* Bump version: 0.36.1 → 0.36.2 ([`5f52438`](https://github.com/python-zeroconf/python-zeroconf/commit/5f52438f4c0851bb1a3b78575c0c28e0b6ce561d)) + +* Update changelog for 0.36.2 (#973) ([`b4efa33`](https://github.com/python-zeroconf/python-zeroconf/commit/b4efa33b4ef6d5292d8d477da4258d99d22c4e84)) + +* Include NSEC records for non-existant types when responding with addresses (#972) + +Implements datatracker.ietf.org/doc/html/rfc6762#section-6.2 ([`7a20fd3`](https://github.com/python-zeroconf/python-zeroconf/commit/7a20fd3bc8dc0a703619ca9413faf674b3d7a111)) + +* Add support for writing NSEC records (#971) ([`768a23c`](https://github.com/python-zeroconf/python-zeroconf/commit/768a23c656e3f091ecbecbb6b380b5becbbf9674)) + + +## v0.36.1 (2021-08-29) + +### Unknown + +* Bump version: 0.36.0 → 0.36.1 ([`e8d8401`](https://github.com/python-zeroconf/python-zeroconf/commit/e8d84017b750ab5f159abc7225f9922d84a8f9fd)) + +* Update changelog for 0.36.1 (#970) ([`d504333`](https://github.com/python-zeroconf/python-zeroconf/commit/d5043337de39a11b2b241e9247a34c41c0c7c2bc)) + +* Skip goodbye packets for addresses when there is another service registered with the same name (#968) ([`d9d3208`](https://github.com/python-zeroconf/python-zeroconf/commit/d9d3208eed84b71b61c458f2992b08b5db259da1)) + +* Fix equality and hash for dns records with the unique bit (#969) ([`574e241`](https://github.com/python-zeroconf/python-zeroconf/commit/574e24125a536dc4fb9a1784797efd495ceb1fdf)) + + +## v0.36.0 (2021-08-16) + +### Unknown + +* Bump version: 0.35.1 → 0.36.0 ([`e4985c7`](https://github.com/python-zeroconf/python-zeroconf/commit/e4985c7dd2088d4da9fc2be25f67beb65f548e95)) + +* Update changelog for 0.36.0 (#966) ([`bc50bce`](https://github.com/python-zeroconf/python-zeroconf/commit/bc50bce04b650756fef3f8b1cce6defbc5dccee5)) + +* Create full IPv6 address tuple to enable service discovery on Windows (#965) ([`733eb3a`](https://github.com/python-zeroconf/python-zeroconf/commit/733eb3a31ed40c976f5fa4b7b3baf055589ef36b)) + + +## v0.35.1 (2021-08-15) + +### Unknown + +* Bump version: 0.35.0 → 0.35.1 ([`4281221`](https://github.com/python-zeroconf/python-zeroconf/commit/4281221b668123b770c6d6b0835dd876d1d2f22d)) + +* Fix formatting in 0.35.1 changelog entry (#964) ([`c7c7d47`](https://github.com/python-zeroconf/python-zeroconf/commit/c7c7d4778e9962af5180616af73977d8503e4762)) + +* Update changelog for 0.35.1 (#963) ([`f7bebfe`](https://github.com/python-zeroconf/python-zeroconf/commit/f7bebfe09aeb9bb973dbe6ba147b682472b64246)) + +* Cache DNS record and question hashes (#960) ([`d4c109c`](https://github.com/python-zeroconf/python-zeroconf/commit/d4c109c3abffcba2331a7f9e7bf45c6477a8d4e8)) + +* Fix flakey test: test_future_answers_are_removed_on_send (#962) ([`3b482e2`](https://github.com/python-zeroconf/python-zeroconf/commit/3b482e229d37b85e59765e023ddbca77aa513731)) + +* Add coverage for sending answers removes future queued answers (#961) + +- If we send an answer that is queued to be sent out in the future + we should remove it from the queue as the question has already + been answered and we do not want to generate additional traffic. ([`2d1b832`](https://github.com/python-zeroconf/python-zeroconf/commit/2d1b8329ad39b94f9f4aa5f53caf3bb2813879ca)) + +* Only reschedule types if the send next time changes (#958) + +- When the PTR response was seen again, the timer was being canceled and + rescheduled even if the timer was for the same time. While this did + not cause any breakage, it is quite inefficient. ([`7b125a1`](https://github.com/python-zeroconf/python-zeroconf/commit/7b125a1a0a109ef29d0a4e736a27645a7e9b4207)) + + +## v0.35.0 (2021-08-13) + +### Unknown + +* Bump version: 0.34.3 → 0.35.0 ([`1e60e13`](https://github.com/python-zeroconf/python-zeroconf/commit/1e60e13ae15a5b533a48cc955b98951eedd04dbb)) + +* Update changelog for 0.35.0 (#957) ([`dd40437`](https://github.com/python-zeroconf/python-zeroconf/commit/dd40437f4328f4ee36c43239ecf5f484b6ac261e)) + +* Reduce chance of accidental synchronization of ServiceInfo requests (#955) ([`c772936`](https://github.com/python-zeroconf/python-zeroconf/commit/c77293692062ea701037e06c1cf5497f019ae2f2)) + +* Send unicast replies on the same socket the query was received (#952) + +When replying to a QU question, we do not know if the sending host is reachable +from all of the sending sockets. We now avoid this problem by replying via +the receiving socket. This was the existing behavior when `InterfaceChoice.Default` +is set. + +This change extends the unicast relay behavior to used with `InterfaceChoice.Default` +to apply when `InterfaceChoice.All` or interfaces are explicitly passed when +instantiating a `Zeroconf` instance. + +Fixes #951 ([`5fb3e20`](https://github.com/python-zeroconf/python-zeroconf/commit/5fb3e202c06e3a0d30e3c7824397d8e8a9f52555)) + +* Sort responses to increase chance of name compression (#954) + +- When building an outgoing response, sort the names together + to increase the likelihood of name compression. In testing + this reduced the number of packets for large responses + (from 7 packets to 6) ([`ebc23ee`](https://github.com/python-zeroconf/python-zeroconf/commit/ebc23ee5e9592dd7f0235cd57f9b3ad727ec8bff)) + + +## v0.34.3 (2021-08-09) + +### Unknown + +* Bump version: 0.34.2 → 0.34.3 ([`9d69d18`](https://github.com/python-zeroconf/python-zeroconf/commit/9d69d18713bdfab53762a6b8c3aff7fd72ebd025)) + +* Update changelog for 0.34.3 (#950) ([`23b00e9`](https://github.com/python-zeroconf/python-zeroconf/commit/23b00e983b2e8335431dcc074935f379fd399d46)) + +* Fix sending immediate multicast responses (#949) + +- Fixes a typo in handle_assembled_query that prevented immediate + responses from being sent. ([`02af7f7`](https://github.com/python-zeroconf/python-zeroconf/commit/02af7f78d2e5eabcc5cce8238546ee5170951b28)) + + +## v0.34.2 (2021-08-09) + +### Unknown + +* Bump version: 0.34.1 → 0.34.2 ([`6c21f68`](https://github.com/python-zeroconf/python-zeroconf/commit/6c21f6802b58d949038e9c8501ea204eeda57a16)) + +* Update changelog for 0.34.2 (#947) ([`b87f493`](https://github.com/python-zeroconf/python-zeroconf/commit/b87f4934b39af02f26bbbfd6f372c7154fe95906)) + +* Ensure ServiceInfo requests can be answered with the default timeout with network protection (#946) + +- Adjust the time windows to ensure responses that have triggered the +protection against against excessive packet flooding due to +software bugs or malicious attack described in RFC6762 section 6 +can respond in under 1350ms to ensure ServiceInfo can ask two +questions within the default timeout of 3000ms ([`6d7266d`](https://github.com/python-zeroconf/python-zeroconf/commit/6d7266d0e1e6dcb950456da0354b4c43fd5c0ecb)) + +* Coalesce aggregated multicast answers when the random delay is shorter than the last scheduled response (#945) + +- Reduces traffic when we already know we will be sending a group of answers + inside the random delay window described in + https://datatracker.ietf.org/doc/html/rfc6762#section-6.3 + +closes #944 ([`9a5164a`](https://github.com/python-zeroconf/python-zeroconf/commit/9a5164a7a3231903537231bfb56479e617355f92)) + + +## v0.34.1 (2021-08-08) + +### Unknown + +* Bump version: 0.34.0 → 0.34.1 ([`7878a9e`](https://github.com/python-zeroconf/python-zeroconf/commit/7878a9eed93a8ec2396d8450389a08bf54bd5693)) + +* Update changelog for 0.34.1 (#943) ([`9942484`](https://github.com/python-zeroconf/python-zeroconf/commit/9942484172d7a79fe84c47924538c2c02fde7264)) + +* Ensure multicast aggregation sends responses within 620ms (#942) ([`de96e2b`](https://github.com/python-zeroconf/python-zeroconf/commit/de96e2bf01af68d754bb7c71da949e30de88a77b)) + + +## v0.34.0 (2021-08-08) + +### Unknown + +* Bump version: 0.33.4 → 0.34.0 ([`549ac3d`](https://github.com/python-zeroconf/python-zeroconf/commit/549ac3de27eb3924cc7967088c3d316184722b9d)) + +* Update changelog for 0.34.0 (#941) ([`342532e`](https://github.com/python-zeroconf/python-zeroconf/commit/342532e1d13ac24673735dc467a79edebdfb9362)) + +* Implement Multicast Response Aggregation (#940) + +- Responses are now aggregated when possible per rules in RFC6762 section 6.4 +- Responses that trigger the protection against against excessive packet flooding due to + software bugs or malicious attack described in RFC6762 section 6 are delayed instead of discarding as it was causing responders that implement Passive Observation Of Failures (POOF) to evict the records. +- Probe responses are now always sent immediately as there were cases where they would fail to be answered in time to defend a name. + +closes #939 ([`55efb41`](https://github.com/python-zeroconf/python-zeroconf/commit/55efb4169b588cef093f3065f3a894878ae8bd95)) + + +## v0.33.4 (2021-08-06) + +### Unknown + +* Bump version: 0.33.3 → 0.33.4 ([`7bbacd5`](https://github.com/python-zeroconf/python-zeroconf/commit/7bbacd57a134c12ee1fb61d8318b312dfdae18f8)) + +* Update changelog for 0.33.4 (#937) ([`858605d`](https://github.com/python-zeroconf/python-zeroconf/commit/858605db52f909d41198df76130597ff93f64cdd)) + +* Ensure zeroconf can be loaded when the system disables IPv6 (#933) + +Co-authored-by: J. Nick Koston ([`496ac44`](https://github.com/python-zeroconf/python-zeroconf/commit/496ac44e99b56485cc9197490e71bb2dd7bec6f9)) + + +## v0.33.3 (2021-08-05) + +### Unknown + +* Bump version: 0.33.2 → 0.33.3 ([`206671a`](https://github.com/python-zeroconf/python-zeroconf/commit/206671a1237ee8237d302b04c5a84158fed1d50b)) + +* Update changelog for 0.33.3 (#936) ([`6a140cc`](https://github.com/python-zeroconf/python-zeroconf/commit/6a140cc6b9c7e50e572456662d2f76f6fbc2ed25)) + +* Add support for forward dns compression pointers (#934) + +- nslookup supports these and some implementations (likely avahi) + will generate them + +- Careful attention was given to make sure we detect loops + and do not create anti-patterns described in + https://github.com/Forescout/namewreck/blob/main/rfc/draft-dashevskyi-dnsrr-antipatterns-00.txt + +Fixes https://github.com/home-assistant/core/issues/53937 +Fixes https://github.com/home-assistant/core/issues/46985 +Fixes https://github.com/home-assistant/core/issues/53668 +Fixes #308 ([`5682a4c`](https://github.com/python-zeroconf/python-zeroconf/commit/5682a4c3c89043bf8a10e79232933ada5ab71972)) + +* Provide sockname when logging a protocol error (#935) ([`319992b`](https://github.com/python-zeroconf/python-zeroconf/commit/319992bb093d9b965976bad724512d9bcd05aca7)) + + +## v0.33.2 (2021-07-28) + +### Unknown + +* Bump version: 0.33.1 → 0.33.2 ([`4d30c25`](https://github.com/python-zeroconf/python-zeroconf/commit/4d30c25fe57425bcae36a539006e44941ef46e2c)) + +* Update changelog for 0.33.2 (#931) ([`c80b5f7`](https://github.com/python-zeroconf/python-zeroconf/commit/c80b5f7253e521928d6f7e54681675be59371c6c)) + +* Handle duplicate goodbye answers in the same packet (#928) + +- Solves an exception being thrown when we tried to remove the known answer + from the cache when the second goodbye answer in the same packet was processed + +- We previously swallowed all exceptions on cache removal so this was not + visible until 0.32.x which removed the broad exception catch + +Fixes #926 ([`97e0b66`](https://github.com/python-zeroconf/python-zeroconf/commit/97e0b669be60f716e45e963f1bcfcd35b7213626)) + +* Skip ipv6 interfaces that return ENODEV (#930) ([`73e3d18`](https://github.com/python-zeroconf/python-zeroconf/commit/73e3d1865f4167e7c9f7c23ec4cc7ebfac40f512)) + +* Remove some pylint workarounds (#925) ([`1247acd`](https://github.com/python-zeroconf/python-zeroconf/commit/1247acd2e6f6154a4e5f2e27a820c55329391d8e)) + + +## v0.33.1 (2021-07-18) + +### Unknown + +* Bump version: 0.33.0 → 0.33.1 ([`6774de3`](https://github.com/python-zeroconf/python-zeroconf/commit/6774de3e7f8b461ccb83675bbb05d47949df487b)) + +* Update changelog for 0.33.1 (#924) + +- Fixes overly restrictive directory permissions reported in #923 ([`ed80333`](https://github.com/python-zeroconf/python-zeroconf/commit/ed80333896c0710857cc46b5af4d7ba3a81e07c8)) + + +## v0.33.0 (2021-07-18) + +### Unknown + +* Bump version: 0.32.1 → 0.33.0 ([`cfb28aa`](https://github.com/python-zeroconf/python-zeroconf/commit/cfb28aaf134e566d8a89b397967d1ad1ec66de35)) + +* Update changelog for 0.33.0 release (#922) ([`e4a9655`](https://github.com/python-zeroconf/python-zeroconf/commit/e4a96550398c408c3e1e6944662cc3093db912a7)) + +* Fix examples/async_registration.py attaching to the correct loop (#921) ([`b0b23f9`](https://github.com/python-zeroconf/python-zeroconf/commit/b0b23f96d3b33a627a0d071557a36af97a65dae4)) + +* Add support for bump2version (#920) ([`2e00002`](https://github.com/python-zeroconf/python-zeroconf/commit/2e0000252f0aecad8b62a649128326a6528b6824)) + +* Update changelog for 0.33.0 release (#919) ([`96be961`](https://github.com/python-zeroconf/python-zeroconf/commit/96be9618ede3c941e23cb23398b9aed11bed1ffa)) + +* Let connection_lost close the underlying socket (#918) + +- The socket was closed during shutdown before asyncio's connection_lost + handler had a chance to close it which resulted in a traceback on + win32. + +- Fixes #917 ([`919b096`](https://github.com/python-zeroconf/python-zeroconf/commit/919b096d6260a4f9f4306b9b4dddb5b026b49462)) + +* Reduce complexity of DNSRecord (#915) + +- Use constants for calculations in is_expired/is_stale/is_recent ([`b6eaf72`](https://github.com/python-zeroconf/python-zeroconf/commit/b6eaf7249f386f573b0876204ccfdfa02ee9ac5b)) + +* Remove Zeroconf.wait as its now unused in the codebase (#914) ([`aa71084`](https://github.com/python-zeroconf/python-zeroconf/commit/aa7108481235cc018600d096b093c785447d8769)) + +* Switch periodic cleanup task to call_later (#913) + +- Simplifies AsyncEngine to avoid the long running + task ([`38eb271`](https://github.com/python-zeroconf/python-zeroconf/commit/38eb271c952e89260ecac6fac3e723f4206c4648)) + +* Update changelog for 0.33.0 (#912) ([`b2a7a00`](https://github.com/python-zeroconf/python-zeroconf/commit/b2a7a00f82d401066166776cecf0857ebbdb56ad)) + +* Remove locking from ServiceRegistry (#911) + +- All calls to the ServiceRegistry are now done in async context + which makes them thread safe. Locking is no longer needed. ([`2d3da7a`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3da7a77699f88bd90ebc09d36b333690385f85)) + +* Remove duplicate unregister_all_services code (#910) ([`e63ca51`](https://github.com/python-zeroconf/python-zeroconf/commit/e63ca518c91cda7b9f460436aee4fdac1a7b9567)) + +* Rename DNSNsec.next to DNSNsec.next_name (#908) ([`69942d5`](https://github.com/python-zeroconf/python-zeroconf/commit/69942d5bfb4d92c6a312aea7c17f63fce0401e23)) + +* Upgrade syntax to python 3.6 (#907) ([`0578731`](https://github.com/python-zeroconf/python-zeroconf/commit/057873128ff05a0b2d6eae07510e23d705d10bae)) + +* Implement NSEC record parsing (#903) + +- This is needed for negative responses + https://datatracker.ietf.org/doc/html/rfc6762#section-6.1 ([`bc9e9cf`](https://github.com/python-zeroconf/python-zeroconf/commit/bc9e9cf8a5b997ca924730ed091a829f4f961ca3)) + +* Centralize running coroutines from threads (#906) + +- Cleanup to ensure all coros we run from a thread + use _LOADED_SYSTEM_TIMEOUT ([`9399c57`](https://github.com/python-zeroconf/python-zeroconf/commit/9399c57bb2b280c7b433e7fbea7cca2c2f4417ee)) + +* Reduce duplicate code between zeroconf.asyncio and zeroconf._core (#904) ([`e417fc0`](https://github.com/python-zeroconf/python-zeroconf/commit/e417fc0f5ed7eaa47a0dcaffdbc6fe335bfcc058)) + +* Disable N818 in flake8 (#905) + +- We cannot rename these exceptions now without a breaking change + as they have existed for many years ([`f8af0fb`](https://github.com/python-zeroconf/python-zeroconf/commit/f8af0fb251938dcb410127b2af2b8b407989aa08)) + + +## v0.32.1 (2021-07-05) + +### Unknown + +* Release version 0.32.1 ([`675fd6f`](https://github.com/python-zeroconf/python-zeroconf/commit/675fd6fc959e76e4e3690e5c7a02db269ca9ef60)) + +* Fix the changelog's one sentence's tense ([`fc089be`](https://github.com/python-zeroconf/python-zeroconf/commit/fc089be1f412d991f44daeecd0944198d3a638a5)) + +* Update changelog (#899) ([`a93301d`](https://github.com/python-zeroconf/python-zeroconf/commit/a93301d0fd493bf18147187bf8efed1a4ea02214)) + +* Increase timeout in ServiceInfo.request to handle loaded systems (#895) + +It can take a few seconds for a loaded system to run the `async_request` coroutine when the event loop is busy or the system is CPU bound (example being Home Assistant startup). We now add +an additional `_LOADED_SYSTEM_TIMEOUT` (10s) to the `run_coroutine_threadsafe` calls to ensure the coroutine has the total amount of time to run up to its internal timeout (default of 3000ms). + +Ten seconds is a bit large of a timeout; however, its only unused in cases where we wrap other timeouts. We now expect the only instance the `run_coroutine_threadsafe` result timeout will happen in a production circumstance is when someone is running a `ServiceInfo.request()` in a thread and another thread calls `Zeroconf.close()` at just the right moment that the future is never completed unless the system is so loaded that it is nearly unresponsive. + +The timeout for `run_coroutine_threadsafe` is the maximum time a thread can cleanly shut down when zeroconf is closed out in another thread, which should always be longer than the underlying thread operation. ([`56c7d69`](https://github.com/python-zeroconf/python-zeroconf/commit/56c7d692d67b7f56c386a7f1f4e45ebfc4e8366a)) + +* Add test for running sync code within executor (#894) ([`90bc8ca`](https://github.com/python-zeroconf/python-zeroconf/commit/90bc8ca8dce1af26ea81c5d6ecb17cf6ea664a71)) + + +## v0.32.0 (2021-06-30) + +### Unknown + +* Fix readme formatting + +It wasn't proper reStructuredText before: + + % twine check dist/* + Checking dist/zeroconf-0.32.0-py3-none-any.whl: FAILED + `long_description` has syntax errors in markup and would not be rendered on PyPI. + line 381: Error: Unknown target name: "async". + warning: `long_description_content_type` missing. defaulting to `text/x-rst`. + Checking dist/zeroconf-0.32.0.tar.gz: FAILED + `long_description` has syntax errors in markup and would not be rendered on PyPI. + line 381: Error: Unknown target name: "async". + warning: `long_description_content_type` missing. defaulting to `text/x-rst`. ([`82ff150`](https://github.com/python-zeroconf/python-zeroconf/commit/82ff150e0a72a7e20823a0c805f48f117bf1e274)) + +* Release version 0.32.0 ([`ea7bc85`](https://github.com/python-zeroconf/python-zeroconf/commit/ea7bc8592e418332e5b9973007698d3cd79754d9)) + +* Reformat changelog to match prior versions (#892) ([`34f6e49`](https://github.com/python-zeroconf/python-zeroconf/commit/34f6e498dec18b84dab1c27c75348916bceef8e6)) + +* Fix spelling and grammar errors in 0.32.0 changelog (#891) ([`ba235dd`](https://github.com/python-zeroconf/python-zeroconf/commit/ba235dd8bc65de4f461f76fd2bf4647844437e1a)) + +* Rewrite 0.32.0 changelog in past tense (#890) ([`0d91156`](https://github.com/python-zeroconf/python-zeroconf/commit/0d911568d367f1520acb19bdf830fe188b6ffb70)) + +* Reformat backwards incompatible changes to match previous versions (#889) ([`9abb40c`](https://github.com/python-zeroconf/python-zeroconf/commit/9abb40cf331bc0acc5fdbb03fce5c958cec8b41e)) + +* Remove extra newlines between changelog entries (#888) ([`d31fd10`](https://github.com/python-zeroconf/python-zeroconf/commit/d31fd103cc942574f7fbc75e5346cc3d3eaf7ee1)) + +* Collapse changelog for 0.32.0 (#887) ([`14cf936`](https://github.com/python-zeroconf/python-zeroconf/commit/14cf9362c9ae947bcee5911b9c593ca76f50d529)) + +* Disable pylint in the CI (#886) ([`b9dc12d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9dc12dee8b4a7f6d8e1f599948bf16e5e7fab47)) + +* Revert name change of zeroconf.asyncio to zeroconf.aio (#885) + +- Now that `__init__.py` no longer needs to import `asyncio`, + the name conflict is not a concern. + +Fixes #883 ([`b9eae5a`](https://github.com/python-zeroconf/python-zeroconf/commit/b9eae5a6f8f86bfe60446f133cad5fc33d072959)) + +* Update changelog (#879) ([`be1d3bb`](https://github.com/python-zeroconf/python-zeroconf/commit/be1d3bbe0ee12254d11e3d8b75c2faba950fabce)) + +* Add coverage to ensure loading zeroconf._logger does not override logging level (#878) ([`86e2ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/86e2ab9db3c7bd47b6e81837d594280ced3b30f9)) + +* Add coverge for disconnected adapters in add_multicast_member (#877) ([`ab83819`](https://github.com/python-zeroconf/python-zeroconf/commit/ab83819ad6b6ff727a894271dde3e4be6c28cb2c)) + +* Break apart net_socket for easier testing (#875) ([`f0770fe`](https://github.com/python-zeroconf/python-zeroconf/commit/f0770fea80b00f2340815fa983968f68a15c702e)) + +* Fix flapping test test_integration_with_listener_class (#876) ([`decd8a2`](https://github.com/python-zeroconf/python-zeroconf/commit/decd8a26aa8a89ceefcd9452fe562f2eeaa3fecb)) + +* Add coverage to ensure unrelated A records do not generate ServiceBrowser callbacks (#874) + +closes #871 ([`471bacd`](https://github.com/python-zeroconf/python-zeroconf/commit/471bacd3200aa1216054c0e52b2e5842e9760aa0)) + +* Update changelog (#870) ([`972da99`](https://github.com/python-zeroconf/python-zeroconf/commit/972da99e4dd9d0fe1c1e0786da45d66fd43a717a)) + +* Fix deadlock when event loop is shutdown during service registration (#869) ([`4ed9036`](https://github.com/python-zeroconf/python-zeroconf/commit/4ed903698b10f434cfbbe601998f27c10d2fb9db)) + +* Break apart new_socket to be testable (#867) ([`22ff6b5`](https://github.com/python-zeroconf/python-zeroconf/commit/22ff6b56d7b6531d2af5c50dca66fd2be2b276f4)) + +* Add test coverage to ensure ServiceBrowser ignores unrelated updates (#866) ([`dcf18c8`](https://github.com/python-zeroconf/python-zeroconf/commit/dcf18c8a32652c6aa70af180b6a5261f4277faa9)) + +* Add test coverage for duplicate properties in a TXT record (#865) ([`6ef65fc`](https://github.com/python-zeroconf/python-zeroconf/commit/6ef65fc7cafc3d4089a2b943da224c6cb027b4b0)) + +* Update changelog (#864) ([`c64064a`](https://github.com/python-zeroconf/python-zeroconf/commit/c64064ad3b38a40775637c0fd8877d9d00d2d537)) + +* Ensure protocol and sending errors are logged once (#862) ([`c516919`](https://github.com/python-zeroconf/python-zeroconf/commit/c516919064687551299f23e23bf0797888020041)) + +* Remove unreachable code in AsyncListener.datagram_received (#863) ([`f536869`](https://github.com/python-zeroconf/python-zeroconf/commit/f5368692d7907e440ca81f0acee9744f79dbae80)) + +* Add unit coverage for shutdown_loop (#860) ([`af83c76`](https://github.com/python-zeroconf/python-zeroconf/commit/af83c766c2ae72bd23184c6f6300e4d620c7b3e8)) + +* Make a dispatch dict for ServiceStateChange listeners (#859) ([`57cccc4`](https://github.com/python-zeroconf/python-zeroconf/commit/57cccc4dcbdc9df52672297968ccb55054122049)) + +* Cleanup coverage data (#858) ([`3eb7be9`](https://github.com/python-zeroconf/python-zeroconf/commit/3eb7be95fd6cd4960f96f29aa72fc45347c57b6e)) + +* Fix changelog formatting (#857) ([`59247f1`](https://github.com/python-zeroconf/python-zeroconf/commit/59247f1c44b485bf51d4a8d3e3966b9faf40cf82)) + +* Update changelog (#856) ([`cb2e237`](https://github.com/python-zeroconf/python-zeroconf/commit/cb2e237b6f1af0a83bc7352464562cdb7bbcac14)) + +* Only run linters on Linux in CI (#855) + +- The github MacOS and Windows runners are slower and + will have the same results as the Linux runners so there + is no need to wait for them. + +closes #854 ([`03411f3`](https://github.com/python-zeroconf/python-zeroconf/commit/03411f35d82752d5d2633a67db132a011098d9e6)) + +* Speed up test_verify_name_change_with_lots_of_names under PyPy (#853) + +fixes #840 ([`0cd876f`](https://github.com/python-zeroconf/python-zeroconf/commit/0cd876f5a42699aeb0176380ba4cca4d8a536df3)) + +* Make ServiceInfo first question QU (#852) + +- We want an immediate response when making a request with ServiceInfo + by asking a QU question, most responders will not delay the response + and respond right away to our question. This also improves compatibility + with split networks as we may not have been able to see the response + otherwise. If the responder has not multicast the record recently + it may still choose to do so in addition to responding via unicast + +- Reduces traffic when there are multiple zeroconf instances running + on the network running ServiceBrowsers + +- If we don't get an answer on the first try, we ask a QM question + in the event we can't receive a unicast response for some reason + +- This change puts ServiceInfo inline with ServiceBrowser which + also asks the first question as QU since ServiceInfo is commonly + called from ServiceBrowser callbacks + +closes #851 ([`76e0b05`](https://github.com/python-zeroconf/python-zeroconf/commit/76e0b05ca9c601bd638817bf68ca8d981f1d65f8)) + +* Update changelog (#850) ([`8c9d1d8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c9d1d8964d9226d5d3ac38bec908e930954b369)) + +* Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849) + +- Simplifies scheduling as there is no more need to sleep in a loop as + we now schedule future callbacks with call_later + +- Simplifies cancelation as there is no more coroutine to cancel, only a timer handle + We no longer have to handle the canceled error and cleaning up the awaitable + +- Solves the infrequent test failures in test_backoff and test_integration ([`a8c1623`](https://github.com/python-zeroconf/python-zeroconf/commit/a8c16231881de43adedbedbc3f1ea707c0b457f2)) + +* Fix spurious failures in ZeroconfServiceTypes tests (#848) + +- These tests ran the same test twice in 0.5s and would + trigger the duplicate packet suppression. Rather then + making them run longer, we can disable the suppression + for the test. ([`9f71e5b`](https://github.com/python-zeroconf/python-zeroconf/commit/9f71e5b7364d4a23492cafe4f49a5c2acda4178d)) + +* Fix thread safety in handlers test (#847) ([`182c68f`](https://github.com/python-zeroconf/python-zeroconf/commit/182c68ff11ba381444a708e17560e920ae1849ef)) + +* Update changelog (#845) ([`72502c3`](https://github.com/python-zeroconf/python-zeroconf/commit/72502c303a1a889cf84906b8764fd941a840e6d3)) + +* Increase timeout in test_integration (#844) + +- The github macOS runners tend to be a bit loaded and these + sometimes fail because of it ([`dd86f2f`](https://github.com/python-zeroconf/python-zeroconf/commit/dd86f2f9fee4bbaebce956b330c1837a6e9c6c99)) + +* Use AAAA records instead of A records in test_integration_with_listener_ipv6 (#843) ([`688c518`](https://github.com/python-zeroconf/python-zeroconf/commit/688c5184dce67e5af857c138639ced4bdcec1e57)) + +* Fix ineffective patching on PyPy (#842) + +- Use patch in all places so its easier to find where we need + to clean up ([`ecd9c94`](https://github.com/python-zeroconf/python-zeroconf/commit/ecd9c941810e4b413b20dc55929b3ae1a7e57b27)) + +* Limit duplicate packet suppression to 1s intervals (#841) + +- Only suppress duplicate packets that happen within the same + second. Legitimate queriers will retry the question if they + are suppressed. The limit was reduced to one second to be + in line with rfc6762: + + To protect the network against excessive packet flooding due to + software bugs or malicious attack, a Multicast DNS responder MUST NOT + (except in the one special case of answering probe queries) multicast + a record on a given interface until at least one second has elapsed + since the last time that record was multicast on that particular ([`7fb11bf`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb11bfc03c06cbe9ed5a4303b3e632d69665bb1)) + +* Skip dependencies install in CI on cache hit (#839) + +There is no need to reinstall dependencies in the CI when we have a cache hit. ([`937be52`](https://github.com/python-zeroconf/python-zeroconf/commit/937be522a42830b27326b5253d49003b57998bc9)) + +* Adjust restore key for CI cache (#838) ([`3fdd834`](https://github.com/python-zeroconf/python-zeroconf/commit/3fdd8349553c160586fb6831c9466410f19a3308)) + +* Make multipacket known answer suppression per interface (#836) + +- The suppression was happening per instance of Zeroconf instead + of per interface. Since the same network can be seen on multiple + interfaces (usually and wifi and ethernet), this would confuse the + multi-packet known answer supression since it was not expecting + to get the same data more than once + +Fixes #835 ([`7297f3e`](https://github.com/python-zeroconf/python-zeroconf/commit/7297f3ef71c9984296c3e28539ce7a4b42f04a05)) + +* Ensure coverage.xml is written for codecov (#837) ([`0b1abbc`](https://github.com/python-zeroconf/python-zeroconf/commit/0b1abbc8f2b09235cfd44e5586024c7b82dc5289)) + +* Wait for startup in test_integration (#834) ([`540c652`](https://github.com/python-zeroconf/python-zeroconf/commit/540c65218eb9d1aedc88a3d3724af97f39ccb88e)) + +* Cache dependency installs in CI (#833) ([`0bf4f75`](https://github.com/python-zeroconf/python-zeroconf/commit/0bf4f7537a042a00d9d3f815afcdf7ebe29d9f53)) + +* Annotate test failures on github (#831) ([`4039b0b`](https://github.com/python-zeroconf/python-zeroconf/commit/4039b0b755a3d0fe15e4cb1a7cb1592c35e048e1)) + +* Show 20 slowest tests on each run (#832) ([`8230e3f`](https://github.com/python-zeroconf/python-zeroconf/commit/8230e3f40da5d2d152942725d67d5f8c0b8c647b)) + +* Disable duplicate question suppression for test_integration (#830) + +- This test waits until we get 50 known answers. It would + sometimes fail because it could not ask enough + unsuppressed questions in the allowed time. ([`10f4a7f`](https://github.com/python-zeroconf/python-zeroconf/commit/10f4a7f8d607d09673be56e5709912403503d86b)) + +* Convert test_integration to asyncio to avoid testing threading races (#828) + +Fixes #768 ([`4c4b388`](https://github.com/python-zeroconf/python-zeroconf/commit/4c4b388ba125ad23a03722b30c71da86853fe05a)) + +* Update changelog (#827) ([`82f80c3`](https://github.com/python-zeroconf/python-zeroconf/commit/82f80c301a6324d2f1711ca751e81069e90030ec)) + +* Drop oversize packets before processing them (#826) + +- Oversized packets can quickly overwhelm the system and deny + service to legitimate queriers. In practice this is usually + due to broken mDNS implementations rather than malicious + actors. ([`6298ef9`](https://github.com/python-zeroconf/python-zeroconf/commit/6298ef9078cf2408bc1e57660ee141e882d13469)) + +* Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) + +* We now enforce a minimum TTL for PTR records to avoid +ServiceBrowsers generating excessive queries refresh queries. +Apple uses a 15s minimum TTL, however we do not have the same +level of rate limit and safe guards so we use 1/4 of the recommended value. ([`7f6d003`](https://github.com/python-zeroconf/python-zeroconf/commit/7f6d003210244b6f7df133bd474d7ddf64098422)) + +* Update changelog (#822) ([`4a82769`](https://github.com/python-zeroconf/python-zeroconf/commit/4a8276941a07188180ee31dc4ca578306c2df92b)) + +* Only wake up the query loop when there is a change in the next query time (#818) + +The ServiceBrowser query loop (async_browser_task) was being awoken on +every packet because it was using `zeroconf.async_wait` which wakes +up on every new packet. We only need to awaken the loop when the next time +we are going to send a query has changed. + +fixes #814 fixes #768 ([`4062fe2`](https://github.com/python-zeroconf/python-zeroconf/commit/4062fe21d8baaad36960f8cae0f59ac7083a6b55)) + +* Fix reliablity of tests that patch sending (#820) ([`a7b4f8e`](https://github.com/python-zeroconf/python-zeroconf/commit/a7b4f8e070de69db1ed872e2ff7a953ec624394c)) + +* Fix default v6_flow_scope argument with tests that mock send (#819) ([`f9d3529`](https://github.com/python-zeroconf/python-zeroconf/commit/f9d35299a39fee0b1632a3b2ac00170f761d53b1)) + +* Turn on logging in the types test (#816) + +- Will be needed to track down #813 ([`ffd2532`](https://github.com/python-zeroconf/python-zeroconf/commit/ffd2532f72a59ede86732b310512774b8fa344e7)) + +* New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) ([`e32bb5d`](https://github.com/python-zeroconf/python-zeroconf/commit/e32bb5d98be0dc7ed130224206a4de699bcd68e3)) + +* Update changelog (#811) ([`13c558c`](https://github.com/python-zeroconf/python-zeroconf/commit/13c558cf3f40e52a13347a39b050e49a9241c269)) + +* Simplify wait_event_or_timeout (#810) + +- This function always did the same thing on timeout and + wait complete so we can use the same callback. This + solves the CI failing due to the test coverage flapping + back and forth as the timeout would rarely happen. ([`d4c8f0d`](https://github.com/python-zeroconf/python-zeroconf/commit/d4c8f0d3ffdcdc609810aca383492a57f9e1a723)) + +* Make DNSHinfo and DNSAddress use the same match order as DNSPointer and DNSText (#808) + +We want to check the data that is most likely to be unique first +so we can reject the __eq__ as soon as possible. ([`f9bbbce`](https://github.com/python-zeroconf/python-zeroconf/commit/f9bbbce388f2c6c24109c15ef843c10eeccf008f)) + +* Format tests/services/test_info.py with newer black (#809) ([`0129ac0`](https://github.com/python-zeroconf/python-zeroconf/commit/0129ac061db4a950f7bddf1084309e44aaabdbdf)) + +* Qualify IPv6 link-local addresses with scope_id (#343) + +Co-authored-by: Lokesh Prajapati +Co-authored-by: de Angelis, Antonio + +When a service is advertised on an IPv6 address where +the scope is link local, i.e. fe80::/64 (see RFC 4007) +the resolved IPv6 address must be extended with the +scope_id that identifies through the "%" symbol the +local interface to be used when routing to that address. +A new API `parsed_scoped_addresses()` is provided to +return qualified addresses to avoid breaking compatibility +on the existing parsed_addresses(). ([`05bb21b`](https://github.com/python-zeroconf/python-zeroconf/commit/05bb21b9b43f171e30b48fad6a756df49162b557)) + +* Tag 0.32.0b3 (#805) ([`5dccf34`](https://github.com/python-zeroconf/python-zeroconf/commit/5dccf3496a9bd4c268da4c39aab545ddcd50ac57)) + +* Update changelog (#804) ([`59e4bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/59e4bd25347aac254700dc3a1518676042982b3a)) + +* Skip network adapters that are disconnected (#327) + +Co-authored-by: J. Nick Koston ([`df66da2`](https://github.com/python-zeroconf/python-zeroconf/commit/df66da2a943b9ff978602680b746f1edeba048dc)) + +* Add slots to DNS classes (#803) + +- On a busy network that receives many mDNS packets per second, we + will not know the answer to most of the questions being asked. + In this case the creating the DNS* objects are usually garbage + collected within 1s as they are not needed. We now set __slots__ + to speed up the creation and destruction of these objects ([`18fe341`](https://github.com/python-zeroconf/python-zeroconf/commit/18fe341300e28ed93d7b5d7ca8e07edb119bd597)) + +* Update changelog (#802) ([`58ae3cf`](https://github.com/python-zeroconf/python-zeroconf/commit/58ae3cf553cd925ac90f3db551f4085ea5bc8b79)) + +* Update changelog (#801) ([`662ed61`](https://github.com/python-zeroconf/python-zeroconf/commit/662ed6166282b9b5b6e83a596c0576a57f8962d2)) + +* Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) ([`bbc9124`](https://github.com/python-zeroconf/python-zeroconf/commit/bbc91241a86f3339aa27cae7b4ea2ab9d7c1f37d)) + +* Update changelog (#798) ([`9961dce`](https://github.com/python-zeroconf/python-zeroconf/commit/9961dce598d3c6eeda68a2f874a7a50ec33f819c)) + +* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) ([`38e66ec`](https://github.com/python-zeroconf/python-zeroconf/commit/38e66ec5ba5fcb96cef17b8949385075807a2fb7)) + +* Update changelog (#797) ([`c36099a`](https://github.com/python-zeroconf/python-zeroconf/commit/c36099a41a71298d58e7afa42ecdc7a54d3b010a)) + +* Pass both the new and old records to async_update_records (#792) + +* Pass the old_record (cached) as the value and the new_record (wire) +to async_update_records instead of forcing each consumer to +check the cache since we will always have the old_record +when generating the async_update_records call. This avoids +the overhead of multiple cache lookups for each listener. ([`d637d67`](https://github.com/python-zeroconf/python-zeroconf/commit/d637d67378698e0a505be90afbce4e2264b49444)) + +* Remove unused constant from zeroconf._handlers (#796) ([`cb91484`](https://github.com/python-zeroconf/python-zeroconf/commit/cb91484670ba76c8c453dc49502e89195561b31e)) + +* Make add_listener and remove_listener threadsafe (#794) ([`2bfbcbe`](https://github.com/python-zeroconf/python-zeroconf/commit/2bfbcbe9e05b9df98bba66a73deb0041c0e7c13b)) + +* Fix test_tc_bit_defers_last_response_missing failures due to thread safety (#795) ([`6aac0eb`](https://github.com/python-zeroconf/python-zeroconf/commit/6aac0eb0c1e394ec7ee21ddd6e98e446417d0e07)) + +* Ensure outgoing ServiceBrowser questions are seen by the question history (#790) ([`ecad4e8`](https://github.com/python-zeroconf/python-zeroconf/commit/ecad4e84c44ffd21dbf15e969c08f7b3376b131c)) + +* Update changelog (#788) ([`5d23628`](https://github.com/python-zeroconf/python-zeroconf/commit/5d2362825110e9f7a9c9259218a664e2e927e821)) + +* Add async_apple_scanner example (#719) ([`62dc9c9`](https://github.com/python-zeroconf/python-zeroconf/commit/62dc9c91c277bc4755f81597adca030a43d0ce5f)) + +* Add support for requesting QU questions to ServiceBrowser and ServiceInfo (#787) ([`135983c`](https://github.com/python-zeroconf/python-zeroconf/commit/135983cb96a27e3ad3750234286d1d9bfa6ff44f)) + +* Update changelog (#786) ([`3b3ecf0`](https://github.com/python-zeroconf/python-zeroconf/commit/3b3ecf09d2f30ee39c6c29b4d85e000577b2c4b9)) + +* Ensure the queue is created before adding listeners to ServiceBrowser (#785) + +* Ensure the queue is created before adding listeners to ServiceBrowser + +- The callback from the listener could generate an event that would + fire in async context that should have gone to the queue which + could result in the consumer running a sync call in the event loop + and blocking it. + +* add comments + +* add comments + +* add comments + +* add comments + +* black ([`97f5b50`](https://github.com/python-zeroconf/python-zeroconf/commit/97f5b502815075f2ff29bee3ace7cde6ad725dfb)) + +* Add a guard to prevent running ServiceInfo.request in async context (#784) + +* Add a guard to prevent running ServiceInfo.request in async context + +* test ([`dd85ae7`](https://github.com/python-zeroconf/python-zeroconf/commit/dd85ae7defd3f195ed0511a2fdb6512326ca0562)) + +* Inline utf8 decoding when processing incoming packets (#782) ([`3be1bc8`](https://github.com/python-zeroconf/python-zeroconf/commit/3be1bc84bff5ee2840040ddff41185b257a1055c)) + +* Drop utf cache from _dns (#781) + +- The cache did not make enough difference to justify the additional + complexity after additional testing was done ([`1b87343`](https://github.com/python-zeroconf/python-zeroconf/commit/1b873436e2d9ff36876a71c48fa697d277fd3ffa)) + +* Switch to using a simple cache instead of lru_cache (#779) ([`7aeafbf`](https://github.com/python-zeroconf/python-zeroconf/commit/7aeafbf3b990ab671ff691b6c20cd410f69808bf)) + +* Reformat test_handlers (#780) ([`767ae8f`](https://github.com/python-zeroconf/python-zeroconf/commit/767ae8f6cd92493f8f43d66edc70c8fd856ed11e)) + +* Fix Responding to Address Queries (RFC6762 section 6.2) (#777) ([`ac9f72a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac9f72a986ae314af0043cae6fb6219baabea7e6)) + +* Implement duplicate question supression (#770) + +https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 ([`c0f4f48`](https://github.com/python-zeroconf/python-zeroconf/commit/c0f4f48e2bb996ce18cb569aa5369356cbc919ff)) + +* Fix deadlock on ServiceBrowser shutdown with PyPy (#774) ([`b5d54e4`](https://github.com/python-zeroconf/python-zeroconf/commit/b5d54e485d9dbcde1b7b472760a0b307198b8ec8)) + +* Add a guard against the task list changing when shutting down (#776) ([`e8836b1`](https://github.com/python-zeroconf/python-zeroconf/commit/e8836b134c47080edaf47532d7cb844b307dfb08)) + +* Verify async callers can still use Zeroconf without migrating to AsyncZeroconf (#775) ([`f23df4f`](https://github.com/python-zeroconf/python-zeroconf/commit/f23df4f5f05e3911cbf96234b198ea88691aadad)) + +* Implement accidental synchronization protection (RFC2762 section 5.2) (#773) ([`b600547`](https://github.com/python-zeroconf/python-zeroconf/commit/b600547a47878775e1c6fb8df46682a670beccba)) + +* Improve performance of parsing DNSIncoming by caching read_utf (#769) ([`5d44a36`](https://github.com/python-zeroconf/python-zeroconf/commit/5d44a36a59c21ef7869ba9e6dde9f658d3502793)) + +* Add test coverage to ensure RecordManager.add_listener callsback known question answers (#767) ([`e70431e`](https://github.com/python-zeroconf/python-zeroconf/commit/e70431e1fdc92c155309a1d40c89fed48737970c)) + +* Switch to using an asyncio.Event for async_wait (#759) + +- We no longer need to check for thread safety under a asyncio.Condition + as the ServiceBrowser and ServiceInfo internals schedule coroutines + in the eventloop. ([`6c82fa9`](https://github.com/python-zeroconf/python-zeroconf/commit/6c82fa9efd0f434f0f7c83e3bd98bd7851ede4cf)) + +* Break test_lots_of_names into two tests (#764) ([`85532e1`](https://github.com/python-zeroconf/python-zeroconf/commit/85532e13e42447fcd6d4d4b0060f04d33c3ab780)) + +* Fix test_lots_of_names overflowing the incoming buffer (#763) ([`38b59a6`](https://github.com/python-zeroconf/python-zeroconf/commit/38b59a64592f41b2bb547b35c72a010a925a2941)) + +* Fix race condition in ServiceBrowser test_integration (#762) + +- The event was being cleared in the wrong thread which + meant if the test was fast enough it would not be seen + the second time and give a spurious failure ([`fc0e599`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0e599eec77477dd8f21ecd68b238e6a27f1bcf)) + +* Add 60s timeout for each test (#761) ([`936500a`](https://github.com/python-zeroconf/python-zeroconf/commit/936500a47cc33d9daa86f9012b1791986361ff63)) + +* Add missing coverage for SignalRegistrationInterface (#758) ([`9f68fc8`](https://github.com/python-zeroconf/python-zeroconf/commit/9f68fc8b1b834d0194e8ba1069d052aa853a8d38)) + +* Update changelog (#757) ([`1c93baa`](https://github.com/python-zeroconf/python-zeroconf/commit/1c93baa486b1b0f44487891766e0a0c1de3eb252)) + +* Simplify ServiceBrowser callsbacks (#756) ([`f24ebba`](https://github.com/python-zeroconf/python-zeroconf/commit/f24ebba9ecc4d1626d570956a7cc735206d7ff6e)) + +* Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) + +- This guarding is no longer needed as the ServiceBrowser loop + now runs in the event loop and the thread safety guard is no + longer needed ([`f53c88b`](https://github.com/python-zeroconf/python-zeroconf/commit/f53c88b52ed080c80e2e98d3da91a830f0c7ebca)) + +* Drop AsyncServiceListener (#754) ([`04cd268`](https://github.com/python-zeroconf/python-zeroconf/commit/04cd2688022ebd07c1f875fefc73f8d15c4ed56c)) + +* Run ServiceBrowser queries in the event loop (#752) ([`4d0a8f3`](https://github.com/python-zeroconf/python-zeroconf/commit/4d0a8f3c643a0fc5c3a40420bab96ef18dddaecb)) + +* Remove unused argument from AsyncZeroconf (#751) ([`e7adce2`](https://github.com/python-zeroconf/python-zeroconf/commit/e7adce2bf6ea0b4af1709369a36421acd9757b4a)) + +* Fix warning about Zeroconf._async_notify_all not being awaited in sync shutdown (#750) ([`3b9baf0`](https://github.com/python-zeroconf/python-zeroconf/commit/3b9baf07278290b2b4eb8ac5850bccfbd8b107d8)) + +* Update async_service_info_request example to ensure it runs in the right event loop (#749) ([`0f702c6`](https://github.com/python-zeroconf/python-zeroconf/commit/0f702c6a41bb33ed63872249b82d1111bdac4fa6)) + +* Run ServiceInfo requests in the event loop (#748) ([`0dbcabf`](https://github.com/python-zeroconf/python-zeroconf/commit/0dbcabfade41057a055ebefffd410d1afc3eb0ea)) + +* Remove support for notify listeners (#733) ([`7b3b4b5`](https://github.com/python-zeroconf/python-zeroconf/commit/7b3b4b5b8303a684165fcd53c0d9c36a1b8dda3d)) + +* Update changelog (#747) ([`0909c80`](https://github.com/python-zeroconf/python-zeroconf/commit/0909c80c67287ba92ed334ab6896136aec0f3f24)) + +* Relocate service info tests to tests/services/test_info.py (#746) ([`541292e`](https://github.com/python-zeroconf/python-zeroconf/commit/541292e55fee8bbafe687afcb8d152f6fe0efb5f)) + +* Relocate service browser tests to tests/services/test_browser.py (#745) ([`869c95a`](https://github.com/python-zeroconf/python-zeroconf/commit/869c95a51e228131eb7debe1acc47c105b9bf7b5)) + +* Relocate ServiceBrowser to zeroconf._services.browser (#744) ([`368163d`](https://github.com/python-zeroconf/python-zeroconf/commit/368163d3c30325d60021203430711e10fd6d97e9)) + +* Relocate ServiceInfo to zeroconf._services.info (#741) ([`f0d727b`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d727bd9addd6dab373b75008f04a6f8547928b)) + +* Run question answer callbacks from add_listener in the event loop (#740) ([`c8e15dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c8e15dd2bb5f6d2eb3a8ef5f26ad044517b70c47)) + +* Fix flakey cache bit flush test (#739) ([`e227d6e`](https://github.com/python-zeroconf/python-zeroconf/commit/e227d6e4c337ef9d5aa626c41587a8046313e416)) + +* Remove second level caching from ServiceBrowsers (#737) ([`5feda7e`](https://github.com/python-zeroconf/python-zeroconf/commit/5feda7e318f7d164d2b04b2d243a804372517da6)) + +* Breakout ServiceBrowser handler from listener creation (#736) ([`35ac7a3`](https://github.com/python-zeroconf/python-zeroconf/commit/35ac7a39d1fab00898ed6075e7e930424716b627)) + +* Add fast cache lookup functions (#732) ([`9d31245`](https://github.com/python-zeroconf/python-zeroconf/commit/9d31245f9ed4f6b1f7d9d7c51daf0ca394fd208f)) + +* Switch to using DNSRRSet in RecordManager (#735) ([`c035925`](https://github.com/python-zeroconf/python-zeroconf/commit/c035925f47732a889c76a2ff0989b92c6687c950)) + +* Add test coverage to ensure the cache flush bit is properly handled (#734) ([`50af944`](https://github.com/python-zeroconf/python-zeroconf/commit/50af94493ff6bf5d21445eaa80d3a96f348b0d11)) + +* Fix server cache to be case-insensitive (#731) ([`3ee9b65`](https://github.com/python-zeroconf/python-zeroconf/commit/3ee9b650bedbe61d59838897f653ad43a6d51910)) + +* Update changelog (#730) ([`733f79d`](https://github.com/python-zeroconf/python-zeroconf/commit/733f79d28c7dd4500a1598b279ee638ead8bdd55)) + +* Prefix cache functions that are non threadsafe with async_ (#724) ([`3503e76`](https://github.com/python-zeroconf/python-zeroconf/commit/3503e7614fc31bbfe2c919f13689468cc73179fd)) + +* Fix cache handling of records with different TTLs (#729) + +- There should only be one unique record in the cache at + a time as having multiple unique records will different + TTLs in the cache can result in unexpected behavior since + some functions returned all matching records and some + fetched from the right side of the list to return the + newest record. Intead we now store the records in a dict + to ensure that the newest record always replaces the same + unique record and we never have a source of truth problem + determining the TTL of a record from the cache. ([`88aa610`](https://github.com/python-zeroconf/python-zeroconf/commit/88aa610274bf79aef6c74998f2bfca8c8de0dccb)) + +* Add tests for the DNSCache class (#728) + +- There is currently a bug in the implementation where an entry + can exist in two places in the cache with different TTLs. Since + a known answer cannot be both expired and expired at the same + time, this is a bug that needs to be fixed. ([`ceb79bd`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb79bd7f7bdad434cbe5b4846492cd434ea883b)) + +* Update changelog (#727) ([`9cc834d`](https://github.com/python-zeroconf/python-zeroconf/commit/9cc834d501fa5e582adeb4468b02775288e1fa11)) + +* Rename handlers and internals to make it clear what is threadsafe (#726) + +- It was too easy to get confused about what was threadsafe and + what was not threadsafe which lead to unexpected failures. + Rename functions to make it clear what will be run in the event + loop and what is expected to be threadsafe ([`f91af79`](https://github.com/python-zeroconf/python-zeroconf/commit/f91af79c8779ac235598f5584f439c78b3bdcca2)) + +* Fix ServiceInfo with multiple A records (#725) ([`3338594`](https://github.com/python-zeroconf/python-zeroconf/commit/33385948da9123bc9348374edce7502abd898e82)) + +* Relocate cache tests to tests/test_cache.py (#722) ([`e2d4d98`](https://github.com/python-zeroconf/python-zeroconf/commit/e2d4d98db70b376c53883367b3a24c1d2510c2b5)) + +* Synchronize time for fate sharing (#718) ([`18ddb8d`](https://github.com/python-zeroconf/python-zeroconf/commit/18ddb8dbeef3edad3bb97131803dfecde4355467)) + +* Update changelog (#717) ([`1ab6859`](https://github.com/python-zeroconf/python-zeroconf/commit/1ab685960bc0e412d36baf6794fde06350998474)) + +* Cleanup typing in zero._core and document ignores (#714) ([`8183640`](https://github.com/python-zeroconf/python-zeroconf/commit/818364008e911757fca24e41a4eb36e0eef49bfa)) + +* Update README (#716) ([`0f2f4e2`](https://github.com/python-zeroconf/python-zeroconf/commit/0f2f4e207cb5007112ba09e87a332b1a46cd1577)) + +* Cleanup typing in zeroconf._logger (#715) ([`3fcdcfd`](https://github.com/python-zeroconf/python-zeroconf/commit/3fcdcfd9a3efc56a34f0334ffb8706613e07d19d)) + +* Cleanup typing in zeroconf._utils.net (#713) ([`a50b3ee`](https://github.com/python-zeroconf/python-zeroconf/commit/a50b3eeda5f275c31b36cdc1c8312f61599e72bf)) + +* Cleanup typing in zeroconf._services (#711) ([`a42512c`](https://github.com/python-zeroconf/python-zeroconf/commit/a42512ca6a6a4c15f37ab623a96deb2aa06dd053)) + +* Cleanup typing in zeroconf._services.registry (#712) ([`6b923de`](https://github.com/python-zeroconf/python-zeroconf/commit/6b923deb3682088d0fe9182377b5603d0ade1e1a)) + +* Add setter for DNSQuestion to easily make a QU question (#710) + +Closes #703 ([`aeb1b23`](https://github.com/python-zeroconf/python-zeroconf/commit/aeb1b23defa2d5956a6f19acca4ce410d6a04cc9)) + +* Synchronize created time for incoming and outgoing queries (#709) ([`c366c8c`](https://github.com/python-zeroconf/python-zeroconf/commit/c366c8cc45f565c4066fc72b481c6a960bac1cb9)) + +* Set stale unique records to expire 1s in the future instead of instant removal (#706) + +- Fixes #475 + +- https://tools.ietf.org/html/rfc6762#section-10.2 + Queriers receiving a Multicast DNS response with a TTL of zero SHOULD + NOT immediately delete the record from the cache, but instead record + a TTL of 1 and then delete the record one second later. In the case + of multiple Multicast DNS responders on the network described in + Section 6.6 above, if one of the responders shuts down and + incorrectly sends goodbye packets for its records, it gives the other + cooperating responders one second to send out their own response to + "rescue" the records before they expire and are deleted. ([`f3eeecd`](https://github.com/python-zeroconf/python-zeroconf/commit/f3eeecd84413b510b9b8e05e2d1f6ad99d0dc37d)) + +* Fix thread safety in _ServiceBrowser.update_records_complete (#708) ([`dc0c613`](https://github.com/python-zeroconf/python-zeroconf/commit/dc0c6137742edf97626c972e5c9191dfbffaecdc)) + +* Split DNSOutgoing/DNSIncoming/DNSMessage into zeroconf._protocol (#705) ([`f39bde0`](https://github.com/python-zeroconf/python-zeroconf/commit/f39bde0f6cba7a3c1b8fe8bc1a4ab4388801e486)) + +* Update changelog (#699) ([`c368e1c`](https://github.com/python-zeroconf/python-zeroconf/commit/c368e1c67c82598e920ca52b1f7a47ed6e1cf738)) + +* Efficiently bucket queries with known answers (#698) ([`7e30848`](https://github.com/python-zeroconf/python-zeroconf/commit/7e308480238fdf2cfe08474d679121e77f746fa6)) + +* Abstract DNSOutgoing ttl write into _write_ttl (#695) ([`26fa2fb`](https://github.com/python-zeroconf/python-zeroconf/commit/26fa2fb479fff87ca5af17c2c09a557c4b6176b5)) + +* Use unique names in service types tests (#697) ([`767546b`](https://github.com/python-zeroconf/python-zeroconf/commit/767546b656d7db6df0cbf2b257953498f1bc3996)) + +* Rollback data in one call instead of poping one byte at a time in DNSOutgoing (#696) ([`5cbaa3f`](https://github.com/python-zeroconf/python-zeroconf/commit/5cbaa3fc02f635e6c735e1ee5f1ca19b84c0a069)) + +* Fix off by 1 in test_tc_bit_defers_last_response_missing (#694) ([`32b7dc4`](https://github.com/python-zeroconf/python-zeroconf/commit/32b7dc40e2c3621fcacb2f389d51408ab35ac832)) + +* Suppress additionals when answer is suppressed (#690) ([`0cdba98`](https://github.com/python-zeroconf/python-zeroconf/commit/0cdba98e65dd3dce2db8aa607e97e3b67b97721a)) + +* Move setting DNS created and ttl into its own function (#692) ([`993a82e`](https://github.com/python-zeroconf/python-zeroconf/commit/993a82e414db8aadaee0e0475e178e75df417a71)) + +* Remove AA flags from handlers test (#693) + +- The flag was added by mistake when copying from other tests ([`b60f307`](https://github.com/python-zeroconf/python-zeroconf/commit/b60f307d59e342983d1baa6040c3d997f84538ab)) + +* Implement multi-packet known answer supression (#687) + +- Implements https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + +- Fixes https://github.com/jstasiak/python-zeroconf/issues/499 ([`8a25a44`](https://github.com/python-zeroconf/python-zeroconf/commit/8a25a44ec5e4f21c6bdb282fefb8f6c2d296a70b)) + +* Remove sleeps from services types test (#688) + +- Instead of registering the services and doing the broadcast + we now put them in the registry directly. ([`4865d2b`](https://github.com/python-zeroconf/python-zeroconf/commit/4865d2ba782d0313c0f7d878f5887453086febaa)) + +* Add truncated property to DNSMessage to lookup the TC bit (#686) ([`e816053`](https://github.com/python-zeroconf/python-zeroconf/commit/e816053af4d900f57100c07c48f384165ba28b9a)) + +* Update changelog (#684) ([`6fd1bf2`](https://github.com/python-zeroconf/python-zeroconf/commit/6fd1bf2364da4fc2949a905d2e4acb7da003e84d)) + +* Add coverage to verify ServiceInfo tolerates bytes or string in the txt record (#683) ([`95ddb36`](https://github.com/python-zeroconf/python-zeroconf/commit/95ddb36de64ddf3be9e93f07a1daa8389410f73d)) + +* Fix logic reversal in apple_p2p test (#681) ([`00b972c`](https://github.com/python-zeroconf/python-zeroconf/commit/00b972c062fd0ed3f2fcc4ceaec84c43b9a613be)) + +* Check if SO_REUSEPORT exists instead of using an exception catch (#682) ([`d2b5e51`](https://github.com/python-zeroconf/python-zeroconf/commit/d2b5e51d0dcde801e171a4c1e43ef1f86abde825)) + +* Use DNSRRSet for known answer suppression (#680) + +- DNSRRSet uses hash table lookups under the hood which + is much faster than the linear searches used by + DNSRecord.suppressed_by ([`e5ea9bb`](https://github.com/python-zeroconf/python-zeroconf/commit/e5ea9bb6c0a3bce7d05241f275a205ddd9e6b615)) + +* Add DNSRRSet class for quick hashtable lookups of records (#678) + +- This class will be used to do fast checks to see + if records should be suppressed by a set of answers. ([`691c29e`](https://github.com/python-zeroconf/python-zeroconf/commit/691c29eeb049e17a12d6f0a6e3bce2c3f8c2aa02)) + +* Allow unregistering a service multiple times (#679) ([`d3d439a`](https://github.com/python-zeroconf/python-zeroconf/commit/d3d439ad5d475cff094a4ea83f19d17939527021)) + +* Remove unreachable BadTypeInNameException check in _ServiceBrowser (#677) ([`57c94bb`](https://github.com/python-zeroconf/python-zeroconf/commit/57c94bb25e056e1827f15c234d7e0bcb5702a0e3)) + +* Make calculation of times in DNSRecord lazy (#676) + +- Most of the time we only check one of the time attrs + or none at all. Wait to calculate them until they are + requested. ([`ba2a4f9`](https://github.com/python-zeroconf/python-zeroconf/commit/ba2a4f960d0f9478198968a1466a8b48c963b772)) + +* Add oversized packet to the invalid packet test (#671) ([`8535110`](https://github.com/python-zeroconf/python-zeroconf/commit/8535110dd661ce406904930994a9f86faf897597)) + +* Add test for sending unicast responses (#670) ([`d274cd3`](https://github.com/python-zeroconf/python-zeroconf/commit/d274cd3a3409997b764c49d3eae7e8ee2fba33b6)) + +* Add missing coverage for ServiceInfo address changes (#669) ([`d59fb8b`](https://github.com/python-zeroconf/python-zeroconf/commit/d59fb8be29d8602ad66d89f595b26671a528fd77)) + +* Add missing coverage for ServiceListener (#668) ([`75347b4`](https://github.com/python-zeroconf/python-zeroconf/commit/75347b4e30429e130716b666da52953700f0f8e9)) + +* Update async_browser.py example to use AsyncZeroconfServiceTypes (#665) ([`481cc42`](https://github.com/python-zeroconf/python-zeroconf/commit/481cc42d000f5b0258f1be3b6df7cb7b24428b7f)) + +* Permit the ServiceBrowser to browse overlong types (#666) + +- At least one type "tivo-videostream" exists in the wild + so we are permissive about what we will look for, and + strict about what we will announce. + +Fixes #661 ([`e76c7a5`](https://github.com/python-zeroconf/python-zeroconf/commit/e76c7a5b76485efce0929ee8417aa2e0f262c04c)) + +* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.aio (#658) ([`aaf8a36`](https://github.com/python-zeroconf/python-zeroconf/commit/aaf8a368063f080be4a9c01fe671243e63bdf576)) + +* Fix flakey ZeroconfServiceTypes types test (#662) ([`72db0c1`](https://github.com/python-zeroconf/python-zeroconf/commit/72db0c10246e948c15d9a53f60a54b835ccc67bc)) + +* Add test for launching with apple_p2p=True (#660) + +- Switch to using `sys.platform` to detect Mac instead of + `platform.system()` since `platform.system()` is not intended + to be machine parsable and is only for humans. + +Closes #650 ([`0e52be0`](https://github.com/python-zeroconf/python-zeroconf/commit/0e52be059065e23ebe9e11c465adc20655b6080e)) + +* Add test for Zeroconf.get_service_info failure case (#657) ([`5752ace`](https://github.com/python-zeroconf/python-zeroconf/commit/5752ace7727bffa34cdac0455125a941014ab123)) + +* Add coverage for registering a service with a custom ttl (#656) ([`87fe529`](https://github.com/python-zeroconf/python-zeroconf/commit/87fe529a33b920532b2af688bb66182ae832a3ad)) + +* Improve aio utils tests to validate high lock contention (#655) ([`efd6bfb`](https://github.com/python-zeroconf/python-zeroconf/commit/efd6bfbe81f448da2ee68b91d49cbe1982271da3)) + +* Add test coverage for normalize_interface_choice exception paths (#654) ([`3c61d03`](https://github.com/python-zeroconf/python-zeroconf/commit/3c61d03f5954c3e45229d6c1399a63c0f7331d55)) + +* Remove all calls to the executor in AsyncZeroconf (#653) ([`7d8994b`](https://github.com/python-zeroconf/python-zeroconf/commit/7d8994bc3cb4d5978bb1ff189bb5a4b7c81b5c4c)) + +* Set __all__ in zeroconf.aio to ensure private functions do now show in the docs (#652) ([`b940f87`](https://github.com/python-zeroconf/python-zeroconf/commit/b940f878fe1f8e6b8dfe2554b781cd6034dee722)) + +* Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) ([`df9f8d9`](https://github.com/python-zeroconf/python-zeroconf/commit/df9f8d9a0110cc9135b7c2f0b4cd47e985da9a7e)) + +* Add async_unregister_all_services to AsyncZeroconf (#649) ([`72e709b`](https://github.com/python-zeroconf/python-zeroconf/commit/72e709b40caed016ba981be3752c439bbbf40ec7)) + +* Use cache clear helper in aio tests (#648) ([`79e39c0`](https://github.com/python-zeroconf/python-zeroconf/commit/79e39c0e923a1f6d87353761809f34f0fe1f0800)) + +* Ensure services are removed from the registry when calling unregister_all_services (#644) + +- There was a race condition where a query could be answered for a service + in the registry while goodbye packets which could result a fresh record + being broadcast after the goodbye if a query came in at just the right + time. To avoid this, we now remove the services from the registry right + after we generate the goodbye packet ([`cf0b5b9`](https://github.com/python-zeroconf/python-zeroconf/commit/cf0b5b9e2cfa4779425401b3d205f5d913621864)) + +* Use ServiceInfo.key/ServiceInfo.server_key instead of lowering in ServiceRegistry (#647) ([`a83d390`](https://github.com/python-zeroconf/python-zeroconf/commit/a83d390bef042da51d93014c222c65af81723a20)) + +* Add missing coverage to ServiceRegistry (#646) ([`9354ab3`](https://github.com/python-zeroconf/python-zeroconf/commit/9354ab39f350e4e6451dc4965225591761ada40d)) + +* Ensure the ServiceInfo.key gets updated when the name is changed externally (#645) ([`330e36c`](https://github.com/python-zeroconf/python-zeroconf/commit/330e36ceb4202c579fe979958c63c37033ababbb)) + +* Ensure cache is cleared before starting known answer enumeration query test (#639) ([`5ebd954`](https://github.com/python-zeroconf/python-zeroconf/commit/5ebd95452b16e76c37649486b232856a80390ac3)) + +* Ensure AsyncZeroconf.async_close can be called multiple times like Zeroconf.close (#638) ([`ce6912a`](https://github.com/python-zeroconf/python-zeroconf/commit/ce6912a75392cde41d8950b224ba3d14460993ff)) + +* Update changelog (#637) ([`09c18a4`](https://github.com/python-zeroconf/python-zeroconf/commit/09c18a4173a013e67da5a1cdc7089452ba6f67ee)) + +* Ensure eventloop shutdown is threadsafe (#636) + +- Prevent ConnectionResetError from being thrown on + Windows with ProactorEventLoop on cpython 3.8+ ([`bbbbddf`](https://github.com/python-zeroconf/python-zeroconf/commit/bbbbddf40d78dbd62a84f2439763d0a59211c5b9)) + +* Update changelog (#635) ([`c854d03`](https://github.com/python-zeroconf/python-zeroconf/commit/c854d03efd31e1d002518a43221b347fa6ca5de5)) + +* Clear cache in ZeroconfServiceTypes tests to ensure responses can be mcast before the timeout (#634) + +- We prevent the same record from being multicast within 1s + because of RFC6762 sec 14. Since these test timeout after + 0.5s, the answers they are looking for many be suppressed. + Since a legitimate querier will retry again later, we need + to clear the cache to simulate that the record has not + been multicast recently ([`a0977a1`](https://github.com/python-zeroconf/python-zeroconf/commit/a0977a1ddfd7a7a1abcf74c1d90c18021aebc910)) + +* Mark DNSOutgoing write functions as protected (#633) ([`5f66caa`](https://github.com/python-zeroconf/python-zeroconf/commit/5f66caaccf44c1504988cb82c1cba78d28dde7e7)) + +* Return early in the shutdown/close process (#632) ([`4ce33e4`](https://github.com/python-zeroconf/python-zeroconf/commit/4ce33e48e2094f17d8358cf221c7e2f9a8cb3568)) + +* Update changelog (#631) ([`64f6dd7`](https://github.com/python-zeroconf/python-zeroconf/commit/64f6dd7e244c86d58b962f48a50d07625f2a2a33)) + +* Remove unreachable cache check for DNSAddresses (#629) + +- The ServiceBrowser would check to see if a DNSAddress was + already in the cache and return early to avoid sending + updates when the address already was held in the cache. + This check was not needed since there is already a check + a few lines before as `self.zc.cache.get(record)` which + effectively does the same thing. This lead to the check + never being covered in the tests and 2 cache lookups when + only one was needed. ([`2b31612`](https://github.com/python-zeroconf/python-zeroconf/commit/2b31612e3f128b1193da9e0d2640f4e93fab2e3a)) + +* Add test for wait_condition_or_timeout_times_out util (#630) ([`2065b1d`](https://github.com/python-zeroconf/python-zeroconf/commit/2065b1d7ec7cb5d41c34826c2d8887bdd8a018b6)) + +* Return early on invalid data received (#628) + +- Improve coverage for handling invalid incoming data ([`28a614e`](https://github.com/python-zeroconf/python-zeroconf/commit/28a614e0586a0ca1c5c1651b59c9a4d9c1af9a1b)) + +* Update changelog (#627) ([`215d6ba`](https://github.com/python-zeroconf/python-zeroconf/commit/215d6badb3db796b13a000b26953cb57c557e5e5)) + +* Add test to ensure ServiceBrowser sees port change as an update (#625) ([`113874a`](https://github.com/python-zeroconf/python-zeroconf/commit/113874a7b59ac9cc887b1b626ac1486781c7d56f)) + +* Fix random test failures due to monkey patching not being undone between tests (#626) + +- Switch patching to use unitest.mock.patch to ensure the patch + is reverted when the test is completed + +Fixes #505 ([`5750f7c`](https://github.com/python-zeroconf/python-zeroconf/commit/5750f7ceef0441fe1cedc0d96e7ef5ccc232d875)) + +* Ensure zeroconf can be loaded when the system disables IPv6 (#624) ([`42d53c7`](https://github.com/python-zeroconf/python-zeroconf/commit/42d53c7c04a7bbf4e60e691e2e58fe7acfec8ad9)) + +* Update changelog (#623) ([`4d05961`](https://github.com/python-zeroconf/python-zeroconf/commit/4d05961088efa8b503cad5658afade874eaeec76)) + +* Eliminate aio sender thread (#622) ([`f15e84f`](https://github.com/python-zeroconf/python-zeroconf/commit/f15e84f3ee7a644792fe98edde84dd216b3497cb)) + +* Replace select loop with asyncio loop (#504) ([`8f00cfc`](https://github.com/python-zeroconf/python-zeroconf/commit/8f00cfca0e67dde6afda399da6984ed7d8f929df)) + +* Add support for handling QU questions (#621) + +- Implements RFC 6762 sec 5.4: + Questions Requesting Unicast Responses + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 ([`9a32db8`](https://github.com/python-zeroconf/python-zeroconf/commit/9a32db8582588e4bf812fd5670a7e61c50631a2e)) + +* Add is_recent property to DNSRecord (#620) + +- RFC 6762 defines recent as not multicast within one quarter of its TTL + https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 ([`1f36754`](https://github.com/python-zeroconf/python-zeroconf/commit/1f36754f3964738e496a1da9c24380e204aaff01)) + +* Protect the network against excessive packet flooding (#619) ([`0e644ad`](https://github.com/python-zeroconf/python-zeroconf/commit/0e644ad650627024c7a3f926a86f7d9ecc66e591)) + +* Ensure matching PTR queries are returned with the ANY query (#618) + +Fixes #464 ([`b6365aa`](https://github.com/python-zeroconf/python-zeroconf/commit/b6365aa1f889a3045aa185f67354de622bd7ebd3)) + +* Suppress additionals when they are already in the answers section (#617) ([`427b728`](https://github.com/python-zeroconf/python-zeroconf/commit/427b7285269984cbb6f28c87a8bf8f864a5e15d7)) + +* Fix queries for AAAA records (#616) ([`0100c08`](https://github.com/python-zeroconf/python-zeroconf/commit/0100c08c5a3fb90d0795cf57f0bd3e11c7a94a0b)) + +* Breakout the query response handler into its own class (#615) ([`c828c75`](https://github.com/python-zeroconf/python-zeroconf/commit/c828c7555ed1fb82ff95ed578262d1553f19d903)) + +* Avoid including additionals when the answer is suppressed by known-answer supression (#614) ([`219aa3e`](https://github.com/python-zeroconf/python-zeroconf/commit/219aa3e54c944b2935c9a40cc15de19284aded3c)) + +* Add the ability for ServiceInfo.dns_addresses to filter by address type (#612) ([`aea2c8a`](https://github.com/python-zeroconf/python-zeroconf/commit/aea2c8ab24d4be19b34f407c854241e0d73d0525)) + +* Make DNSRecords hashable (#611) + +- Allows storing them in a set for de-duplication + +- Needed to be able to check for duplicates to solve https://github.com/jstasiak/python-zeroconf/issues/604 ([`b7d8678`](https://github.com/python-zeroconf/python-zeroconf/commit/b7d867878153fa600053869265260992e5462b2d)) + +* Ensure the QU bit is set for probe queries (#609) + +- The bit should be set per + https://datatracker.ietf.org/doc/html/rfc6762#section-8.1 ([`22bd147`](https://github.com/python-zeroconf/python-zeroconf/commit/22bd1475fb58c7c421c0009cd0c5c791cedb225d)) + +* Log destination when sending packets (#606) ([`850e211`](https://github.com/python-zeroconf/python-zeroconf/commit/850e2115aa79c10765dfc45a290a68193397de6c)) + +* Fix docs version to match readme (cpython 3.6+) (#602) ([`809b6df`](https://github.com/python-zeroconf/python-zeroconf/commit/809b6df376205e6ab5ce8fb5fe3a92e77662fe2d)) + +* Add ZeroconfServiceTypes to zeroconf.__all__ (#601) + +- This class is in the readme, but is not exported by + default ([`f6cd8f6`](https://github.com/python-zeroconf/python-zeroconf/commit/f6cd8f6d23459f9ed48ad06ff6702e606d620eaf)) + +* Ensure unicast responses can be sent to any source port (#598) + +- Unicast responses were only being sent if the source port + was 53, this prevented responses when testing with dig: + + dig -p 5353 @224.0.0.251 media-12.local + + The above query will now see a response ([`3556c22`](https://github.com/python-zeroconf/python-zeroconf/commit/3556c22aacc72e62c318955c084533b70311bcc9)) + +* Add id_ param to allow setting the id in the DNSOutgoing constructor (#599) ([`cb64e0d`](https://github.com/python-zeroconf/python-zeroconf/commit/cb64e0dd5d1c621f61d0d0f92ea282d287a9c242)) + +* Fix lookup of uppercase names in registry (#597) + +- If the ServiceInfo was registered with an uppercase name and the query was + for a lowercase name, it would not be found and vice-versa. ([`fe72524`](https://github.com/python-zeroconf/python-zeroconf/commit/fe72524dbaf934ca63ebce053e34f3e838743460)) + +* Add unicast property to DNSQuestion to determine if the QU bit is set (#593) ([`d2d8262`](https://github.com/python-zeroconf/python-zeroconf/commit/d2d826220bd4f287835ebb4304450cc2311d1db6)) + +* Reduce branching in DNSOutgoing.add_answer_at_time (#592) ([`35e25fd`](https://github.com/python-zeroconf/python-zeroconf/commit/35e25fd46f8d3689b723dd845eba9862a5dc8a22)) + +* Move notify listener tests to test_core (#591) ([`72032d6`](https://github.com/python-zeroconf/python-zeroconf/commit/72032d6dde2ee7388b8cb4545554519d3ffa8508)) + +* Set mypy follow_imports to skip as ignore is not a valid option (#590) ([`fd70ac1`](https://github.com/python-zeroconf/python-zeroconf/commit/fd70ac1b6bdded992f8fbbb723ca92f5395abf23)) + +* Relocate handlers tests to tests/test_handlers (#588) ([`8aa14d3`](https://github.com/python-zeroconf/python-zeroconf/commit/8aa14d33849c057c91a00e1093606081ade488e7)) + +* Relocate ServiceRegistry tests to tests/services/test_registry (#587) ([`ae6530a`](https://github.com/python-zeroconf/python-zeroconf/commit/ae6530a59e2d8ddb9a7367243c29c5e00665a82f)) + +* Disable flakey ServiceTypesQuery ipv6 win32 test (#586) ([`5cb5702`](https://github.com/python-zeroconf/python-zeroconf/commit/5cb5702fca2845e99b457e4427428497c3cd9b31)) + +* Relocate network utils tests to tests/utils/test_net (#585) ([`12f5676`](https://github.com/python-zeroconf/python-zeroconf/commit/12f567695b5364c9c5c5af0a7017d877de84274d)) + +* Relocate ServiceTypesQuery tests to tests/services/test_types (#584) ([`1fe282b`](https://github.com/python-zeroconf/python-zeroconf/commit/1fe282ba246505d172356cc8672307c7d125820d)) + +* Mark zeroconf.services as protected by renaming to zeroconf._services (#583) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`4a88066`](https://github.com/python-zeroconf/python-zeroconf/commit/4a88066d66b2f2a00ebc388c5cda478c52cb9e6c)) + +* Mark zeroconf.utils as protected by renaming to zeroconf._utils (#582) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`cc5bc36`](https://github.com/python-zeroconf/python-zeroconf/commit/cc5bc36f6f7597a0adb0d637147c2f93ca243ff4)) + +* Mark zeroconf.cache as protected by renaming to zeroconf._cache (#581) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`a16e85b`](https://github.com/python-zeroconf/python-zeroconf/commit/a16e85b20c2069aa9cee0510c618cb61d46dc19c)) + +* Mark zeroconf.exceptions as protected by renaming to zeroconf._exceptions (#580) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`241700a`](https://github.com/python-zeroconf/python-zeroconf/commit/241700a07a76a8c45afbe1bdd8325cd9f0eb0168)) + +* Fix flakey backoff test race on startup (#579) ([`dd9ada7`](https://github.com/python-zeroconf/python-zeroconf/commit/dd9ada781fdb1d5efc7c6ad194426e92550245b1)) + +* Mark zeroconf.logger as protected by renaming to zeroconf._logger (#578) ([`500066f`](https://github.com/python-zeroconf/python-zeroconf/commit/500066f940aa89737f343976ee0387eae97eac37)) + +* Mark zeroconf.handlers as protected by renaming to zeroconf._handlers (#577) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`1a2ee68`](https://github.com/python-zeroconf/python-zeroconf/commit/1a2ee6892e996c1e84ba97082e5cda609d1d55d7)) + +* Log zeroconf.asyncio deprecation warning with the logger module (#576) ([`c29a235`](https://github.com/python-zeroconf/python-zeroconf/commit/c29a235eb59ed3b4883305cf11f8bf9fa06284d3)) + +* Mark zeroconf.core as protected by renaming to zeroconf._core (#575) ([`601e8f7`](https://github.com/python-zeroconf/python-zeroconf/commit/601e8f70499638a6f24291bc0a28054fd78243c0)) + +* Mark zeroconf.dns as protected by renaming to zeroconf._dns (#574) + +- The public API should only access zeroconf and zeroconf.aio + as internals may be relocated between releases ([`0e61b15`](https://github.com/python-zeroconf/python-zeroconf/commit/0e61b1502c7fd3412f979bc4d651ee016e712de9)) + +* Update changelog (#573) ([`f10a562`](https://github.com/python-zeroconf/python-zeroconf/commit/f10a562471ad89527e6eef6ba935a27177bb1417)) + +* Relocate services tests to test_services (#570) ([`ae552e9`](https://github.com/python-zeroconf/python-zeroconf/commit/ae552e94732568fd798e1f2d0e811849edff7790)) + +* Remove DNSOutgoing.packet backwards compatibility (#569) + +- DNSOutgoing.packet only returned a partial message when the + DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL + This was a legacy function that was replaced with .packets() + which always returns a complete payload in #248 As packet() + should not be used since it will end up missing data, it has + been removed ([`1e7c074`](https://github.com/python-zeroconf/python-zeroconf/commit/1e7c07481bb0cd08fe492dab02be888c6a1dadf2)) + +* Breakout DNSCache into zeroconf.cache (#568) ([`0e0bc2a`](https://github.com/python-zeroconf/python-zeroconf/commit/0e0bc2a901ed1d64e357c63e9fb8655f3a6e9298)) + +* Removed protected imports from zeroconf namespace (#567) + +- These protected items are not intended to be part of the + public API ([`a8420cd`](https://github.com/python-zeroconf/python-zeroconf/commit/a8420cde192647486eba4da4e54df9d0fe65adba)) + +* Update setup.py for utils and services (#562) ([`7807fa0`](https://github.com/python-zeroconf/python-zeroconf/commit/7807fa0dfdab20d950c446f17b7233a8c65cbab1)) + +* Move additional dns tests to test_dns (#561) ([`ae1ce09`](https://github.com/python-zeroconf/python-zeroconf/commit/ae1ce092de7eb4797da0f56e9eb8e538c95a8cc1)) + +* Move exceptions tests to test_exceptions (#560) ([`b5d848d`](https://github.com/python-zeroconf/python-zeroconf/commit/b5d848de1ed95c55f8c262bcf0811248818da901)) + +* Move additional tests to test_core (#559) ([`eb37f08`](https://github.com/python-zeroconf/python-zeroconf/commit/eb37f089579fdc5a405dbc2f0ce5620cf9d1b011)) + +* Relocate additional dns tests to test_dns (#558) ([`18b9d0a`](https://github.com/python-zeroconf/python-zeroconf/commit/18b9d0a8bd07c0a0d2923763a5f131905c31e0df)) + +* Relocate dns tests to test_dns (#557) ([`f0d99e2`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d99e2e68791376a8517254338c708a3244f178)) + +* Relocate some of the services tests to test_services (#556) ([`715cd9a`](https://github.com/python-zeroconf/python-zeroconf/commit/715cd9a1d208139862e6d9d718114e1e472efd28)) + +* Fix invalid typing in ServiceInfo._set_text (#554) ([`3d69656`](https://github.com/python-zeroconf/python-zeroconf/commit/3d69656c4e5fbd8f90d54826877a04120d5ec951)) + +* Add missing coverage for ipv6 network utils (#555) ([`3dfda64`](https://github.com/python-zeroconf/python-zeroconf/commit/3dfda644efef83640e80876e4fe7da10e87b5990)) + +* Move ZeroconfServiceTypes to zeroconf.services.types (#553) ([`e50b62b`](https://github.com/python-zeroconf/python-zeroconf/commit/e50b62bb633916d5b84df7bcf7a804c9e3ef7fc2)) + +* Add recipe for TYPE_CHECKING to .coveragerc (#552) ([`e7fb4e5`](https://github.com/python-zeroconf/python-zeroconf/commit/e7fb4e5fb2a6b2163b143a63e2a9e8c5d1eca482)) + +* Move QueryHandler and RecordManager handlers into zeroconf.handlers (#551) ([`5b489e5`](https://github.com/python-zeroconf/python-zeroconf/commit/5b489e5b15ff89a0ffc000ccfeab2a8af346a65e)) + +* Move ServiceListener to zeroconf.services (#550) ([`ffdc988`](https://github.com/python-zeroconf/python-zeroconf/commit/ffdc9887ede1f867c155743b344efc53e0ceee42)) + +* Move the ServiceRegistry into its own module (#549) ([`4086fb4`](https://github.com/python-zeroconf/python-zeroconf/commit/4086fb4304b0653153865306e46c865c90137922)) + +* Move ServiceStateChange to zeroconf.services (#548) ([`c8a0a71`](https://github.com/python-zeroconf/python-zeroconf/commit/c8a0a71c31252bbc4a242701bc786eb419e1a8e8)) + +* Relocate core functions into zeroconf.core (#547) ([`bf0e867`](https://github.com/python-zeroconf/python-zeroconf/commit/bf0e867ead1e48e05a27fe8db69900d9dc387ea2)) + +* Breakout service classes into zeroconf.services (#544) ([`bdea21c`](https://github.com/python-zeroconf/python-zeroconf/commit/bdea21c0a61b6d9d0af3810f18dbc2fc2364c484)) + +* Move service_type_name to zeroconf.utils.name (#543) ([`b4814f5`](https://github.com/python-zeroconf/python-zeroconf/commit/b4814f5f216cd4072bafdd7dd1e68ee522f329c2)) + +* Relocate DNS classes to zeroconf.dns (#541) ([`1e3e7df`](https://github.com/python-zeroconf/python-zeroconf/commit/1e3e7df8b7fdacd90cf5d864411e5db5a915be94)) + +* Update zeroconf.aio import locations (#539) ([`8733cad`](https://github.com/python-zeroconf/python-zeroconf/commit/8733cad2eae71ebdf94ecadc6fd5439882477235)) + +* Move int2byte to zeroconf.utils.struct (#540) ([`6af42b5`](https://github.com/python-zeroconf/python-zeroconf/commit/6af42b54640ebba541302bfcf7688b3926453b15)) + +* Breakout network utils into zeroconf.utils.net (#537) ([`5af3eb5`](https://github.com/python-zeroconf/python-zeroconf/commit/5af3eb58bfdc1736e6db175c4c6f7c6f2c05b694)) + +* Move time utility functions into zeroconf.utils.time (#536) ([`7ff810a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ff810a02e608fae39634be09d6c3ce0a93485b8)) + +* Avoid making DNSOutgoing aware of the Zeroconf object (#535) + +- This is not a breaking change since this code has not + yet shipped ([`2976cc2`](https://github.com/python-zeroconf/python-zeroconf/commit/2976cc2001cbba2c0afc57b9a3d301f382ddac8a)) + +* Add missing coverage for QuietLogger (#534) ([`328c1b9`](https://github.com/python-zeroconf/python-zeroconf/commit/328c1b9acdcd5cafa2df3e5b4b833b908d299500)) + +* Move logger into zeroconf.logger (#533) ([`e2e4eed`](https://github.com/python-zeroconf/python-zeroconf/commit/e2e4eede9117827f47c66a4852dd2d236b46ecda)) + +* Move exceptions into zeroconf.exceptions (#532) ([`5100506`](https://github.com/python-zeroconf/python-zeroconf/commit/5100506f896b649e6a6a8e2efb592362cd2644d3)) + +* Move constants into const.py (#531) ([`89d4755`](https://github.com/python-zeroconf/python-zeroconf/commit/89d4755106a6c3bced395b0a26eb3082c1268fa1)) + +* Move asyncio utils into zeroconf.utils.aio (#530) ([`2d8a27a`](https://github.com/python-zeroconf/python-zeroconf/commit/2d8a27a54aee298af74121986b4ea76f1f50b421)) + +* Relocate tests to tests directory (#527) ([`3f1a5a7`](https://github.com/python-zeroconf/python-zeroconf/commit/3f1a5a7b7a929d5f699812a809347b0c2f799fbf)) + +* Fix flakey test_update_record test (round 2) (#528) ([`14542bd`](https://github.com/python-zeroconf/python-zeroconf/commit/14542bd2bd327fd9b3d93cfb48a3bf09d6c89e15)) + +* Move ipversion auto detection code into its own function (#524) ([`16d40b5`](https://github.com/python-zeroconf/python-zeroconf/commit/16d40b50ccab6a8d53fe4aeb7b0006f7fd67ef53)) + +* Fix flakey test_update_record (#525) + +- Ensure enough time has past that the first record update + was processed before sending the second one ([`f49342c`](https://github.com/python-zeroconf/python-zeroconf/commit/f49342cdaff2d012ad23635b49ae746ad71333df)) + +* Update python compatibility as PyPy3 7.2 is required (#523) + +- When the version requirement changed to cpython 3.6, PyPy + was not bumped as well ([`b37d115`](https://github.com/python-zeroconf/python-zeroconf/commit/b37d115a233b61e2989d1439f65cdd911b86f407)) + +* Make the cache cleanup interval a constant (#522) ([`7ce29a2`](https://github.com/python-zeroconf/python-zeroconf/commit/7ce29a2f736af13886aa66dc1c49e15768e6fdcc)) + +* Add test helper to inject DNSIncoming (#518) ([`ef7aa25`](https://github.com/python-zeroconf/python-zeroconf/commit/ef7aa250e140d70b8c62abf4d13dcaa36f128c63)) + +* Remove broad exception catch from RecordManager.remove_listener (#517) ([`e125239`](https://github.com/python-zeroconf/python-zeroconf/commit/e12523933819087d2a087b8388e79b24af058a58)) + +* Small cleanups to RecordManager.add_listener (#516) ([`f80a051`](https://github.com/python-zeroconf/python-zeroconf/commit/f80a0515cf73b1e304d0615f8cee91ae38ac1ae8)) + +* Move RecordUpdateListener management into RecordManager (#514) ([`6cc3adb`](https://github.com/python-zeroconf/python-zeroconf/commit/6cc3adb020115ef9626caf61bb5f7550a2da8b4c)) + +* Update changelog (#513) ([`3d6c682`](https://github.com/python-zeroconf/python-zeroconf/commit/3d6c68278713a2ca66e27938feedcc451a078369)) + +* Break out record updating into RecordManager (#512) ([`9a766a2`](https://github.com/python-zeroconf/python-zeroconf/commit/9a766a2a96abd0f105056839b5c30f2ede31ea2e)) + +* Remove uneeded wait in the Engine thread (#511) + +- It is not longer necessary to wait since the socketpair + was added in #243 which will cause the select to unblock + when a new socket is added or removed. ([`70b455b`](https://github.com/python-zeroconf/python-zeroconf/commit/70b455ba53ce43e9280c02612e8a89665abd57f6)) + +* Stop monkey patching send in the TTL test (#510) ([`954ca3f`](https://github.com/python-zeroconf/python-zeroconf/commit/954ca3fb498bdc7cd5a6a168c40ad5b6b2476e71)) + +* Stop monkey patching send in the PTR optimization test (#509) ([`db866f7`](https://github.com/python-zeroconf/python-zeroconf/commit/db866f7d032ed031e6aa5e14fba24b3dafeafa8d)) + +* Extract code for handling queries into QueryHandler (#507) ([`1cfcc56`](https://github.com/python-zeroconf/python-zeroconf/commit/1cfcc5636a845924eb683ad4acf4d9a36ef85fb7)) + +* Update changelog for zeroconf.asyncio -> zeroconf.aio (#506) ([`26b7005`](https://github.com/python-zeroconf/python-zeroconf/commit/26b70050ffe7dee4fb34428f285be377d1d8f210)) + +* Rename zeroconf.asyncio to zeroconf.aio (#503) + +- The asyncio name could shadow system asyncio in some cases. If + zeroconf is in sys.path, this would result in loading zeroconf.asyncio + when system asyncio was intended. + +- An `zeroconf.asyncio` shim module has been added that imports `zeroconf.aio` + that was available in 0.31 to provide backwards compatibility in 0.32. + This module will be removed in 0.33 to fix the underlying problem + detailed in #502 ([`bfca3b4`](https://github.com/python-zeroconf/python-zeroconf/commit/bfca3b46fd9a395f387bd90b68c523a3ca84bde4)) + +* Update changelog, move breaking changes to the top of the list (#501) ([`9b480bc`](https://github.com/python-zeroconf/python-zeroconf/commit/9b480bc1abb2c2702f60796f2edae76ce03ca5d4)) + +* Set the TC bit for query packets where the known answers span multiple packets (#494) ([`f04a2eb`](https://github.com/python-zeroconf/python-zeroconf/commit/f04a2eb43745eba7c43c9c56179ed1fceb992bd8)) + +* Ensure packets are properly seperated when exceeding maximum size (#498) + +- Ensure that questions that exceed the max packet size are + moved to the next packet. This fixes DNSQuestions being + sent in multiple packets in violation of: + https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 + +- Ensure only one resource record is sent when a record + exceeds _MAX_MSG_TYPICAL + https://datatracker.ietf.org/doc/html/rfc6762#section-17 ([`e2908c6`](https://github.com/python-zeroconf/python-zeroconf/commit/e2908c6c89802ba7a0ea51ac351da40bce3f1cb6)) + +* Make a base class for DNSIncoming and DNSOutgoing (#497) ([`38e4b42`](https://github.com/python-zeroconf/python-zeroconf/commit/38e4b42b847e700db52bc51973210efc485d8c23)) + +* Update internal version check to match docs (3.6+) (#491) ([`20f8b3d`](https://github.com/python-zeroconf/python-zeroconf/commit/20f8b3d6fb8d117b0c3c794c4075a00e117e3f31)) + +* Remove unused __ne__ code from Python 2 era (#492) ([`f0c02a0`](https://github.com/python-zeroconf/python-zeroconf/commit/f0c02a02c1a2d7c914c62479bad4957b06471661)) + +* Lint before testing in the CI (#488) ([`69880ae`](https://github.com/python-zeroconf/python-zeroconf/commit/69880ae6ca4d4f0a7d476b0271b89adea92b9389)) + +* Add AsyncServiceBrowser example (#487) ([`ef9334f`](https://github.com/python-zeroconf/python-zeroconf/commit/ef9334f1279d029752186bc6f4a1ebff6229bf5b)) + +* Move threading daemon property into ServiceBrowser class (#486) ([`275765a`](https://github.com/python-zeroconf/python-zeroconf/commit/275765a4fd3b477b79163c04f6411709e14506b9)) + +* Enable test_integration_with_listener_class test on PyPy (#485) ([`49db96d`](https://github.com/python-zeroconf/python-zeroconf/commit/49db96dae466a602662f4fde1537f62a8c8d3110)) + +* RecordUpdateListener now uses update_records instead of update_record (#419) ([`0a69aa0`](https://github.com/python-zeroconf/python-zeroconf/commit/0a69aa0d37e13cb2c65ceb5cc3ab0fd7e9d34b22)) + +* AsyncServiceBrowser must recheck for handlers to call when holding condition (#483) + +- There was a short race condition window where the AsyncServiceBrowser + could add to _handlers_to_call in the Engine thread, have the + condition notify_all called, but since the AsyncServiceBrowser was + not yet holding the condition it would not know to stop waiting + and process the handlers to call. ([`9606936`](https://github.com/python-zeroconf/python-zeroconf/commit/960693628006e23fd13fcaefef915ca0c84401b9)) + +* Relocate ServiceBrowser wait time calculation to seperate function (#484) + +- Eliminate the need to duplicate code between the ServiceBrowser + and AsyncServiceBrowser to calculate the wait time. ([`9c06ce1`](https://github.com/python-zeroconf/python-zeroconf/commit/9c06ce15db31ebffe3a556896393d48cb786b5d9)) + +* Switch from using an asyncio.Event to asyncio.Condition for waiting (#482) ([`393910b`](https://github.com/python-zeroconf/python-zeroconf/commit/393910b67ac667a660ee9351cc8f94310937f654)) + +* ServiceBrowser must recheck for handlers to call when holding condition (#477) ([`8da00ca`](https://github.com/python-zeroconf/python-zeroconf/commit/8da00caf31e007153e10a8038a0a484edea03c2f)) + +* Provide a helper function to convert milliseconds to seconds (#481) ([`849e9bc`](https://github.com/python-zeroconf/python-zeroconf/commit/849e9bc792c6cc77b879b4761195192bea1720ce)) + +* Fix AsyncServiceInfo.async_request not waiting long enough (#480) + +- The call to async_wait should have been in milliseconds, but + the time was being passed in seconds which resulted in waiting + 1000x shorter ([`b0c0cdc`](https://github.com/python-zeroconf/python-zeroconf/commit/b0c0cdc6779dc095cf03ebd92652af69800b7bca)) + +* Add support for updating multiple records at once to ServiceInfo (#474) + +- Adds `update_records` method to `ServiceInfo` ([`ed53f62`](https://github.com/python-zeroconf/python-zeroconf/commit/ed53f6283265eb8fb506d4af8fb31bd4eaa7292b)) + +* Narrow exception catch in DNSAddress.__repr__ to only expected exceptions (#473) ([`b853413`](https://github.com/python-zeroconf/python-zeroconf/commit/b8534130ec31a6be191fcc60615ab2fd02fd8d7a)) + +* Add test coverage to ensure ServiceInfo rejects expired records (#468) ([`d0f5a60`](https://github.com/python-zeroconf/python-zeroconf/commit/d0f5a60275ccf810407055c63ca9080fa6654443)) + +* Reduce branching in service_type_name (#472) ([`00af5ad`](https://github.com/python-zeroconf/python-zeroconf/commit/00af5adc4be76afd23135d37653119f45c57a531)) + +* Fix flakey test_update_record (#470) ([`1eaeef2`](https://github.com/python-zeroconf/python-zeroconf/commit/1eaeef2d6f07efba67e91699529f8361226233ce)) + +* Reduce branching in Zeroconf.handle_response (#467) + +- Adds `add_records` and `remove_records` to `DNSCache` to + permit multiple records to be added or removed in one call + +- This change is not enough to remove the too-many-branches + pylint disable, however when combined with #419 it should + no longer be needed ([`8a9ae29`](https://github.com/python-zeroconf/python-zeroconf/commit/8a9ae29b6f6643f3625938ac44df66dcc556de46)) + +* Ensure PTR questions asked in uppercase are answered (#465) ([`7a50402`](https://github.com/python-zeroconf/python-zeroconf/commit/7a5040247cbaad6bed3fc1204820dfc31ed9b0ae)) + +* Clear cache between ServiceTypesQuery tests (#466) + +- Ensures the test relies on the ZeroconfServiceTypes.find making + the correct calls instead of the cache from the previous call ([`c3365e1`](https://github.com/python-zeroconf/python-zeroconf/commit/c3365e1fd060cebc63cc42443260bd785077c246)) + +* Break apart Zeroconf.handle_query to reduce branching (#462) ([`c1ed987`](https://github.com/python-zeroconf/python-zeroconf/commit/c1ed987ede34b0049e6466e673b1629d7cd0cd6a)) + +* Support for context managers in Zeroconf and AsyncZeroconf (#284) + +Co-authored-by: J. Nick Koston ([`4c4b529`](https://github.com/python-zeroconf/python-zeroconf/commit/4c4b529c841f015108a7489bd8f3b92a5e57e827)) + +* Use constant for service type enumeration (#461) ([`558cec3`](https://github.com/python-zeroconf/python-zeroconf/commit/558cec3687ac7e7f494ab7aa4ce574c1e784b81f)) + +* Reduce branching in Zeroconf.handle_response (#459) ([`ceb0def`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb0def1b43f2e55bb17e33d13d4efdaa055221c)) + +* Reduce branching in Zeroconf.handle_query (#460) ([`5e24da0`](https://github.com/python-zeroconf/python-zeroconf/commit/5e24da08bc463bf79b27eb3768ec01755804f403)) + +* Enable pylint (#438) ([`6fafdee`](https://github.com/python-zeroconf/python-zeroconf/commit/6fafdee241571d68937e29ee0a2b1bd5ef0038d9)) + +* Trap OSError directly in Zeroconf.send instead of checking isinstance (#453) + +- Fixes: Instance of 'Exception' has no 'errno' member (no-member) ([`9510808`](https://github.com/python-zeroconf/python-zeroconf/commit/9510808cfd334b0b2f6381da8214225c4cfbf6a0)) + +* Disable protected-access on the ServiceBrowser usage of _handlers_lock (#452) + +- This will be fixed in https://github.com/jstasiak/python-zeroconf/pull/419 ([`69c4cf6`](https://github.com/python-zeroconf/python-zeroconf/commit/69c4cf69bbc34474e70eac3ad0fe905be7ab4eb4)) + +* Mark functions with too many branches in need of refactoring (#455) ([`5fce89d`](https://github.com/python-zeroconf/python-zeroconf/commit/5fce89db2707b163231aec216e4c4fc310527e4c)) + +* Disable pylint no-self-use check on abstract methods (#451) ([`7544cdf`](https://github.com/python-zeroconf/python-zeroconf/commit/7544cdf956c4eeb4b688729432ba87278f606b7c)) + +* Use unique name in test_async_service_browser test (#450) ([`f26a92b`](https://github.com/python-zeroconf/python-zeroconf/commit/f26a92bc2abe61f5a2b5acd76991f81d07452201)) + +* Disable no-member check for WSAEINVAL false positive (#454) ([`ef0cf8e`](https://github.com/python-zeroconf/python-zeroconf/commit/ef0cf8e393a8ffdccb3cd2094a8764f707f518c1)) + +* Mark methods used by asyncio without self use (#447) ([`7e03f83`](https://github.com/python-zeroconf/python-zeroconf/commit/7e03f836dd7a4ee938bfff21cd150e863f608b5e)) + +* Extract _get_queue from _AsyncSender (#444) ([`18851ed`](https://github.com/python-zeroconf/python-zeroconf/commit/18851ed4c0f605996798472e1a68dded16d41ff6)) + +* Add missing update_service method to ZeroconfServiceTypes (#449) ([`ffc6cbb`](https://github.com/python-zeroconf/python-zeroconf/commit/ffc6cbb94d7401a70ebd6f747ed6c5e56e528bb0)) + +* Fix redefining argument with the local name 'record' in ServiceInfo.update_record (#448) ([`929ba12`](https://github.com/python-zeroconf/python-zeroconf/commit/929ba12d046496782491d96160e6cb8d0d04cfe5)) + +* Remove unneeded-not in new_socket (#445) ([`424c002`](https://github.com/python-zeroconf/python-zeroconf/commit/424c00257083f1d091a52ff0c966b306eea70efb)) + +* Disable broad except checks in places we still catch broad exceptions (#443) ([`6002c9c`](https://github.com/python-zeroconf/python-zeroconf/commit/6002c9c88a9a49814f86070c07925f798a61461a)) + +* Merge _TYPE_CNAME and _TYPE_PTR comparison in DNSIncoming.read_others (#442) ([`41be4f4`](https://github.com/python-zeroconf/python-zeroconf/commit/41be4f4db0501adb9fbaa6b353fbcb36a45e6e21)) + +* Convert unnecessary use of a comprehension to a list (#441) ([`a70370a`](https://github.com/python-zeroconf/python-zeroconf/commit/a70370a0f653df911cc6f641522cec0fcc8471a3)) + +* Remove unused now argument from ServiceInfo._process_record (#440) ([`594da70`](https://github.com/python-zeroconf/python-zeroconf/commit/594da709273c2e0a53fee2f9ad7fcec607ad0868)) + +* Disable pylint too-many-branches for functions that need refactoring (#439) ([`4bcb698`](https://github.com/python-zeroconf/python-zeroconf/commit/4bcb698bda0ec7266d5e454b5e81a07eb64be32a)) + +* Cleanup unused variables (#437) ([`8412eb7`](https://github.com/python-zeroconf/python-zeroconf/commit/8412eb791dd5ad1c287c1d7cc24c5db75a5291b7)) + +* Cleanup unnecessary else after returns (#436) ([`1d3f986`](https://github.com/python-zeroconf/python-zeroconf/commit/1d3f986e00e18682c209cecbdea2481f4ca987b5)) + +* Update changelog for latest changes (#435) ([`6737e13`](https://github.com/python-zeroconf/python-zeroconf/commit/6737e13d8e6227b96d5cc0e776c62889b7dc4fd3)) + +* Add zeroconf.asyncio to the docs (#434) ([`5460cae`](https://github.com/python-zeroconf/python-zeroconf/commit/5460caef83b5cdb9c5d637741ed95dea6b328f08)) + +* Fix warning when generating sphinx docs (#432) + +- `docstring of zeroconf.ServiceInfo:5: WARNING: Unknown target name: "type".` ([`e5a0c9a`](https://github.com/python-zeroconf/python-zeroconf/commit/e5a0c9a45df93a668f3611ddf5c41a1800cb4556)) + +* Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) ([`415a7b7`](https://github.com/python-zeroconf/python-zeroconf/commit/415a7b762030e9d236bef71f39156686a0b277f9)) + +* Seperate non-thread specific code from ServiceBrowser into _ServiceBrowserBase (#428) ([`e7b2bb5`](https://github.com/python-zeroconf/python-zeroconf/commit/e7b2bb5e351f04f4f1e14ef5a20ed2111f8097c4)) + +* Remove is_type_unique as it is unused (#426) ([`e68e337`](https://github.com/python-zeroconf/python-zeroconf/commit/e68e337cd482e06a422b2d2e2e6ae12ce1673ce5)) + +* Avoid checking the registry when answering requests for _services._dns-sd._udp.local. (#425) + +- _services._dns-sd._udp.local. is a special case and should never + be in the registry ([`47e266e`](https://github.com/python-zeroconf/python-zeroconf/commit/47e266eb66be36b355f1738cd4d2f7369712b7b3)) + +* Remove unused argument from ServiceInfo.dns_addresses (#423) + +- This should always return all addresses since its _CLASS_UNIQUE ([`fc97e5c`](https://github.com/python-zeroconf/python-zeroconf/commit/fc97e5c3ad35da789373a1898c00efe0f13a3b5f)) + +* A methods to generate DNSRecords from ServiceInfo (#422) ([`41de419`](https://github.com/python-zeroconf/python-zeroconf/commit/41de419453c0679c5a04ec248339783afbeb0e4f)) + +* Seperate logic for consuming records in ServiceInfo (#421) ([`8bca030`](https://github.com/python-zeroconf/python-zeroconf/commit/8bca0305deae0db8ced7e213be3aaee975985c56)) + +* Seperate query generation for ServiceBrowser (#420) ([`58cfcf0`](https://github.com/python-zeroconf/python-zeroconf/commit/58cfcf0c902b5e27937f118bf4f7a855db635301)) + +* Add async_request example with browse (#415) ([`7f08826`](https://github.com/python-zeroconf/python-zeroconf/commit/7f08826c03b7997758ff0236834bf6f1a091c558)) + +* Add async_register_service/async_unregister_service example (#414) ([`71cfbcb`](https://github.com/python-zeroconf/python-zeroconf/commit/71cfbcb85bdd5948f1b96a871b10e9e35ab76c3b)) + +* Update changelog for 0.32.0 (#411) ([`bb83edf`](https://github.com/python-zeroconf/python-zeroconf/commit/bb83edfbca339fb6ec20b821d79b171220f5e675)) + +* Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) ([`0fa049c`](https://github.com/python-zeroconf/python-zeroconf/commit/0fa049c2e0f5e9f18830583a8df2736630c891e2)) + +* Add async_wait function to AsyncZeroconf (#410) ([`53306e1`](https://github.com/python-zeroconf/python-zeroconf/commit/53306e1b99d9133590d47081994ee77cef468828)) + +* Add support for registering notify listeners (#409) + +- Notify listeners will be used by AsyncZeroconf to set + asyncio.Event objects when new data is received + +- Registering a notify listener: + notify_listener = YourNotifyListener() + Use zeroconf.add_notify_listener(notify_listener) + +- Unregistering a notify listener: + Use zeroconf.remove_notify_listener(notify_listener) + +- Notify listeners must inherit from the NotifyListener + class ([`745087b`](https://github.com/python-zeroconf/python-zeroconf/commit/745087b234dd5ff65b4b041a7221d58030a69cdd)) + +* Remove unreachable code in ServiceInfo.get_name (#407) ([`ff31f38`](https://github.com/python-zeroconf/python-zeroconf/commit/ff31f386273fbe9fd0b466bbe5f724c815745215)) + +* Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) + +- Uses the same pattern as ZeroconfServiceTypes.find ([`2da6198`](https://github.com/python-zeroconf/python-zeroconf/commit/2da6198b2e60a598580637e80b3bd579c1f845a5)) + +* Use a dedicated thread for sending outgoing packets with asyncio (#404) + +- Sends now go into a queue and are processed by the thread FIFO + +- Avoids overwhelming the executor when registering multiple + services in parallel ([`1e7b46c`](https://github.com/python-zeroconf/python-zeroconf/commit/1e7b46c36f6e0735b44d3edd9740891a2dc0c761)) + +* Seperate query generation for Zeroconf (#403) + +- Will be used to send the query in asyncio ([`e753078`](https://github.com/python-zeroconf/python-zeroconf/commit/e753078f0345fa28ffceb8de69542c8549d2994c)) + +* Seperate query generation in ServiceInfo (#401) ([`bddf69c`](https://github.com/python-zeroconf/python-zeroconf/commit/bddf69c0839eda966376987a8c4a1fbe3d865529)) + +* Remove unreachable code in ServiceInfo (part 2) (#402) + +- self.server is never None ([`4ae27be`](https://github.com/python-zeroconf/python-zeroconf/commit/4ae27beba29c6e9ac1782f40eadda584b4722af7)) + +* Remove unreachable code in ServiceInfo (#400) + +- self.server is never None ([`dd63835`](https://github.com/python-zeroconf/python-zeroconf/commit/dd6383589b161e828def0ed029519a645e434512)) + +* Update changelog with latest changes (#394) ([`a6010a9`](https://github.com/python-zeroconf/python-zeroconf/commit/a6010a94b626a9a1585cc47417c08516020729d7)) + +* Add test coverage for multiple AAAA records (#391) ([`acf174d`](https://github.com/python-zeroconf/python-zeroconf/commit/acf174db93ee60f1a80d501eb691d9cb434a90b7)) + +* Enable IPv6 in the CI (#393) ([`ec2fafd`](https://github.com/python-zeroconf/python-zeroconf/commit/ec2fafd904cd2d341a3815fcf6d34508dcddda5a)) + +* Fix IPv6 setup under MacOS when binding to "" (#392) + +- Setting IP_MULTICAST_TTL and IP_MULTICAST_LOOP does not work under + MacOS when the bind address is "" ([`d67d5f4`](https://github.com/python-zeroconf/python-zeroconf/commit/d67d5f41effff4c01735de0ae64ed25a5dbe7567)) + +* Update changelog for 0.32.0 (Unreleased) (#390) ([`33a3a6a`](https://github.com/python-zeroconf/python-zeroconf/commit/33a3a6ae42ef8c4ea0f606ad2a02df3f6bc13752)) + +* Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) ([`8f4d2e8`](https://github.com/python-zeroconf/python-zeroconf/commit/8f4d2e858a5efadeb33120322c1169f3ce7d6e0c)) + +* Fix flapping test: test_update_record (#388) ([`ba8d8e3`](https://github.com/python-zeroconf/python-zeroconf/commit/ba8d8e3e658c71e0d603db3f4c5bdfe8e508710a)) + +* Simplify DNSPointer processing in ServiceBrowser (#386) ([`709bd9a`](https://github.com/python-zeroconf/python-zeroconf/commit/709bd9abae63cf566220693501cd37cf74391ccf)) + +* Ensure listeners do not miss initial packets if Engine starts too quickly (#387) ([`62a02d7`](https://github.com/python-zeroconf/python-zeroconf/commit/62a02d774fd874340fa3043bd3bf260a77ffe3d8)) + +* Update changelog with latest commits (#384) ([`69d9357`](https://github.com/python-zeroconf/python-zeroconf/commit/69d9357b3dae7a99d302bf4ad71d4ed45cbe3e42)) + +* Ensure the cache is checked for name conflict after final service query with asyncio (#382) + +- The check was not happening after the last query ([`5057f97`](https://github.com/python-zeroconf/python-zeroconf/commit/5057f97b9b724c041d2bee65972fe3637bf04f0b)) + +* Fix multiple unclosed instances in tests (#383) ([`69a79b9`](https://github.com/python-zeroconf/python-zeroconf/commit/69a79b9fd48a24d311520e228c78b2aae52d1dd5)) + +* Update changelog with latest merges (#381) ([`2b502bc`](https://github.com/python-zeroconf/python-zeroconf/commit/2b502bc2e21efa2f840c42ed79f850b276a8c103)) + +* Complete ServiceInfo request as soon as all questions are answered (#380) + +- Closes a small race condition where there were no questions + to ask because the cache was populated in between checks ([`3afa5c1`](https://github.com/python-zeroconf/python-zeroconf/commit/3afa5c13f2be956505428c5b01f6ce507845131a)) + +* Coalesce browser questions scheduled at the same time (#379) + +- With multiple types, the ServiceBrowser questions can be + chatty because it would generate a question packet for + each type. If multiple types are due to be requested, + try to combine the questions into a single outgoing + packet(s) ([`60c1895`](https://github.com/python-zeroconf/python-zeroconf/commit/60c1895e67a6147ab8c6ba7d21d4fe5adec3e590)) + +* Bump version to 0.31.0 to match released version (#378) ([`23442d2`](https://github.com/python-zeroconf/python-zeroconf/commit/23442d2e5a0336a64646cb70f2ce389746744ce0)) + +* Update changelog with latest merges (#377) ([`5535ea8`](https://github.com/python-zeroconf/python-zeroconf/commit/5535ea8c365557681721fdafdcabfc342c75daf5)) + +* Ensure duplicate packets do not trigger duplicate updates (#376) + +- If TXT or SRV records update was already processed and then + recieved again, it was possible for a second update to be + called back in the ServiceBrowser ([`b158b1c`](https://github.com/python-zeroconf/python-zeroconf/commit/b158b1cff31620d5cf27969e475d788332f4b38c)) + +* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) ([`5133742`](https://github.com/python-zeroconf/python-zeroconf/commit/51337425c9be08d59d496c6783d07d5e4e2382d4)) + +* Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) ([`03f2eb6`](https://github.com/python-zeroconf/python-zeroconf/commit/03f2eb688859a78807305771d04b216e20e72064)) + +* Reduce length of ServiceBrowser thread name with many types (#373) + +- Before + +"zeroconf-ServiceBrowser__ssh._tcp.local.-_enphase-envoy._tcp.local.-_hap._udp.local." +"-_nut._tcp.local.-_Volumio._tcp.local.-_kizbox._tcp.local.-_home-assistant._tcp.local." +"-_viziocast._tcp.local.-_dvl-deviceapi._tcp.local.-_ipp._tcp.local.-_touch-able._tcp.local." +"-_hap._tcp.local.-_system-bridge._udp.local.-_dkapi._tcp.local.-_airplay._tcp.local." +"-_elg._tcp.local.-_miio._udp.local.-_wled._tcp.local.-_esphomelib._tcp.local." +"-_ipps._tcp.local.-_fbx-api._tcp.local.-_xbmc-jsonrpc-h._tcp.local.-_powerview._tcp.local." +"-_spotify-connect._tcp.local.-_leap._tcp.local.-_api._udp.local.-_plugwise._tcp.local." +"-_googlecast._tcp.local.-_printer._tcp.local.-_axis-video._tcp.local.-_http._tcp.local." +"-_mediaremotetv._tcp.local.-_homekit._tcp.local.-_bond._tcp.local.-_daap._tcp.local._243" + +- After + +"zeroconf-ServiceBrowser-_miio._udp-_mediaremotetv._tcp-_dvl-deviceapi._tcp-_ipp._tcp" +"-_dkapi._tcp-_hap._udp-_xbmc-jsonrpc-h._tcp-_hap._tcp-_googlecast._tcp-_airplay._tcp" +"-_viziocast._tcp-_api._udp-_kizbox._tcp-_spotify-connect._tcp-_home-assistant._tcp" +"-_bond._tcp-_powerview._tcp-_daap._tcp-_http._tcp-_leap._tcp-_elg._tcp-_homekit._tcp" +"-_ipps._tcp-_plugwise._tcp-_ssh._tcp-_esphomelib._tcp-_Volumio._tcp-_fbx-api._tcp" +"-_wled._tcp-_touch-able._tcp-_enphase-envoy._tcp-_axis-video._tcp-_printer._tcp" +"-_system-bridge._udp-_nut._tcp-244" ([`5d4aa28`](https://github.com/python-zeroconf/python-zeroconf/commit/5d4aa2800d1196274cfdd0bf3e631f49ab5b78bd)) + +* Update changelog for 0.32.0 (unreleased) (#372) ([`82fb26f`](https://github.com/python-zeroconf/python-zeroconf/commit/82fb26f14518a8e59f886b8d7b0708a68725bf48)) + +* Remove Callable quoting (#371) + +- The current minimum supported cpython is 3.6+ which does not need + the quoting ([`7f45bef`](https://github.com/python-zeroconf/python-zeroconf/commit/7f45bef8db444b0436c5f80b4f4b31b2f1d7ec2f)) + +* Abstract check to see if a record matches a type the ServiceBrowser wants (#369) ([`4819ef8`](https://github.com/python-zeroconf/python-zeroconf/commit/4819ef8c97ddbbadcd6e7cf1b5fee36f573bde45)) + +* Reduce complexity of ServiceBrowser enqueue_callback (#368) + +- The handler key was by name, however ServiceBrowser can have multiple + types which meant the check to see if a state change was an add + remove, or update was overly complex. Reduce the complexity by + making the key (name, type_) ([`4657a77`](https://github.com/python-zeroconf/python-zeroconf/commit/4657a773690a34c897c80894a10ac33b6edadf8b)) + +* Fix empty answers being added in ServiceInfo.request (#367) ([`5a4c1e4`](https://github.com/python-zeroconf/python-zeroconf/commit/5a4c1e46510956276de117d86bee9d2ccb602802)) + +* Ensure ServiceInfo populates all AAAA records (#366) + +- Use get_all_by_details to ensure all records are loaded + into addresses. + +- Only load A/AAAA records from cache once in load_from_cache + if there is a SRV record present + +- Move duplicate code that checked if the ServiceInfo was complete + into its own function ([`bae3a9b`](https://github.com/python-zeroconf/python-zeroconf/commit/bae3a9b97672581e77255c4937b815173c8547b4)) + +* Remove black python 3.5 exception block (#365) ([`6d29e6c`](https://github.com/python-zeroconf/python-zeroconf/commit/6d29e6c93bdcf6cf31fcfa133258257704945dfc)) + +* Small cleanup of ServiceInfo.update_record (#364) + +- Return as record is not viable (None or expired) + +- Switch checks to isinstance since its needed by mypy anyways + +- Prepares for supporting multiple AAAA records (via https://github.com/jstasiak/python-zeroconf/pull/361) ([`1b8b291`](https://github.com/python-zeroconf/python-zeroconf/commit/1b8b2917e7e70e3996e9a96204dd5df3dfb39072)) + +* Add new cache function get_all_by_details (#363) + +- When working with IPv6, multiple AAAA records can exist + for a given host. get_by_details would only return the + latest record in the cache. + +- Fix a case where the cache list can change during + iteration ([`d8c3240`](https://github.com/python-zeroconf/python-zeroconf/commit/d8c32401ada4f430cd75617324b6d8ecd1dbe1f2)) + +* Small cleanups to asyncio tests (#362) ([`7e960b7`](https://github.com/python-zeroconf/python-zeroconf/commit/7e960b78cac8008beca9c5451c6d465e2674a050)) + +* Improve test coverage for name conflicts (#357) ([`c0674e9`](https://github.com/python-zeroconf/python-zeroconf/commit/c0674e97aee4f61212389337340fc8ff4472eb25)) + +* Return task objects created by AsyncZeroconf (#360) ([`8c1c394`](https://github.com/python-zeroconf/python-zeroconf/commit/8c1c394e9b4aa01e08a2c3e240396b533792be55)) + +* Separate cache loading from I/O in ServiceInfo (#356) + +Provides a load_from_cache method on ServiceInfo that does no I/O + +- When a ServiceBrowser is running for a type there is no need + to make queries on the network since the entries will already + be in the cache. When discovering many devices making queries + that will almost certainly fail for offline devices delays the + startup of online devices. + +- The DNSEntry and ServiceInfo classes were matching on the name + instead of the key (lowercase name). These classes now treat dns + names the same reguardless of case. + + https://datatracker.ietf.org/doc/html/rfc6762#section-16 + > The simple rules for case-insensitivity in Unicast DNS [RFC1034] + > [RFC1035] also apply in Multicast DNS; that is to say, in name + > comparisons, the lowercase letters "a" to "z" (0x61 to 0x7A) match + > their uppercase equivalents "A" to "Z" (0x41 to 0x5A). Hence, if a + > querier issues a query for an address record with the name + > "myprinter.local.", then a responder having an address record with + > the name "MyPrinter.local." should issue a response. ([`87ba2a3`](https://github.com/python-zeroconf/python-zeroconf/commit/87ba2a3960576cfcf4207ea74a711b2c0cc584a7)) + +* 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. ([`a41d7b8`](https://github.com/python-zeroconf/python-zeroconf/commit/a41d7b8aa5572f3faf29eb087cc18a1343bbcdfa)) + +* Eliminate the reaper thread (#349) + +- Cache is now purged between reads when the interval is reached + +- Reduce locking since we are already making a copy of the readers + and not reading under the lock + +- Simplify shutdown process ([`7816278`](https://github.com/python-zeroconf/python-zeroconf/commit/781627864efbb3c8285e1b75144d688083414cf3)) + +* Return early when already closed (#350) + +- Reduce indentation with a return early guard in close ([`523aefb`](https://github.com/python-zeroconf/python-zeroconf/commit/523aefb0b0c477489e4e1e4ab763ce56c57295b7)) + +* Skip socket creation if add_multicast_member fails (windows) (#341) + +Co-authored-by: Timothee 'TTimo' Besset ([`beccad1`](https://github.com/python-zeroconf/python-zeroconf/commit/beccad1f0b41730f541b2e90ea2eaa2496de5044)) + +* Simplify cache iteration (#340) + +- Remove the need to trap runtime error +- Only copy the names of the keys when iterating the cache +- Fixes RuntimeError: list changed size during iterating entries_from_name +- Cache services +- The Repear thread is no longer aware of the cache internals ([`fe94810`](https://github.com/python-zeroconf/python-zeroconf/commit/fe948105cc0923336ffa6d93cbe7d45470612a36)) + + +## v0.29.0 (2021-03-25) + +### Unknown + +* Release version 0.29.0 ([`203ec2e`](https://github.com/python-zeroconf/python-zeroconf/commit/203ec2e26e6f0f676e7d88b4a1b0c80ad74659f1)) + +* Fill a missing changelog entry ([`53cb804`](https://github.com/python-zeroconf/python-zeroconf/commit/53cb8044bfb4256f570d438817fd37acc8b78511)) + +* Make mypy configuration more lenient + +We want to be able to call untyped modules. ([`f871b90`](https://github.com/python-zeroconf/python-zeroconf/commit/f871b90d25c0f788590ceb14237b08a6b5e6eeeb)) + +* Silence a flaky test on PyPy ([`bc6ef8c`](https://github.com/python-zeroconf/python-zeroconf/commit/bc6ef8c65b22d982798104d5bdf11b78746a8ddd)) + +* Silence a mypy false-positive ([`6482da0`](https://github.com/python-zeroconf/python-zeroconf/commit/6482da05344e6ae8c4da440da4a704a20c344bb6)) + +* Switch from Travis CI/Coveralls to GH Actions/Codecov + +Travis CI free tier is going away and Codecov is my go-to code coverage +service now. + +Closes GH-332. ([`bd80d20`](https://github.com/python-zeroconf/python-zeroconf/commit/bd80d20682c0af5e15a4b7102dcfe814cdba3a01)) + +* Drop Python 3.5 compatibilty, it reached its end of life ([`ab67a7a`](https://github.com/python-zeroconf/python-zeroconf/commit/ab67a7aecd63042178061f0d1a76f9a7f6e1559a)) + +* Use a single socket for InterfaceChoice.Default + +When using multiple sockets with multi-cast, the outgoing +socket's responses could be read back on the incoming socket, +which leads to duplicate processing and could fill up the +incoming buffer before it could be processed. + +This behavior manifested with error similar to +`OSError: [Errno 105] No buffer space available` + +By using a single socket with InterfaceChoice.Default +we avoid this case. ([`6beefbb`](https://github.com/python-zeroconf/python-zeroconf/commit/6beefbbe76a0e261394b308c8cc68545be653019)) + +* Simplify read_name + +(venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result = "".join((result, "thisisaname" + "."))' +20000 loops, best of 5: 16.4 usec per loop +(venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result += "thisisaname" + "."' +2000000 loops, best of 5: 0.105 usec per loop ([`5e268fa`](https://github.com/python-zeroconf/python-zeroconf/commit/5e268faeaa99f0a513c7bbeda8f447f4eb36a747)) + +* Fix link to readme md --> rst (#324) ([`c5a675d`](https://github.com/python-zeroconf/python-zeroconf/commit/c5a675d22788aa905a4e47feb1d4c30f30416356)) + + +## v0.28.8 (2021-01-04) + +### Unknown + +* Release version 0.28.8 ([`1d726b5`](https://github.com/python-zeroconf/python-zeroconf/commit/1d726b551a49e945b134df6e29b352697030c5a9)) + +* Ensure the name cache is rolled back when the packet reaches maximum size + +If the packet was too large, it would be rolled back at the end of write_record. +We need to remove the names that were added to the name cache (self.names) +as well to avoid a case were we would create a pointer to a name that was +rolled back. + +The size of the packet was incorrect at the end after the inserts because +insert_short would increase self.size even though it was already accounted +before. To resolve this insert_short_at_start was added which does not +increase self.size. This did not cause an actual bug, however it sure +made debugging this problem far more difficult. + +Additionally the size now inserted and then replaced when the actual +size is known because it made debugging quite difficult since the size +did not previously agree with the data. ([`86b4e11`](https://github.com/python-zeroconf/python-zeroconf/commit/86b4e11434d44e2f9a42354109a10f601c44d66a)) + + +## v0.28.7 (2020-12-13) + +### Unknown + +* Release version 0.28.7 ([`8f7effd`](https://github.com/python-zeroconf/python-zeroconf/commit/8f7effd2f89c542162d0e5ac257c561501690d16)) + +* Refactor to move service registration into a registry + +This permits removing the broad exception catch that +was expanded to avoid a crash in when adding or +removing a service ([`2708fef`](https://github.com/python-zeroconf/python-zeroconf/commit/2708fef6052f7e6e6eb36a157438b316e6d38b21)) + +* Prevent crash when a service is added or removed during handle_response + +Services are now modified under a lock. The service processing +is now done in a try block to ensure RuntimeError is caught +which prevents the zeroconf engine from unexpectedly +terminating. ([`4136858`](https://github.com/python-zeroconf/python-zeroconf/commit/41368588e5fcc6ec9596f306e39e2eaac2a9ec18)) + +* Restore IPv6 addresses output + +Before this change, script `examples/browser.py` printed IPv4 only, even with `--v6` argument. +With this change, `examples/browser.py` prints both IPv4 + IPv6 by default, and IPv6 only with `--v6-only` argument. + +I took the idea from the fork +https://github.com/ad3angel1s/python-zeroconf/blob/master/examples/browser.py ([`4da1612`](https://github.com/python-zeroconf/python-zeroconf/commit/4da1612b728acbcf2ab0c4bee09891c46f387bfb)) + + +## v0.28.6 (2020-10-13) + +### Unknown + +* Release version 0.28.6 ([`4744427`](https://github.com/python-zeroconf/python-zeroconf/commit/474442750d5d529436a118fda98a0b5f4680dc4d)) + +* Merge strict and allow_underscores (#309) + +Those really serve the same purpose -- are we receiving data (and want +to be flexible) or registering services (and want to be strict). ([`6a0c5dd`](https://github.com/python-zeroconf/python-zeroconf/commit/6a0c5dd4e84c30264747847e8f1045ece2a14288)) + +* Loosen validation to ensure get_service_info can handle production devices (#307) + +Validation of names was too strict and rejected devices that are otherwise +functional. A partial list of devices that unexpectedly triggered +a BadTypeInNameException: + + Bose Soundtouch + Yeelights + Rachio Sprinklers + iDevices ([`6ab0cd0`](https://github.com/python-zeroconf/python-zeroconf/commit/6ab0cd0a0446f158a1d8a64a3bc548cf9e103179)) + + +## v0.28.5 (2020-09-11) + +### Unknown + +* Release version 0.28.5 ([`eda1b3d`](https://github.com/python-zeroconf/python-zeroconf/commit/eda1b3dd17329c40a59b628b4bbca15c42af43b7)) + +* Fix AttributeError: module 'unittest' has no attribute 'mock' (#302) + +We only had module-level unittest import before now, but code accessing +mock through unittest.mock was working because we have a test-level +import from unittest.mock which causes unittest to gain the mock +attribute and if the test was run before other tests (those using +unittest.mock.patch) all was good. If the test was not run before them, +though, they'd fail. + +Closes GH-295. ([`2db7fff`](https://github.com/python-zeroconf/python-zeroconf/commit/2db7fff033937a929cdfee1fc7c93c594872799e)) + +* Ignore duplicate messages (#299) + +When watching packet captures, I noticed that zeroconf was processing +incoming data 3x on a my Home Assistant OS install because there are +three interfaces. + +We can skip processing duplicate packets in order to reduce the overhead +of decoding data we have already processed. + +Before + +Idle cpu ~8.3% + +recvfrom 4 times + + 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 + 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 + 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 + 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 + +sendto 8 times + + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 + +After + +Idle cpu ~4.1% + +recvfrom 4 times (no change): + + 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 + 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 + 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 + 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 + +sendto 2 times (reduced by 4x): + + 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 + 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 + +With debug logging on for ~5 minutes + + bash-5.0# grep 'Received from' home-assistant.log |wc + 11458 499196 19706165 + bash-5.0# grep 'Ignoring' home-assistant.log |wc + 9357 210562 9299687 ([`f321932`](https://github.com/python-zeroconf/python-zeroconf/commit/f3219326e65f4410d45ace05f88082354a2f7525)) + +* Test with the development version of Python 3.9 (#300) + +There've been reports of test failures on Python 3.9, let's verify this. +Allowing failures for now until it goes stable. ([`1f81e0b`](https://github.com/python-zeroconf/python-zeroconf/commit/1f81e0bcad1cae735ba532758d167368925c8ede)) + + +## v0.28.4 (2020-09-06) + +### Unknown + +* Release version 0.28.4 ([`fb876d6`](https://github.com/python-zeroconf/python-zeroconf/commit/fb876d6013979cdaa8c0ddebe81e7520e9ee8cc9)) + +* Add ServiceListener to __all__ for Zeroconf module (#298) + +It's part of the public API. ([`0265a9d`](https://github.com/python-zeroconf/python-zeroconf/commit/0265a9d57630a4a19bcd3638a6bb3f4b18eba01b)) + +* Avoid copying the entires cache and reduce frequency of Reaper + +The cache reaper was running at least every 10 seconds, making +a copy of the cache, and iterated all the entries to +check if they were expired so they could be removed. + +In practice the reaper was actually running much more frequently +because it used self.zc.wait which would unblock any time +a record was updated, a listener was added, or when a +listener was removed. + +This change ensures the reaper frequency is only every 10s, and +will first attempt to iterate the cache before falling back to +making a copy. + +Previously it made sense to expire the cache more frequently +because we had places were we frequently had to enumerate +all the cache entries. With #247 and #232 we no longer +have to account for this concern. + +On a mostly idle RPi running HomeAssistant and a busy +network the total time spent reaping the cache was +more than the total time spent processing the mDNS traffic. + +Top 10 functions, idle RPi (before) + + %Own %Total OwnTime TotalTime Function (filename:line) + 0.00% 0.00% 2.69s 2.69s handle_read (zeroconf/__init__.py:1367) <== Incoming mDNS + 0.00% 0.00% 1.51s 2.98s run (zeroconf/__init__.py:1431) <== Reaper + 0.00% 0.00% 1.42s 1.42s is_expired (zeroconf/__init__.py:502) <== Reaper + 0.00% 0.00% 1.12s 1.12s entries (zeroconf/__init__.py:1274) <== Reaper + 0.00% 0.00% 0.620s 0.620s do_execute (sqlalchemy/engine/default.py:593) + 0.00% 0.00% 0.620s 0.620s read_utf (zeroconf/__init__.py:837) + 0.00% 0.00% 0.610s 0.610s do_commit (sqlalchemy/engine/default.py:546) + 0.00% 0.00% 0.540s 1.16s read_name (zeroconf/__init__.py:853) + 0.00% 0.00% 0.380s 0.380s do_close (sqlalchemy/engine/default.py:549) + 0.00% 0.00% 0.340s 0.340s write (asyncio/selector_events.py:908) + +After this change, the Reaper code paths do not show up in the top +10 function sample. + + %Own %Total OwnTime TotalTime Function (filename:line) + 4.00% 4.00% 2.72s 2.72s handle_read (zeroconf/__init__.py:1378) <== Incoming mDNS + 4.00% 4.00% 1.81s 1.81s read_utf (zeroconf/__init__.py:837) + 1.00% 5.00% 1.68s 3.51s read_name (zeroconf/__init__.py:853) + 0.00% 0.00% 1.32s 1.32s do_execute (sqlalchemy/engine/default.py:593) + 0.00% 0.00% 0.960s 0.960s readinto (socket.py:669) + 0.00% 0.00% 0.950s 0.950s create_connection (urllib3/util/connection.py:74) + 0.00% 0.00% 0.910s 0.910s do_commit (sqlalchemy/engine/default.py:546) + 1.00% 1.00% 0.880s 0.880s write (asyncio/selector_events.py:908) + 0.00% 0.00% 0.700s 0.810s __eq__ (zeroconf/__init__.py:606) + 2.00% 2.00% 0.670s 0.670s unpack (zeroconf/__init__.py:737) ([`1e4aaea`](https://github.com/python-zeroconf/python-zeroconf/commit/1e4aaeaa10c306b9447dacefa03b89ce1e9d7493)) + +* Add an author in the last changelog entry ([`9e27d12`](https://github.com/python-zeroconf/python-zeroconf/commit/9e27d126d75c73466584c417ab35c1d6cf47ca8b)) + + +## v0.28.3 (2020-08-31) + +### Unknown + +* Release version 0.28.3 ([`0e49aec`](https://github.com/python-zeroconf/python-zeroconf/commit/0e49aeca6497ede18a3f0c71ea69f2343934ba19)) + +* Reduce the time window that the handlers lock is held + +Only hold the lock if we have an update. ([`5a359bb`](https://github.com/python-zeroconf/python-zeroconf/commit/5a359bb0931fbda8444e30d07a50e59cf4ccca8e)) + +* Reformat using the latest black (20.8b1) ([`57d89d8`](https://github.com/python-zeroconf/python-zeroconf/commit/57d89d85e52dea1f8cb7f6d4b02c0281d5ba0540)) + + +## v0.28.2 (2020-08-27) + +### Unknown + +* Release version 0.28.2 ([`f64768a`](https://github.com/python-zeroconf/python-zeroconf/commit/f64768a7253829f9d8f7796a6a5c8129b92f2aad)) + +* Increase test coverage for dns cache ([`3be96b0`](https://github.com/python-zeroconf/python-zeroconf/commit/3be96b014d61c94d71ae3aa23ba223eead4f4cb7)) + +* Don't ask already answered questions (#292) + +Fixes GH-288. + +Co-authored-by: Erik ([`fca090d`](https://github.com/python-zeroconf/python-zeroconf/commit/fca090db06a0d481ad7f608c4fde3e936ad2f80e)) + +* Remove initial delay before querying for service info ([`0f73664`](https://github.com/python-zeroconf/python-zeroconf/commit/0f7366423fab8369700be086f3007c20897fde1f)) + + +## v0.28.1 (2020-08-17) + +### Unknown + +* Release version 0.28.1 ([`3c5d385`](https://github.com/python-zeroconf/python-zeroconf/commit/3c5d3856e286824611712de13aa0fcbe94e4313f)) + +* Ensure all listeners are cleaned up on ServiceBrowser cancelation (#290) + +When creating listeners for a ServiceBrowser with multiple types +they would not all be removed on cancelation. This led +to a build up of stale listeners when ServiceBrowsers were +frequently added and removed. ([`c9f3c91`](https://github.com/python-zeroconf/python-zeroconf/commit/c9f3c91da568fdbd26d571eed8a636a49e527b15)) + +* Gitignore some build artifacts ([`19e33a6`](https://github.com/python-zeroconf/python-zeroconf/commit/19e33a6829846008b50f408c77ac3e8e73176529)) + + +## v0.28.0 (2020-07-07) + +### Unknown + +* Release version 0.28.0 ([`0fdbf5e`](https://github.com/python-zeroconf/python-zeroconf/commit/0fdbf5e197a9f76e9e9c91a5e0908a0c66370dbd)) + +* Advertise Python 3.8 compatibility ([`02bcad9`](https://github.com/python-zeroconf/python-zeroconf/commit/02bcad902c516a5a2d2aa3302bca9871900da6e3)) + +* Fix an OS X edge case (#270, #188) + +This contains two major changes: + +* Listen on data from respond_sockets in addition to listen_socket +* Do not bind respond sockets to 0.0.0.0 or ::/0 + +The description of the original change by Emil: + +<<< +Without either of these changes, I get no replies at all when browsing for +services using the browser example. I'm on a corporate network, and when +connecting to a different network it works without these changes, so maybe +it's something about the network configuration in this particular network +that breaks the previous behavior. + +Unfortunately, I have no idea how this affects other platforms, or what +the changes really mean. However, it works for me and it seems reasonable +to get replies back on the same socket where they are sent. +>>> + +The tests pass and it's been confirmed to a reasonable degree that this +doesn't break the previously working use cases. + +Additionally this removes a memory leak where data sent to some of the +respond sockets would not be ever read from them (#171). + +Co-authored-by: Emil Styrke ([`fc92b1e`](https://github.com/python-zeroconf/python-zeroconf/commit/fc92b1e2635868792aa7ebe937a9cfef2e2f0418)) + +* Stop using socket.if_nameindex (#282) + +This improves Windows compatibility ([`a7f9823`](https://github.com/python-zeroconf/python-zeroconf/commit/a7f9823cbed254b506a09cc514d86d9f5dc61ad3)) + +* Make Mypy happy (#281) + +Otherwise it'd complain: + + % make mypy + mypy examples/*.py zeroconf/*.py + zeroconf/__init__.py:2039: error: Returning Any from function declared to return "int" + Found 1 error in 1 file (checked 6 source files) + make: *** [mypy] Error 1 ([`4381784`](https://github.com/python-zeroconf/python-zeroconf/commit/4381784150e07625b4acd2034b253bf2ed320c5f)) + +* Use Adapter.index from ifaddr. (#280) + +Co-authored-by: PhilippSelenium ([`64056ab`](https://github.com/python-zeroconf/python-zeroconf/commit/64056ab4aa55eb11c185c9879462ba1f82c7e886)) + +* Exclude a problematic pep8-naming version ([`023e72d`](https://github.com/python-zeroconf/python-zeroconf/commit/023e72d821faed9513ee0ef3a22a00231d87389e)) + +* Log listen and respond sockets just in case ([`3b6906a`](https://github.com/python-zeroconf/python-zeroconf/commit/3b6906ab94f8d9ebeb1c97b6026ab7f9be226eab)) + +* Fix one log format string (we use a socket object here) ([`328abfc`](https://github.com/python-zeroconf/python-zeroconf/commit/328abfc54138e68e36a9f5381650bd6997701e73)) + +* Add support for passing text addresses to ServiceInfo + +Not sure if parsed_addresses is the best way to name the parameter, but +we already have a parsed_addresses property so for the sake of +consistency let's stick to that. ([`0a9aa8d`](https://github.com/python-zeroconf/python-zeroconf/commit/0a9aa8d31bffec5d7b7291b84fbc95222b10d189)) + +* Support Windows when using socket errno checks (#274) + +Windows reports errno.WSAEINVAL(10022) instead of errno.EINVAL(22). +This issue is triggered when a device has two IP's assigned under +windows. + +This fixes #189 ([`c31ae7f`](https://github.com/python-zeroconf/python-zeroconf/commit/c31ae7fd519df04f41939d3c60c2b88960737fd6)) + + +## v0.27.1 (2020-06-05) + +### Unknown + +* Release version 0.27.1 ([`0538abf`](https://github.com/python-zeroconf/python-zeroconf/commit/0538abf135f5502d94dd883475bcb2781ce5ddd2)) + +* Fix false warning (#273) + +When there is nothing to write, we don't need to warn about not making progress. ([`10065b9`](https://github.com/python-zeroconf/python-zeroconf/commit/10065b976247ae9247cddaff8f3e9d7b331e66d7)) + +* Improve logging (mainly include sockets in some messages) (#271) ([`beff998`](https://github.com/python-zeroconf/python-zeroconf/commit/beff99897f0a5ece17e224a7ea9b12ebd420044f)) + +* Simplify DNSHinfo constructor, cpu and os are always text (#266) ([`d6593af`](https://github.com/python-zeroconf/python-zeroconf/commit/d6593af2a3811b262d70bbc75c2c91613de41b21)) + +* Improve ImportError message (wrong supported Python version) ([`8045191`](https://github.com/python-zeroconf/python-zeroconf/commit/8045191ae6300da47d38e5cd82957965139359d2)) + +* Remove old Python 2-specific code ([`6f876a7`](https://github.com/python-zeroconf/python-zeroconf/commit/6f876a7f14f0b172860005b0d6d959d82f7c1bbf)) + + +## v0.27.0 (2020-05-27) + +### Unknown + +* Release version 0.27.0 ([`0502f19`](https://github.com/python-zeroconf/python-zeroconf/commit/0502f1904b0a8b9134ea2a09333232b30b3b6897)) + +* Remove no longer needed typing dependency + +We don't support Python older than 3.5. ([`d881aba`](https://github.com/python-zeroconf/python-zeroconf/commit/d881abaf591f260ad019f4ff86e7f70a6f018a64)) + +* Add --find option to example/browser.py (#263, rebased #175) + +Co-authored-by: Perry Kundert ([`781ac83`](https://github.com/python-zeroconf/python-zeroconf/commit/781ac834da38708d95bfe6e5f5ec7dd0f31efc54)) + +* Restore missing warnings import ([`178cec7`](https://github.com/python-zeroconf/python-zeroconf/commit/178cec75bd9a065b150b3542dfdb40682f6745b6)) + +* Warn on every call to missing update_service() listener method + +This is in order to provide visibility to the library users that this +method exists - without it the client code may be missing data. ([`488ee1e`](https://github.com/python-zeroconf/python-zeroconf/commit/488ee1e85762dc5856d8e132da54762e5e712c5a)) + +* Separately send large mDNS responses to comply with RFC 6762 (#248) + +This fixes issue #245 + +Split up large multi-response packets into separate packets instead of relying on IP Fragmentation. IP Fragmentation of mDNS packets causes ChromeCast Audios to +crash their mDNS responder processes and RFC 6762 +(https://tools.ietf.org/html/rfc6762) section 17 states some +requirements for Multicast DNS Message Size, and the fourth paragraph reads: + +"A Multicast DNS packet larger than the interface MTU, which is sent +using fragments, MUST NOT contain more than one resource record." + +This change makes this implementation conform with this MUST NOT clause. ([`87a0fe2`](https://github.com/python-zeroconf/python-zeroconf/commit/87a0fe27a7be9d96af08f8a007f37a16105c64a0)) + +* Remove deprecated ServiceInfo address parameter/property (#260) ([`ab72aa8`](https://github.com/python-zeroconf/python-zeroconf/commit/ab72aa8e5a6a83e50d24d7fb187e8fa8a549a847)) + + +## v0.26.3 (2020-05-26) + +### Unknown + +* Release version 0.26.3 ([`fbcefca`](https://github.com/python-zeroconf/python-zeroconf/commit/fbcefca592632304579c1b3f9c7bd3dd342e1618)) + +* Don't call callbacks when holding _handlers_lock (#258) + +Closes #255 + +Background: +#239 adds the lock _handlers_lock: + +python-zeroconf/zeroconf/__init__.py + + self._handlers_lock = threading.Lock() # ensure we process a full message in one go + +Which is used in the engine thread: + + def handle_response(self, msg: DNSIncoming) -> None: + """Deal with incoming response packets. All answers + are held in the cache, and listeners are notified.""" + + with self._handlers_lock: + + +And also by the service browser when issuing the state change callbacks: + + if len(self._handlers_to_call) > 0 and not self.zc.done: + with self.zc._handlers_lock: + handler = self._handlers_to_call.popitem(False) + self._service_state_changed.fire( + zeroconf=self.zc, service_type=self.type, name=handler[0], state_change=handler[1] + ) + +Both pychromecast and Home Assistant calls Zeroconf.get_service_info from the service callbacks which means the lock may be held for several seconds which will starve the engine thread. ([`fe86566`](https://github.com/python-zeroconf/python-zeroconf/commit/fe865667e4610d57067a8f710f4d818eaa5e14dc)) + +* Give threads unique names (#257) ([`54d116f`](https://github.com/python-zeroconf/python-zeroconf/commit/54d116fd69a66062f91be04d84ceaebcfb13cc43)) + +* Use equality comparison instead of identity comparison for ints + +Integers aren't guaranteed to have the same identity even though they +may be equal. ([`445d7f5`](https://github.com/python-zeroconf/python-zeroconf/commit/445d7f5dbe38947bd0bd1e3a5b8d649c1819c21f)) + +* Merge 0.26.2 release commit + +I accidentally only pushed 0.26.2 tag (commit ffb42e5836bd) without +pushing the commit to master and now I merged aa9de4de7202 so this is +the best I can do without force-pushing to master. Tag 0.26.2 will +continue to point to that dangling commit. ([`1c4d3fc`](https://github.com/python-zeroconf/python-zeroconf/commit/1c4d3fcbf34b09364e52a773783dc9c924a7b17a)) + +* Improve readability of logged incoming data (#254) ([`aa9de4d`](https://github.com/python-zeroconf/python-zeroconf/commit/aa9de4de7202b3ab0a60f14532d227f63d7d981b)) + +* Add support for multiple types to ServiceBrowsers + +As each ServiceBrowser runs in its own thread there +is a scale problem when listening for many types. + +ServiceBrowser can now accept a list of types +in addition to a single type. ([`a6ad100`](https://github.com/python-zeroconf/python-zeroconf/commit/a6ad100a60e8434cef6b411208eef98f68d594d3)) + +* Fix race condition where a listener gets +a message before the lock is created. ([`24a0619`](https://github.com/python-zeroconf/python-zeroconf/commit/24a06191ea35469948d12124a07429207b3c1b3b)) + +* Fix flake8 E741 in setup.py (#252) ([`4b1d953`](https://github.com/python-zeroconf/python-zeroconf/commit/4b1d953979287e08f914857867da1000634ca3af)) + + +## v0.26.1 (2020-05-06) + +### Unknown + +* Release version 0.26.1 ([`4c359e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4c359e2e7cdf104efca90ffd9912ea7c7792e3bf)) + +* Remove unwanted pylint directives + +Those are results of a bad conflict resolution I did when merging [1]. + +[1] 552a030eb592 ("Call UpdateService on SRV & A/AAAA updates as well as TXT (#239)") ([`0dd6fe4`](https://github.com/python-zeroconf/python-zeroconf/commit/0dd6fe44ca3895375ba447fed5f138042ab12ebf)) + +* Avoid iterating the entire cache when an A/AAAA address has not changed (#247) + +Iterating the cache is an expensive operation +when there is 100s of devices generating zeroconf +traffic as there can be 1000s of entries in the +cache. ([`0540342`](https://github.com/python-zeroconf/python-zeroconf/commit/0540342bacd859f38f6d2a3743a7959cd3ae4d02)) + +* Update .gitignore for Visual Studio config files (#244) ([`16431b6`](https://github.com/python-zeroconf/python-zeroconf/commit/16431b6cb51f561a4c5d2897e662b254ca4243ec)) + + +## v0.26.0 (2020-04-26) + +### Unknown + +* Release version 0.26.0 ([`36941ae`](https://github.com/python-zeroconf/python-zeroconf/commit/36941aeb72711f7954d40f0abeab4802174636df)) + +* Call UpdateService on SRV & A/AAAA updates as well as TXT (#239) + +Fix https://github.com/jstasiak/python-zeroconf/issues/235 + +Contains: + +* Add lock around handlers list +* Reverse DNSCache order to ensure newest records take precedence + + When there are multiple records in the cache, the behaviour was + inconsistent. Whilst the DNSCache.get() method returned the newest, + any function which iterated over the entire cache suffered from + a last write winds issue. This change makes this behaviour consistent + and allows the removal of an (incorrect) wait from one of the unit tests. ([`552a030`](https://github.com/python-zeroconf/python-zeroconf/commit/552a030eb592a0c07feaa7a01ece1464da4b1d0b)) + + +## v0.25.1 (2020-04-14) + +### Unknown + +* Release version 0.25.1 ([`f8fe400`](https://github.com/python-zeroconf/python-zeroconf/commit/f8fe400e4be833728f015a3d6396bfc3f7c185c0)) + +* Update Engine to immediately notify its worker thread (#243) ([`976e3dc`](https://github.com/python-zeroconf/python-zeroconf/commit/976e3dcf9d6d897b063ab6f0b7831bcfa6ac1814)) + +* Remove unstable IPv6 tests from Travis (#241) ([`cf0382b`](https://github.com/python-zeroconf/python-zeroconf/commit/cf0382ba771bcc22284fd719c80a26eaa05ba5cd)) + +* Switch to pytest for test running (#240) + +Nose is dead for all intents and purposes (last release in 2015) and +pytest provide a very valuable feature of printing relevant extra +information in case of assertion failure (from[1]): + + ================================= FAILURES ================================= + _______________________________ test_answer ________________________________ + + def test_answer(): + > assert func(3) == 5 + E assert 4 == 5 + E + where 4 = func(3) + + test_sample.py:6: AssertionError + ========================= short test summary info ========================== + FAILED test_sample.py::test_answer - assert 4 == 5 + ============================ 1 failed in 0.12s ============================= + +This should be helpful in debugging tests intermittently failing on +PyPy. + +Several TestCase.assertEqual() calls have been replaced by plain +assertions now that that method no longer provides anything we can't get +without it. Few assertions have been modified to not explicitly provide +extra information in case of failure – pytest will provide this +automatically. + +Dev dependencies are forced to be the latest versions to make sure +we don't fail because of outdated ones on Travis. + +[1] https://docs.pytest.org/en/latest/getting-started.html#create-your-first-test ([`f071f3d`](https://github.com/python-zeroconf/python-zeroconf/commit/f071f3d49d82ab212b86f889532200c94b36aea6)) + + +## v0.25.0 (2020-04-03) + +### Unknown + +* Release version 0.25.0 ([`0cbced8`](https://github.com/python-zeroconf/python-zeroconf/commit/0cbced809989283893e02914e251a94739a41062)) + +* Improve ServiceInfo documentation ([`e839c40`](https://github.com/python-zeroconf/python-zeroconf/commit/e839c40081ba15e228d447969b725ee42f1ef2ad)) + +* Remove uniqueness assertions + +The assertions, added in [1] and modified in [2] introduced a +regression. When browsing in the presence of devices advertising SRV +records not marked as unique there would be an undesired crash (from [3]): + + Exception in thread zeroconf-ServiceBrowser__hap._tcp.local.: + Traceback (most recent call last): + File "/usr/lib/python3.7/threading.py", line 917, in _bootstrap_inner + self.run() + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1504, in run + handler(self.zc) + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1444, in + zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1322, in fire + h(**kwargs) + File "browser.py", line 20, in on_service_state_change + info = zeroconf.get_service_info(service_type, name) + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 2191, in get_service_info + if info.request(self, timeout): + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1762, in request + out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN), now) + File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 907, in add_answer_at_time + assert record.unique + AssertionError + +The intention is to bring those assertions back in a way that only +enforces uniqueness when sending records, not when receiving them. + +[1] bef8f593ae82 ("Ensure all TXT, SRV, A records are unique") +[2] 5e4f496778d9 ("Refactor out unique assertion") +[3] https://github.com/jstasiak/python-zeroconf/issues/236 ([`a79015e`](https://github.com/python-zeroconf/python-zeroconf/commit/a79015e7c4bdc843d97bd5c82ef8ed4eeae01a34)) + +* Rationalize handling of values in TXT records + +* Do not interpret received values; use None if a property has no value +* When encoding values, use either raw bytes or UTF-8 ([`8e3adf8`](https://github.com/python-zeroconf/python-zeroconf/commit/8e3adf8300a6f2b0bc0dcc4cde54d8890e0727e9)) + + +## v0.24.5 (2020-03-08) + +### Unknown + +* Release version 0.24.5 ([`aba2858`](https://github.com/python-zeroconf/python-zeroconf/commit/aba28583f5431f584587770b6c149e4a607a987e)) + +* Resolve memory leak in DNSCache + +When all the records for a given name were removed from the cache, the +name itself that contain the list was never removed. This left an empty list +in memory for every device that was no longer broadcasting on the +network. ([`eac53f4`](https://github.com/python-zeroconf/python-zeroconf/commit/eac53f45bddb8d3d559b1d4672a926b746435771)) + +* Optimize handle_response cache check + +The handle_response loop would encounter a unique record +it would search the cache in order to remove keys that +matched the DNSEntry for the record. + +Since the cache is stored as a list of records with the key as the record name, + we can avoid searching the entire cache each time and on +search for the DNSEntry of the record. In practice this means +with 5000 entries and records in the cache we now only need to search +4 or 5. + +When looping over the cache entries for the name, we now check the expire time +first as its cheaper than calling DNSEntry.__eq__ + +Test environment: + + Home Assistant running on home networking with a /22 + and a significant amount of broadcast traffic + + Testing was done with py-spy v0.3.3 + (https://github.com/benfred/py-spy/releases) + + # py-spy top --pid + +Before: +``` +Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) +Total Samples 10200 +GIL: 0.00%, Active: 0.00%, Threads: 35 + + %Own %Total OwnTime TotalTime Function (filename:line) + 0.00% 0.00% 18.13s 18.13s _worker (concurrent/futures/thread.py:78) + 0.00% 0.00% 2.51s 2.56s run (zeroconf/__init__.py:1221) + 0.00% 0.00% 0.420s 0.420s __eq__ (zeroconf/__init__.py:394) + 0.00% 0.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1260) + 0.00% 0.00% 0.240s 0.670s handle_response (zeroconf/__init__.py:2452) + 0.00% 0.00% 0.230s 0.230s __eq__ (zeroconf/__init__.py:606) + 0.00% 0.00% 0.200s 0.810s handle_response (zeroconf/__init__.py:2449) + 0.00% 0.00% 0.140s 0.150s __eq__ (zeroconf/__init__.py:632) + 0.00% 0.00% 0.130s 0.130s entries (zeroconf/__init__.py:1185) + 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) + 0.00% 0.00% 0.080s 0.080s read_utf (zeroconf/__init__.py:818) + 0.00% 0.00% 0.080s 0.080s __eq__ (zeroconf/__init__.py:678) + 0.00% 0.00% 0.070s 0.080s __eq__ (zeroconf/__init__.py:533) + 0.00% 0.00% 0.060s 0.060s __eq__ (zeroconf/__init__.py:677) + 0.00% 0.00% 0.050s 0.050s get (zeroconf/__init__.py:1146) + 0.00% 0.00% 0.050s 0.050s do_commit (sqlalchemy/engine/default.py:541) + 0.00% 0.00% 0.040s 2.86s run (zeroconf/__init__.py:1226) +``` + +After +``` +Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) +Total Samples 10200 +GIL: 7.00%, Active: 61.00%, Threads: 35 + + %Own %Total OwnTime TotalTime Function (filename:line) + 47.00% 47.00% 24.84s 24.84s _worker (concurrent/futures/thread.py:78) + 5.00% 5.00% 2.97s 2.97s run (zeroconf/__init__.py:1226) + 1.00% 1.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1265) + 1.00% 1.00% 0.200s 0.200s read_utf (zeroconf/__init__.py:818) + 0.00% 0.00% 0.120s 0.120s unpack (zeroconf/__init__.py:723) + 0.00% 1.00% 0.120s 0.320s read_name (zeroconf/__init__.py:834) + 0.00% 0.00% 0.100s 0.240s update_record (zeroconf/__init__.py:2440) + 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) + 0.00% 0.00% 0.070s 0.070s update_record (zeroconf/__init__.py:1469) + 0.00% 0.00% 0.060s 0.070s __eq__ (zeroconf/__init__.py:606) + 0.00% 0.00% 0.050s 0.050s acquire (logging/__init__.py:843) + 0.00% 0.00% 0.050s 0.050s unpack (zeroconf/__init__.py:722) + 0.00% 0.00% 0.050s 0.050s read_name (zeroconf/__init__.py:828) + 0.00% 0.00% 0.050s 0.050s is_expired (zeroconf/__init__.py:494) + 0.00% 0.00% 0.040s 0.040s emit (logging/__init__.py:1028) + 1.00% 1.00% 0.040s 0.040s __init__ (zeroconf/__init__.py:386) + 0.00% 0.00% 0.040s 0.040s __enter__ (threading.py:241) +``` ([`37fa0a0`](https://github.com/python-zeroconf/python-zeroconf/commit/37fa0a0d59a5b5d09295a462bf911e82d2d770ed)) + +* Support cooperating responders (#224) ([`1ca023f`](https://github.com/python-zeroconf/python-zeroconf/commit/1ca023fae4b586679446ceaf3e2e9955ea5bf180)) + +* Remove duplciate update messages sent to listeners + +The prior code used to send updates even when the new record was identical to the old. + +This resulted in duplciate update messages when there was in fact no update (apart from TTL refresh) ([`d8caa4e`](https://github.com/python-zeroconf/python-zeroconf/commit/d8caa4e2d71025ed42b33abb4d329329437b44fb)) + +* Refactor out unique assertion ([`5e4f496`](https://github.com/python-zeroconf/python-zeroconf/commit/5e4f496778d91ccfc65e946d3d94c39ab6388b29)) + +* Fix representation of IPv6 DNSAddress (#230) ([`f6690d2`](https://github.com/python-zeroconf/python-zeroconf/commit/f6690d2048cb87cb0fb3a7c3b832cf1a1f40e61a)) + +* Do not exclude interfaces with host-only netmasks from InterfaceChoice.All (#227) + +Host-only netmasks do not forbid multicast. + +Tested on Debian 10 running in Qubes and on Ubuntu 18.04. ([`ca8e53d`](https://github.com/python-zeroconf/python-zeroconf/commit/ca8e53de55a563f5c7049be2eda14ae0ecd1a7cf)) + +* Ensure all TXT, SRV, A records are unique + +Fixes issues with shared records being used where they shouldn't be. + +PTR records should be shared, but SRV, TXT and A/AAAA records should be unique. + +Whilst mDNS and DNS-SD in theory support shared records for these types of record, they are not implemented in python-zeroconf at the moment. + +See zeroconf.check_service() method which verifies the service is unique on the network before registering. ([`bef8f59`](https://github.com/python-zeroconf/python-zeroconf/commit/bef8f593ae820eb8465934de91eb27468edf6444)) + + +## v0.24.4 (2019-12-30) + +### Unknown + +* Release version 0.24.4 ([`29432bf`](https://github.com/python-zeroconf/python-zeroconf/commit/29432bfffd057cf4da7636ba0c28c9d8a7ad4357)) + +* Clean up output of ttl remaining to be whole seconds only ([`ba1b78d`](https://github.com/python-zeroconf/python-zeroconf/commit/ba1b78dbdcc64f8d35c951e7ca53d2898e7d7900)) + +* Clean up format to cleanly separate [question]=ttl,answer ([`4b735dc`](https://github.com/python-zeroconf/python-zeroconf/commit/4b735dc5411f7b563f23b60b5c2aa806151cca1a)) + +* Update DNS entries so all subclasses of DNSRecord use to_string for display + +All records based on DNSRecord now properly use to_string in repr, some were +only dumping the answer without the question (inconsistent). ([`8ccad54`](https://github.com/python-zeroconf/python-zeroconf/commit/8ccad54dab4a0ab7f573996f6fc0c2f2bad7eafe)) + +* Fix resetting of TTL (#209) + +Fix resetting of TTL + +Previously the reset_ttl method changed the time created and the TTL value, but did not change the expiration time or stale times. As a result a record would expire even when this method had been called. ([`b47efd8`](https://github.com/python-zeroconf/python-zeroconf/commit/b47efd8eed0b5ed9d3b6bca8573a6ed1916c982a)) + + +## v0.24.3 (2019-12-23) + +### Unknown + +* Release version 0.24.3 ([`2316027`](https://github.com/python-zeroconf/python-zeroconf/commit/2316027e5e96d8f10fae7607da5b72a9bab819fc)) + +* Fix import-time TypeError on CPython 3.5.2 + +The error: TypeError: 'ellipsis' object is not iterable." + +Explanation can be found here: https://github.com/jstasiak/python-zeroconf/issues/208 + +Closes GH-208. ([`f53e24b`](https://github.com/python-zeroconf/python-zeroconf/commit/f53e24bddb3a6cb242cace2a541ed507e823be33)) + + +## v0.24.2 (2019-12-17) + +### Unknown + +* Release version 0.24.2 ([`76bc675`](https://github.com/python-zeroconf/python-zeroconf/commit/76bc67532ad26f54c194e1e6537d2da4390f83e2)) + +* Provide and enforce type hints everywhere except for tests + +The tests' time will come too in the future, though, I think. I believe +nose has problems with running annotated tests right now so let's leave +it for later. + +DNSEntry.to_string renamed to entry_to_string because DNSRecord +subclasses DNSEntry and overrides to_string with a different signature, +so just to be explicit and obvious here I renamed it – I don't think any +client code will break because of this. + +I got rid of ServicePropertiesType in favor of generic Dict because +having to type all the properties got annoying when variance got +involved – maybe it'll be restored in the future but it seems like too +much hassle now. ([`f771587`](https://github.com/python-zeroconf/python-zeroconf/commit/f7715874c2242b95cf9815549344ea66ac107b6e)) + +* Fix get_expiration_time percent parameter annotation + +It takes integer percentage values at the moment so let's document that. ([`5986bf6`](https://github.com/python-zeroconf/python-zeroconf/commit/5986bf66e77e77f9e0b6ba43a4758ecb0da04ff6)) + +* Add support for AWDL interface on macOS + +The API is inspired by Apple's NetService.includesPeerToPeer +(see https://developer.apple.com/documentation/foundation/netservice/1414086-includespeertopeer) ([`fcafdc1`](https://github.com/python-zeroconf/python-zeroconf/commit/fcafdc1e285cc5c3c1f2c413ac9309d3426179f4)) + + +## v0.24.1 (2019-12-16) + +### Unknown + +* Release version 0.24.1 ([`53dd06c`](https://github.com/python-zeroconf/python-zeroconf/commit/53dd06c37f6205129e81f5c6b69e508a54f94d07)) + +* Bugfix: TXT record's name is never equal to Service Browser's type. + +TXT record's name is never equal to Service Browser's type. We should +check whether TXT record's name ends with Service Browser's type. +Otherwise, we never get updates of TXT records. ([`2a597ee`](https://github.com/python-zeroconf/python-zeroconf/commit/2a597ee80906a27effd442d033de10b5129e6900)) + +* Bugfix: Flush outdated cache entries when incoming record is unique. + +According to RFC 6762 10.2. Announcements to Flush Outdated Cache Entries, +when the incoming record's cache-flush bit is set (record.unique == True +in this module), "Instead of merging this new record additively into the +cache in addition to any previous records with the same name, rrtype, and +rrclass, all old records with that name, rrtype, and rrclass that were +received more than one second ago are declared invalid, and marked to +expire from the cache in one second." ([`1d39b3e`](https://github.com/python-zeroconf/python-zeroconf/commit/1d39b3edd141093f9e579ab83377fe8f5ecb357d)) + +* Change order of equality check to favor cheaper checks first + +Comparing two strings is much cheaper than isinstance, so we should try +those first + +A performance test was run on a network with 170 devices running Zeroconf. +There was a ServiceBrowser running on a separate thread while a timer ran +on the main thread that forced a thread switch every 2 seconds (to include +the effect of thread switching in the measurements). Every minute, +a Zeroconf broadcast was made on the network. + +This was ran this for an hour on a Macbook Air from 2015 (Intel Core +i7-5650U) using Ubuntu 19.10 and Python 3.7, both before this commit and +after. + +These are the results of the performance tests: +Function Before count Before time Before time per count After count After time After time per count Time reduction +DNSEntry.__eq__ 528 0.001s 1.9μs 538 0.001s 1.9μs 1.9% +DNSPointer.__eq__ 24369256 (24.3M) 134.641s 5.5μs 25989573 (26.0M) 86.405s 3.3μs 39.8% +DNSText.__eq__ 52966716 (53.0M) 190.640s 3.6μs 53604915 (53.6M) 169.104s 3.2μs 12.4% +DNSService.__eq__ 52620538 (52.6M) 171.660s 3.3μs 56557448 (56.6M) 170.222s 3.0μs 7.8% ([`815ac77`](https://github.com/python-zeroconf/python-zeroconf/commit/815ac77e9146c37afd7c5389ed45adee9f1e2e36)) + +* Dont recalculate the expiration and stale time every update + +I have a network with 170 devices running Zeroconf. Every minute +a zeroconf request for broadcast is cast out. Then we were listening for +Zeroconf devices on that network. + +To get a more realistic test, the Zeroconf ServiceBrowser is ran on +a separate thread from a main thread. On the main thread an I/O limited +call to QNetworkManager is made every 2 seconds, + +in order to include performance penalties due to thread switching. The +experiment was ran on a MacBook Air 2015 (Intel Core i7-5650U) through +Ubuntu 19.10 and Python 3.7. + +This was left running for exactly one hour, both before and after this commit. + +Before this commit, there were 132107499 (132M) calls to the +get_expiration_time function, totalling 141.647s (just over 2 minutes). + +After this commit, there were 1661203 (1.6M) calls to the +get_expiration_time function, totalling 2.068s. + +This saved about 2 minutes of processing time out of the total 60 minutes, +on average 3.88% processing power on the tested CPU. It is expected to see +similar improvements on all CPU architectures. ([`2e9699c`](https://github.com/python-zeroconf/python-zeroconf/commit/2e9699c542f691fc605e4a1c03cbf496273a9835)) + +* Significantly improve the speed of the entries function of the cache + +Tested this with Python 3.6.8, Fedora 28. This was done in a network with +a lot of discoverable devices. + +before: +Total time: 1.43086 s + +Line # Hits Time Per Hit % Time Line Contents +============================================================== + 1138 @profile + 1139 def entries(self): + 1140 """Returns a list of all entries""" + 1141 2063 3578.0 1.7 0.3 if not self.cache: + 1142 2 3.0 1.5 0.0 return [] + 1143 else: + 1144 # avoid size change during iteration by copying the cache + 1145 2061 22051.0 10.7 1.5 values = list(self.cache.values()) + 1146 2061 1405227.0 681.8 98.2 return reduce(lambda a, b: a + b, values) + +After: +Total time: 0.43725 s + +Line # Hits Time Per Hit % Time Line Contents +============================================================== + 1138 @profile + 1139 def entries(self): + 1140 """Returns a list of all entries""" + 1141 3651 10171.0 2.8 2.3 if not self.cache: + 1142 2 7.0 3.5 0.0 return [] + 1143 else: + 1144 # avoid size change during iteration by copying the cache + 1145 3649 67054.0 18.4 15.3 values = list(self.cache.values()) + 1146 3649 360018.0 98.7 82.3 return list(itertools.chain.from_iterable(values)) ([`157fc20`](https://github.com/python-zeroconf/python-zeroconf/commit/157fc2003318d785d07b362e1fd2ba3fe5d373f0)) + +* The the formatting of the IPv6 section in the readme ([`6ab7dbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6ab7dbf27a2086e20f4486e693e2091d043af1db)) + + +## v0.24.0 (2019-11-19) + +### Unknown + +* Release version 0.24.0 ([`f03dc42`](https://github.com/python-zeroconf/python-zeroconf/commit/f03dc42d6234419053bda18ca6f2b90bec1b9257)) + +* Improve type hint coverage ([`c827f9f`](https://github.com/python-zeroconf/python-zeroconf/commit/c827f9fdc4c58433143ea8815029c3387b500ff5)) + +* Add py.typed marker (closes #199) + +This required changing to a proper package. ([`41b31cb`](https://github.com/python-zeroconf/python-zeroconf/commit/41b31cb338e8a8a7d1a548662db70d9014e8a352)) + +* Link to the documentation ([`3db9d82`](https://github.com/python-zeroconf/python-zeroconf/commit/3db9d82d888abe880bfdd2fb2c3fe3eddcb48ae9)) + +* Setup basic Sphinx documentation + +Closes #200 ([`1c33e5f`](https://github.com/python-zeroconf/python-zeroconf/commit/1c33e5f5b44732d446d629cc13000cff3527afef)) + +* ENOTCONN is not an error during shutdown + +When `python-zeroconf` is used in conjunction with `eventlet`, `select.select()` will return with an error code equal to `errno.ENOTCONN` instead of `errno.EBADF`. As a consequence, an exception is shown in the console during shutdown. I believe that it should not cause any harm to treat `errno.ENOTCONN` the same way as `errno.EBADF` to prevent this exception. ([`c86423a`](https://github.com/python-zeroconf/python-zeroconf/commit/c86423ab0223bab682614e18a6a09050dfc80087)) + +* Rework exposing IPv6 addresses on ServiceInfo + +* Return backward compatibility for ServiceInfo.addresses by making + it return V4 addresses only +* Add ServiceInfo.parsed_addresses for convenient access to addresses +* Raise TypeError if addresses are not provided as bytes (otherwise + an ugly assertion error is raised when sending) +* Add more IPv6 unit tests ([`98a1ce8`](https://github.com/python-zeroconf/python-zeroconf/commit/98a1ce8b99ddb03de9f6cccca49396fcf177e0d0)) + +* Finish AAAA records support + +The correct record type was missing in a few places. Also use +addresses_by_version(All) in preparation for switching addresses +to V4 by default. ([`aae7fd3`](https://github.com/python-zeroconf/python-zeroconf/commit/aae7fd3ba851d1894732c4270cef745127cc03da)) + +* Test with pypy3.6 + +Right now this is available as pypy3 in Travis CI. Running black on PyPy +needs to be disabled for now because of an issue[1] that's been patched +only recently and it's not available in Travis yet. + +[1] https://bitbucket.org/pypy/pypy/issues/2985/pypy36-osreplace-pathlike-typeerror ([`fec839a`](https://github.com/python-zeroconf/python-zeroconf/commit/fec839ae4fdcb870066fff855809583dcf7d7a17)) + +* Stop specifying precise pypy3.5 version + +This allows us to test with the latest available one. ([`c2e8bde`](https://github.com/python-zeroconf/python-zeroconf/commit/c2e8bdebc6cec128d01197d53c3402278a4b62ed)) + +* Simplify Travis CI configuration regarding Python 3.7 + +Selecting xenial manually is no longer needed. ([`5359ea0`](https://github.com/python-zeroconf/python-zeroconf/commit/5359ea0a0b4cdca0854ae97c5d11036633102c67)) + +* Test with Python 3.8 ([`15118c8`](https://github.com/python-zeroconf/python-zeroconf/commit/15118c837a148a37edd29a20294e598ecf09c3cf)) + +* Make AAAA records work (closes #52) (#191) + +This PR incorporates changes from the earlier PR #179 (thanks to Mikael Pahmp), adding tests and a few more fixes to make AAAA records work in practice. + +Note that changing addresses to container IPv6 addresses may be considered a breaking change, for example, for consumers that unconditionally apply inet_aton to them. I'm introducing a new function to be able to retries only addresses from one family. ([`5bb9531`](https://github.com/python-zeroconf/python-zeroconf/commit/5bb9531be48f6f1e119643677c36d9e714204a8b)) + +* Improve static typing coverage ([`e5323d8`](https://github.com/python-zeroconf/python-zeroconf/commit/e5323d8c9795c59019173b8d202a50a49c415039)) + +* Add additional recommended records to PTR responses (#184) + +RFC6763 indicates a server should include the SRV/TXT/A/AAAA records +when responding to a PTR record request. This optimization ensures +the client doesn't have to then query for these additional records. + +It has been observed that when multiple Windows 10 machines are monitoring +for the same service, this unoptimized response to the PTR record +request can cause extremely high CPU usage in both the DHCP Client +& Device Association service (I suspect due to all clients having to +then sending/receiving the additional queries/responses). ([`ea64265`](https://github.com/python-zeroconf/python-zeroconf/commit/ea6426547f79c32c6d5d3bcc2d0a261bf503197a)) + +* Rename IpVersion to IPVersion + +A follow up to 3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8. ([`ceb602c`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb602c0d1bc1d3a269fd233b072a9b929076438)) + +* First stab at supporting listening on IPv6 interfaces + +This change adds basic support for listening on IPv6 interfaces. +Some limitations exist for non-POSIX platforms, pending fixes in +Python and in the ifaddr library. Also dual V4-V6 sockets may not +work on all BSD platforms. As a result, V4-only is used by default. + +Unfortunately, Travis does not seem to support IPv6, so the tests +are disabled on it, which also leads to coverage decrease. ([`3d5787b`](https://github.com/python-zeroconf/python-zeroconf/commit/3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8)) + + +## v0.23.0 (2019-06-04) + +### Unknown + +* Release version 0.23.0 ([`7bd0436`](https://github.com/python-zeroconf/python-zeroconf/commit/7bd04363c7ff0f583a17cc2fac42f9a9c1724769)) + +* Add support for multiple addresses when publishing a service (#170) + +This is a rebased and fixed version of PR #27, which also adds compatibility shim for ServiceInfo.address and does a proper deprecation for it. + +* Present all addresses that are available. + +* Add support for publishing multiple addresses. + +* Add test for backwards compatibility. + +* Provide proper deprecation of the "address" argument and field + +* Raise deprecation warnings when address is used +* Add a compatibility property to avoid breaking existing code + (based on suggestion by Bas Stottelaar in PR #27) +* Make addresses keyword-only, so that address can be eventually + removed and replaced with it without breaking consumers +* Raise TypeError instead of an assertion on conflicting address + and addresses + +* Disable black on ServiceInfo.__init__ until black is fixed + +Due to https://github.com/python/black/issues/759 black produces +code that is invalid Python 3.5 syntax even with --target-version py35. +This patch disables reformatting for this call (it doesn't seem to be +possible per line) until it's fixed. ([`c787610`](https://github.com/python-zeroconf/python-zeroconf/commit/c7876108150cd251786db4ab52dadd1b2283d262)) + +* Makefile: be specific which files to check with black (#169) + +Otherwise black tries to check the "env" directory, which fails. ([`6b85a33`](https://github.com/python-zeroconf/python-zeroconf/commit/6b85a333de21fa36187f081c3c115c8af40d7055)) + +* Run black --check as part of CI to enforce code style ([`12477c9`](https://github.com/python-zeroconf/python-zeroconf/commit/12477c954e7f051d10152f9ab970e28fd4222b30)) + +* Refactor the CI script a bit to make adding black check easier ([`69ad22c`](https://github.com/python-zeroconf/python-zeroconf/commit/69ad22cf852a12622f78aa2f4e7cf20c2d395db2)) + +* Reformat the code using Black + +We could use some style consistency in the project and Black looks like +the best tool for the job. + +Two flake8 errors are being silenced from now on: + +* E203 whitespace before : +* W503 line break before binary operator + +Both are to satisfy Black-formatted code (and W503 is somemwhat against +the latest PEP8 recommendations regarding line breaks and binary +operators in new code). ([`beb596c`](https://github.com/python-zeroconf/python-zeroconf/commit/beb596c345b0764bdfe1a828cfa744bcc560cf32)) + +* Add support for MyListener call getting updates to service TXT records (2nd attempt) (#166) + +Add support for MyListener call getting updates to service TXT records + +At the moment, the implementation supports notification to the ServiceListener class for additions and removals of service, but for service updates to the TXT record, the client must poll the ServiceInfo class. This draft PR provides a mechanism to have a callback on the ServiceListener class be invoked when the TXT record changes. ([`d4e06bc`](https://github.com/python-zeroconf/python-zeroconf/commit/d4e06bc54098bfa7a863bcc11bb9e2035738c8f5)) + +* Remove Python 3.4 from the Python compatibility section + +I forgot to do this in 4a02d0489da80e8b9e8d012bb7451cd172c753ca. ([`e1c2b00`](https://github.com/python-zeroconf/python-zeroconf/commit/e1c2b00c772a1538a6682c45884bbe89c8efba60)) + +* Drop Python 3.4 support (it's dead now) + +See https://devguide.python.org/#status-of-python-branches ([`4a02d04`](https://github.com/python-zeroconf/python-zeroconf/commit/4a02d0489da80e8b9e8d012bb7451cd172c753ca)) + + +## v0.22.0 (2019-04-27) + +### Unknown + +* Prepare release 0.22.0 ([`db1dcf6`](https://github.com/python-zeroconf/python-zeroconf/commit/db1dcf682e453766b53773d70c0091b81a87a192)) + +* Add arguments to set TTLs via ServiceInfo ([`ecc021b`](https://github.com/python-zeroconf/python-zeroconf/commit/ecc021b7a3cec863eed5a3f71a1f28e3026c25b0)) + +* Use recommended TTLs with overrides via ServiceInfo ([`a7aedb5`](https://github.com/python-zeroconf/python-zeroconf/commit/a7aedb58649f557a5e372fc776f98457ce84eb39)) + +* ttl: modify default used to respond to _services queries ([`f25989d`](https://github.com/python-zeroconf/python-zeroconf/commit/f25989d8cdae8f77e19eba70f236dd8103b33e8f)) + +* Fix service removal packets not being sent on shutdown ([`57310e1`](https://github.com/python-zeroconf/python-zeroconf/commit/57310e185a4f924dd257edd64f866da685a786c6)) + +* Adjust query intervals to match RFC 6762 (#159) + +* Limit query backoff time to one hour as-per rfc6762 section 5.2 +* tests: monkey patch backoff limit to focus testing on TTL expiry +* tests: speed up integration test +* tests: add test of query backoff interval and limit +* Set initial query interval to 1 second as-per rfc6762 sec 5.2 +* Add comments around timing constants +* tests: fix linting errors +* tests: fix float assignment to integer var + + +Sets the repeated query backoff limit to one hour as opposed to 20 seconds, reducing unnecessary network traffic +Adds a test for the behaviour of the backoff procedure +Sets the first repeated query to happen after one second as opposed to 500ms ([`bee8abd`](https://github.com/python-zeroconf/python-zeroconf/commit/bee8abdba49e2275d203e3b0b4a3afac330ec4ea)) + +* Turn on and address mypy check_untyped_defs ([`4218d75`](https://github.com/python-zeroconf/python-zeroconf/commit/4218d757994467ee710b0cad034ea1fb6035d3ea)) + +* Turn on and address mypy warn-return-any ([`006e614`](https://github.com/python-zeroconf/python-zeroconf/commit/006e614315c12e5232e6168ce0bacf0dc056ba8a)) + +* Turn on and address mypy no-implicit-optional ([`071c6ed`](https://github.com/python-zeroconf/python-zeroconf/commit/071c6edb924b6bc9b67859dc9860cfe09cc98d07)) + +* Add reminder to enable disallow_untyped_calls for mypy ([`24bb44f`](https://github.com/python-zeroconf/python-zeroconf/commit/24bb44f858cd325d7ff2892c53dc1dd9f26ed768)) + +* Enable some more mypy warnings ([`183a846`](https://github.com/python-zeroconf/python-zeroconf/commit/183a84636a9d4fec6306d065a4f855fec95086e4)) + +* Run mypy on test_zeroconf.py too + +This will reveal issues with current type hints as demonstrated by a +commit/issue to be submitted later, as well as prevent some others +from cropping up meanwhile. ([`74391d5`](https://github.com/python-zeroconf/python-zeroconf/commit/74391d5c124bf6f899059db93bbf7e99b96d8aad)) + +* Move mypy config to setup.cfg + +Removes need for a separate file, better to have more in one place. ([`2973931`](https://github.com/python-zeroconf/python-zeroconf/commit/29739319ccf71f48c06bc1b74cd193f17fb6b272)) + +* Don't bother with a universal wheel as we're Python >= 3 only ([`9c0f1ab`](https://github.com/python-zeroconf/python-zeroconf/commit/9c0f1ab03b90f87ff1d58278a0b9b77c16195185)) + +* Add unit tests for default ServiceInfo properties. ([`a12c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/a12c3b2a3b4300849e0a4dcdd4df5386286b88d3)) + +* Modify ServiceInfo's __init__ properties' default value. + +This commit modifies the default value of the argument properties of +ServiceInfo’s __init__() to byte array (properties=b’’). This enables +to instantiate it without setting the properties argument. As it is, +and because properties is not mandatory, if a user does not specify +the argument, an exception (AssertionError) is thrown: + +Traceback (most recent call last): + File "src/zeroconf-test.py", line 72, in + zeroconf.register_service(service) + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 1864, in register_service + self.send(out) + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 2091, in send + packet = out.packet() + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 1026, in packet + overrun_answers += self.write_record(answer, time_) + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 998, in write_record + record.write(self) + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 579, in write + out.write_string(self.text) + File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 903, in write_string + assert isinstance(value, bytes) +AssertionError + +The argument can be either a dictionary or a byte array. The function +_set_properties() will always create a byte array with the user's +properties. Changing the default value to a byte array, avoids the +conversion to byte array and avoids the exception. ([`9321007`](https://github.com/python-zeroconf/python-zeroconf/commit/93210079259bd0973e3b54a90dff971e14abf595)) + +* Fix some spelling errors ([`88fb0e3`](https://github.com/python-zeroconf/python-zeroconf/commit/88fb0e34f902498f6ceb583ce6fa9346745a14ca)) + +* Require flake8 >= 3.6.0, drop pycodestyle restriction + +Fixes current build breakage related to flake8 dependencies. + +The breakage: + +$ make flake8 +flake8 --max-line-length=110 examples *.py +Traceback (most recent call last): + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/pkg_resources/__init__.py", line 2329, in resolve + return functools.reduce(getattr, self.attrs, module) +AttributeError: module 'pycodestyle' has no attribute 'break_after_binary_operator' +During handling of the above exception, another exception occurred: +Traceback (most recent call last): + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 182, in load_plugin + self._load(verify_requirements) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 154, in _load + self._plugin = resolve() + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/pkg_resources/__init__.py", line 2331, in resolve + raise ImportError(str(exc)) +ImportError: module 'pycodestyle' has no attribute 'break_after_binary_operator' +During handling of the above exception, another exception occurred: +Traceback (most recent call last): + File "/home/travis/virtualenv/python3.5.6/bin/flake8", line 11, in + sys.exit(main()) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/cli.py", line 16, in main + app.run(argv) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 412, in run + self._run(argv) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 399, in _run + self.initialize(argv) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 381, in initialize + self.find_plugins() + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 197, in find_plugins + self.check_plugins.load_plugins() + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 434, in load_plugins + plugins = list(self.manager.map(load_plugin)) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 319, in map + yield func(self.plugins[name], *args, **kwargs) + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 432, in load_plugin + return plugin.load_plugin() + File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 189, in load_plugin + raise failed_to_load +flake8.exceptions.FailedToLoadPlugin: Flake8 failed to load plugin "pycodestyle.break_after_binary_operator" due to module 'pycodestyle' has no attribute 'break_after_binary_operator'. ([`73b3620`](https://github.com/python-zeroconf/python-zeroconf/commit/73b3620908cb5e2f54231692c17f6bbb8a42d09d)) + +* Drop flake8-blind-except + +Obsoleted by pycodestyle 2.1's E722. ([`e3b7e40`](https://github.com/python-zeroconf/python-zeroconf/commit/e3b7e40af52d05264794e2e4d37dfdb1c5d3814a)) + +* Test with PyPy 3.5 5.10.1 ([`51a6f70`](https://github.com/python-zeroconf/python-zeroconf/commit/51a6f7081bd5590ca5ea5418b39172714b7ef1fe)) + +* Fix a changelog typo ([`e08db28`](https://github.com/python-zeroconf/python-zeroconf/commit/e08db282edd8459e35d17ae4e7278106056a0c94)) + + +## v0.21.3 (2018-09-21) + +### Unknown + +* Prepare release 0.21.3 ([`059530d`](https://github.com/python-zeroconf/python-zeroconf/commit/059530d075fe1575ebbab535be67ac7d5ae7caed)) + +* Actually allow underscores in incoming service names + +This was meant to be released earlier, but I failed to merge part of my +patch. + +Fixes: ff4a262adc69 ("Allow underscores in incoming service names") +Closes #102 ([`ae3bd51`](https://github.com/python-zeroconf/python-zeroconf/commit/ae3bd517d84aae631db1cc294caf22541a7f4bd5)) + + +## v0.21.2 (2018-09-20) + +### Unknown + +* Prepare release 0.21.2 ([`af33c83`](https://github.com/python-zeroconf/python-zeroconf/commit/af33c83e72d6fa4171342f78d15b2f28038f1318)) + +* Fix typing-related TypeError + +Older typing versions don't allow what we did[1]. We don't really need +to be that precise here anyway. + +The error: + + $ python + Python 3.5.2 (default, Nov 23 2017, 16:37:01) + [GCC 5.4.0 20160609] on linux + Type "help", "copyright", "credits" or "license" for more information. + >>> import zeroconf + Traceback (most recent call last): + File "", line 1, in + File "/scraper/venv/lib/python3.5/site-packages/zeroconf.py", line 320, in + OptionalExcInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]] + File "/usr/lib/python3.5/typing.py", line 649, in __getitem__ + return Union[arg, type(None)] + File "/usr/lib/python3.5/typing.py", line 552, in __getitem__ + dict(self.__dict__), parameters, _root=True) + File "/usr/lib/python3.5/typing.py", line 512, in __new__ + for t2 in all_params - {t1} if not isinstance(t2, TypeVar)): + File "/usr/lib/python3.5/typing.py", line 512, in + for t2 in all_params - {t1} if not isinstance(t2, TypeVar)): + File "/usr/lib/python3.5/typing.py", line 1077, in __subclasscheck__ + if super().__subclasscheck__(cls): + File "/usr/lib/python3.5/abc.py", line 225, in __subclasscheck__ + for scls in cls.__subclasses__(): + TypeError: descriptor '__subclasses__' of 'type' object needs an argument + +Closes #141 +Fixes: 1f33c4f8a805 ("Introduce some static type analysis to the codebase") + +[1] https://github.com/python/typing/issues/266 ([`627c22e`](https://github.com/python-zeroconf/python-zeroconf/commit/627c22e19166c123244567410adc390ed368eca7)) + + +## v0.21.1 (2018-09-17) + +### Unknown + +* Prepare release 0.21.1 ([`1684a46`](https://github.com/python-zeroconf/python-zeroconf/commit/1684a46d57a437fc8cc7b5887d51440424c6ded5)) + +* Bringing back compatibility with python 3.4 (#140) + +The latest release of zeroconf in PyPI (0.21.0) breaks compatibility with python 3.4 due to an unstated dependency on the typing package. ([`919191c`](https://github.com/python-zeroconf/python-zeroconf/commit/919191ca266d8d589ad33cc6dd2c197f75092634)) + + +## v0.21.0 (2018-09-16) + +### Unknown + +* Prepare release 0.21.0 ([`b03cee3`](https://github.com/python-zeroconf/python-zeroconf/commit/b03cee348973469e9ebfce6e9b0e0a367c146401)) + +* Allow underscores in incoming service names + +There are real world cases of services broadcasting names with +underscores in them so tough luck, let's accept those to be compatible. +Registering service names with underscores in them continues to be +disallowed. + +Closes https://github.com/jstasiak/python-zeroconf/issues/102 ([`ff4a262`](https://github.com/python-zeroconf/python-zeroconf/commit/ff4a262adc6926905c71e2952b3159b84a974d02)) + +* Don't mention unsupported Python versions ([`208ec1b`](https://github.com/python-zeroconf/python-zeroconf/commit/208ec1ba58a6ebf7160a760feffe62cf366137e5)) + +* using ifaddr instead of netifaces as ifaddr is a pure python lib ([`7c0500e`](https://github.com/python-zeroconf/python-zeroconf/commit/7c0500ee19869ce0e85e58a26b8fdb0868e0b142)) + +* Show that we actually support Python 3.7 + +We can't just add Python 3.7 like earlier versions because Travis +doesn't support it at the moment[1]. + +[1] https://github.com/travis-ci/travis-ci/issues/9815 ([`418b4b8`](https://github.com/python-zeroconf/python-zeroconf/commit/418b4b814e6483a20a5cac2178a2cd815d5b91c0)) + +* Introduce some static type analysis to the codebase + +The main purpose of this change is to make the code easier to read and +explore. Preventing some classes of bugs is a bonus. + +On top of just adding type hints and enabling mypy to verify them the +following changes were needed: +* casts in places where we know what we're doing but mypy can't know it +* RecordUpdateListener interfaces extracted out of ServiceBrowser and + ServiceInfo classes so that we have a common name we can use in places + where we only need an instance of one of those classes to call to call + update_record() on it. This way we can keep mypy happy +* assert isinstance(...) blocks to provide hints for mypy as to what + concrete types we're dealing with +* some local type mixing removed (previously we'd first assign a value + of one type to a variable and then overwrite with another type) +* explicit "return None" in case of function that returns optionally - + mypy enforces explicit return in this case ([`1f33c4f`](https://github.com/python-zeroconf/python-zeroconf/commit/1f33c4f8a8050cdfb051c0da7ebe80a9ff24cf25)) + +* Fix a logging call + +The format string expects three parameters, one of them was accidentally +passed to the log_warning_once() method instead. + +Fixes: aa1f48433cbd ("Improve test coverage, and fix issues found") ([`23fdcce`](https://github.com/python-zeroconf/python-zeroconf/commit/23fdcce35fa020d09267e6fa57cf21cfb744a2c4)) + +* Fix UTF-8 multibyte name compression ([`e11700f`](https://github.com/python-zeroconf/python-zeroconf/commit/e11700ff9ea9eb429c701dfb73c4cf2c45994015)) + +* Remove some legacy cruft + +The latest versions of flake8 and flake8-import-order can be used just +fine now (they've been ok for some time). + +Since with google style flake8-import-order would generate more issues +than with the cryptography style I decided to switch and fix one thing +it complained about. + +We switch to pycodestyle instead of pinned pep8 version as that pep8 +version can't be installed with latest flake8 and the name of the +package has been changed to pycodestyle. We still pin the version though +as there's a bad interaction between the latest pycodestyle and the +latest flake8. ([`6fe8132`](https://github.com/python-zeroconf/python-zeroconf/commit/6fe813212f46576cf305c17ee815536a83128fce)) + +* Fix UnboundLocalError for count after loop + +This code throws an `UnboundLocalError` as `count` doesn't exist in the `else` branch of the for loop. ([`42c8662`](https://github.com/python-zeroconf/python-zeroconf/commit/42c866298725a8e9667bf1230be845e856cb382a)) + +* examples: Add an example of resolving a known service by service name + +To use: +* `avahi-publish-service -s 'My Service Name' _test._tcp 0` +* `./examples/resolver.py` should print a `ServiceInfo` +* Kill the `avahi-publish-service` process +* `./examples/resolver.py` should print `None` + +Signed-off-by: Simon McVittie ([`703d971`](https://github.com/python-zeroconf/python-zeroconf/commit/703d97150de1c74b7c1a62b59c1ff7081dec8256)) + +* Handle Interface Quirck to make it work on WSL (Windows Service for Linux) ([`374f45b`](https://github.com/python-zeroconf/python-zeroconf/commit/374f45b783caf35b26f464130fbd1ff62591af2e)) + +* Make some variables PEP 8-compatible + +Previously pep8-naming would complain about those: + +test_zeroconf.py:221:10: N806 variable 'numQuestions' in function should be lowercase + (numQuestions, numAnswers, numAuthorities, ([`49fc106`](https://github.com/python-zeroconf/python-zeroconf/commit/49fc1067245b2d3a7bcc1e7611f36ba8d9a36598)) + +* Fix flake8 (#131) + +* flake8 and therefore Travis should be happy now + +* attempt to fix flake8 + +* happy flake8 ([`53bc65a`](https://github.com/python-zeroconf/python-zeroconf/commit/53bc65af14ed979a5234bfa03c1295a2b27f6e40)) + +* implementing unicast support (#124) + +* implementing unicast support + +* use multicast=False for outgoing dns requests in unicast mode ([`826c961`](https://github.com/python-zeroconf/python-zeroconf/commit/826c9619797e4cf1f2c39b95ed1c93faed7eee2a)) + +* Remove unwanted whitespace ([`d0d1cfb`](https://github.com/python-zeroconf/python-zeroconf/commit/d0d1cfbb31f0ea6bd08b0c8ffa97ba3d7604bccc)) + +* Fix TTL handling for published service, align default TTL with RFC6762 (#113) + +Honor TTL passed in service registration +Set default TTL to 120 s as recommended by RFC6762 ([`14e3ad5`](https://github.com/python-zeroconf/python-zeroconf/commit/14e3ad5f15f5a0f5235ad7dbb22924b4b5ae1c77)) + +* add import error for Python <= 3.3 (#123) ([`fe62ba3`](https://github.com/python-zeroconf/python-zeroconf/commit/fe62ba31a8ab05a948ed6036dc319b1a1fa14e66)) + + +## v0.20.0 (2018-02-21) + +### Unknown + +* Release version 0.20.0 ([`0622570`](https://github.com/python-zeroconf/python-zeroconf/commit/0622570645116b0c45ee03d38b7b308be2026bd4)) + +* Add some missing release information ([`5978bdb`](https://github.com/python-zeroconf/python-zeroconf/commit/5978bdbdab017d06ea496ea6d7c66c672751b255)) + +* Drop support for Python 2 and 3.3 + +This simplifies the code slightly, reduces the number of dependencies +and otherwise speeds up the CI process. If someone *really* needs to use +really old Python they have the option of using older versions of the +package. ([`f22f421`](https://github.com/python-zeroconf/python-zeroconf/commit/f22f421e4e6bf1ca7671b1eb540ba09fbf1e04b1)) + +* Add license and readme file to source tarball (#108) + +Closes #97 ([`6ad04a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6ad04a5d7f6d63c1f48b5948b6ade0e56cafe258)) + +* Allow the usage of newer netifaces in development + +We're being consistent with c5e1f65c19b2f63a09b6517f322d600911fa1e13 +here. ([`7123f8e`](https://github.com/python-zeroconf/python-zeroconf/commit/7123f8ed7dfd9277245748271d8870f18299b035)) + +* Correct broken __eq__ in child classes to DNSRecord ([`4d6dd73`](https://github.com/python-zeroconf/python-zeroconf/commit/4d6dd73a8313b81bbfef8b074d6fe4878bce4f74)) + +* Refresh ServiceBrowser entries already when 'stale' +Updated integration testcase to test for this. ([`37c5211`](https://github.com/python-zeroconf/python-zeroconf/commit/37c5211980548ab701bba725feeb5395ed1af0a7)) + +* Add new records first in cache entry instead of last (#110) + +* Add new records first in cache entry instead of last + +* Added DNSCache unit test ([`8101b55`](https://github.com/python-zeroconf/python-zeroconf/commit/8101b557199c4d3d001c75a717eafa4d5544142f)) + + +## v0.19.1 (2017-06-13) + +### Unknown + +* Use more recent PyPy3 on Travis CI + +The default PyPy3 is really old (implements Python 3.2) and some +packages won't cooperate with it anymore. ([`d0e4712`](https://github.com/python-zeroconf/python-zeroconf/commit/d0e4712eaa696ff13470b719cb6842260a3ada11)) + +* Release version 0.19.1 ([`1541191`](https://github.com/python-zeroconf/python-zeroconf/commit/1541191090a92ef23b8e3747933c95f7233aa2de)) + +* Allow newer netifaces releases + +The bug that was concerning us[1] is fixed now. + +[1] https://bitbucket.org/al45tair/netifaces/issues/39/netmask-is-always-255255255255 ([`c5e1f65`](https://github.com/python-zeroconf/python-zeroconf/commit/c5e1f65c19b2f63a09b6517f322d600911fa1e13)) + + +## v0.19.0 (2017-03-21) + +### Unknown + +* Release version 0.19.0 ([`ecadb8c`](https://github.com/python-zeroconf/python-zeroconf/commit/ecadb8c30cd8e75da5b6d3e0e93d024f013dbfa2)) + +* Fix a whitespace issue flake8 doesn't like ([`87aa4e5`](https://github.com/python-zeroconf/python-zeroconf/commit/87aa4e587221e982902233ed2c8990ed27a2290f)) + +* Remove outdated example ([`d8686b5`](https://github.com/python-zeroconf/python-zeroconf/commit/d8686b5642d66b2c9ecc6f40b92e1a1a28279f79)) + +* Remove outdated comment ([`5aa6e85`](https://github.com/python-zeroconf/python-zeroconf/commit/5aa6e8546438d76b3fba5e91f9e4d4e3a3901757)) + +* Work around netifaces Windows netmask bug ([`6231d6d`](https://github.com/python-zeroconf/python-zeroconf/commit/6231d6d48d89240d95de9644570baf1b07ab04b0)) + + +## v0.18.0 (2017-02-03) + +### Unknown + +* Release version 0.18.0 ([`48b1949`](https://github.com/python-zeroconf/python-zeroconf/commit/48b19498724825237d3002ee7681b6296c625b12)) + +* Add a missing changelog entry ([`5343510`](https://github.com/python-zeroconf/python-zeroconf/commit/53435104d5fb29847ac561f58e16cb48dd97b9f8)) + +* Handle select errors when closing Zeroconf + +Based on a pull request by someposer[1] (code adapted to work on +Python 3). + +Fixes two pychromecast issues[2][3]. + +[1] https://github.com/jstasiak/python-zeroconf/pull/88 +[2] https://github.com/balloob/pychromecast/issues/59 +[3] https://github.com/balloob/pychromecast/issues/120 ([`6e229f2`](https://github.com/python-zeroconf/python-zeroconf/commit/6e229f2714c8aff6555dfee2bdff34bda980a0c3)) + +* Explicitly support Python 3.6 ([`0a5ea31`](https://github.com/python-zeroconf/python-zeroconf/commit/0a5ea31543941033bcb4b2cb76fa7e125cb33550)) + +* Pin flake8 because flake8-import-order is pinned ([`9f0d8fe`](https://github.com/python-zeroconf/python-zeroconf/commit/9f0d8fe87dedece1365149911ce9587482fe1501)) + +* Drop Python 2.6 support, no excuse to use 2.6 these days ([`56ea542`](https://github.com/python-zeroconf/python-zeroconf/commit/56ea54245eeab9d544d96c38d136f9f47eedcda4)) + + +## v0.17.7 (2017-02-01) + +### Unknown + +* Prepare the 0.17.7 release ([`376e011`](https://github.com/python-zeroconf/python-zeroconf/commit/376e011ad60c051f27632c77e6d50b64cf1defec)) + +* Merge pull request #77 from stephenrauch/fix-instance-name-with-dot + +Allow dots in service instance name ([`9035c6a`](https://github.com/python-zeroconf/python-zeroconf/commit/9035c6a246b6856b5087b1bba9a9f3ce5873fcda)) + +* Allow dots in service instance name ([`e46af83`](https://github.com/python-zeroconf/python-zeroconf/commit/e46af83d35b4430d4577481b371d569797427858)) + +* Merge pull request #75 from stephenrauch/Fix-name-change + +Fix for #29 ([`136dce9`](https://github.com/python-zeroconf/python-zeroconf/commit/136dce985fd66c81159d48b5f40e44349d1070ef)) + +* Fix/Implement duplicate name change (Issue 29) ([`788a48f`](https://github.com/python-zeroconf/python-zeroconf/commit/788a48f78466e048bdfc3028618bc4eaf807ef5b)) + +* some docs, cleanup and a couple of small test cases ([`b629ffb`](https://github.com/python-zeroconf/python-zeroconf/commit/b629ffb9c860a30366fa83b71487b546d6edd15b)) + +* Merge pull request #73 from stephenrauch/simplify-and-fix-pr-70 + +Simplify and fix PR 70 ([`6b67c0d`](https://github.com/python-zeroconf/python-zeroconf/commit/6b67c0d562866e63b81d1ec1c7f540c56244ade1)) + +* Simplify and fix PR 70 ([`2006cdd`](https://github.com/python-zeroconf/python-zeroconf/commit/2006cddf99377f43b528fbafea7d98be9d6282f0)) + +* Merge pull request #72 from stephenrauch/Catch-and-log-sendto-exceptions + +Catch and log sendto() exceptions ([`c3f563f`](https://github.com/python-zeroconf/python-zeroconf/commit/c3f563f6d108d46732a380b7912f8f5c23d5e548)) + +* Catch and log sendto() exceptions ([`0924310`](https://github.com/python-zeroconf/python-zeroconf/commit/0924310415b79f0fa2523494d8a60803ec295e09)) + +* Merge pull request #71 from stephenrauch/improved-test-coverage + +Improve test coverage, and fix issues found ([`254c207`](https://github.com/python-zeroconf/python-zeroconf/commit/254c2077f727d5e130aab2aaec111d58c134bd79)) + +* Improve test coverage, and fix issues found ([`aa1f484`](https://github.com/python-zeroconf/python-zeroconf/commit/aa1f48433cbd4dbf52565ec0c2635e5d52a37086)) + +* Merge pull request #70 from stephenrauch/Limit-size-of-packet + +Limit the size of the packet that can be built ([`208e221`](https://github.com/python-zeroconf/python-zeroconf/commit/208e2219a1268e637e3cf02e1838cb94a6de2f31)) + +* Limit the size of the packet that can be built ([`8355c85`](https://github.com/python-zeroconf/python-zeroconf/commit/8355c8556929fcdb777705c97fc99de6012367b4)) + +* Merge pull request #69 from stephenrauch/name-compression + +Help for oversized packets ([`5d9f40d`](https://github.com/python-zeroconf/python-zeroconf/commit/5d9f40de1a8549633cb5592fafc34d34df172965)) + +* Implement Name Compression ([`59877eb`](https://github.com/python-zeroconf/python-zeroconf/commit/59877ebb1b20ccd2747a0601e30329162ddcba4c)) + +* Drop oversized packets in send() ([`035605a`](https://github.com/python-zeroconf/python-zeroconf/commit/035605ab000fc8a8af94b4b9e1be9b81880b6bca)) + +* Add exception handler for oversized packets ([`af19c12`](https://github.com/python-zeroconf/python-zeroconf/commit/af19c12ec2286ee49e789a11599551dc43391383)) + +* Add QuietLogger mixin ([`0b77872`](https://github.com/python-zeroconf/python-zeroconf/commit/0b77872f7bb06ba6949c69bbfb70e8ae21f8ff9b)) + +* Improve service name validation error messages ([`fad66ca`](https://github.com/python-zeroconf/python-zeroconf/commit/fad66ca696530d39d8d5ae598e1724077eba8a5e)) + +* Merge pull request #68 from stephenrauch/Handle-dnsincoming-exceptions + +Handle DNSIncoming exceptions ([`6c0a32d`](https://github.com/python-zeroconf/python-zeroconf/commit/6c0a32d6e4bd7be0b7573b95a5325b19dfd509d2)) + +* Make all test cases localhost only ([`080d0c0`](https://github.com/python-zeroconf/python-zeroconf/commit/080d0c09f1e58d4f8c430dac513948e5919e3f3b)) + +* Handle DNS Incoming Exception + +This fixes a regression from removal of some overly broad exception +handling in 0.17.6. This change adds an explicit handler for +DNSIncoming(). Will also log at warn level the first time it sees a +particular parsing exception. ([`061a2aa`](https://github.com/python-zeroconf/python-zeroconf/commit/061a2aa3c6e8a7c954a313c8a7d396f26f544c2b)) + + +## v0.17.6 (2016-07-08) + +### Testing + +* test: added test for DNS-SD subtype discovery ([`914241b`](https://github.com/python-zeroconf/python-zeroconf/commit/914241b92c3097669e1e8c1a380f6c2f23a14cf8)) + +### Unknown + +* Fix readme to valid reStructuredText, ([`94570b7`](https://github.com/python-zeroconf/python-zeroconf/commit/94570b730aaab606db820b9c4d48b1c313fdaa98)) + +* Prepare release 0.17.6 ([`e168a6f`](https://github.com/python-zeroconf/python-zeroconf/commit/e168a6fa5486d92114fb02d4c40b36f8298a022f)) + +* Merge pull request #61 from stephenrauch/add-python3.5 + +Add python 3.5 to Travis ([`617d9fd`](https://github.com/python-zeroconf/python-zeroconf/commit/617d9fd0db5bef350eaebd13cfcc73803900ad24)) + +* Add python 3.5 to Travis ([`6198e89`](https://github.com/python-zeroconf/python-zeroconf/commit/6198e8909b968430ddac9261f4dd9c508d96db65)) + +* Merge pull request #60 from stephenrauch/delay_ServiceBrowser_connect + +Delay connecting ServiceBrowser() until it is running ([`56d9ac1`](https://github.com/python-zeroconf/python-zeroconf/commit/56d9ac13381a3ae205cb2b9339981a50f0a2eb62)) + +* Delay connecting ServiceBrowser() until it is running ([`6d1370c`](https://github.com/python-zeroconf/python-zeroconf/commit/6d1370cc2aa6d2c125aa924342e224b6b92ef8d9)) + +* Merge pull request #57 from herczy/master + +resolve issue #56: service browser initialization race ([`0225a18`](https://github.com/python-zeroconf/python-zeroconf/commit/0225a18957a26855720d7ab002f3983cb9d76e0e)) + +* resolve issue #56: service browser initialization race ([`1567016`](https://github.com/python-zeroconf/python-zeroconf/commit/15670161c597bc035c0e9411d0bb830b9520589f)) + +* Merge pull request #58 from strahlex/subtype-test + +added test for DNS-SD subtype discovery ([`4a569fe`](https://github.com/python-zeroconf/python-zeroconf/commit/4a569fe389d2fb5fd4b4f294ae9ebc0e38164e4a)) + +* Merge pull request #53 from stephenrauch/validate_service_names + +Validate service names ([`76a5e99`](https://github.com/python-zeroconf/python-zeroconf/commit/76a5e99f2e772a9462d0f4b3ab4c80f1b0a3b542)) + +* Service Name Validation + +This change validates service, instance and subtype names against +rfc6763. + +Also adds test code for subtypes and provides a fix for issue 37. ([`88fa059`](https://github.com/python-zeroconf/python-zeroconf/commit/88fa0595cd880b6d82ac8580512461e64eb32d6b)) + +* Test Case and fixes for DNSHInfo (#49) + +* Fix ability for a cache lookup to match properly + +When querying for a service type, the response is processed. During the +processing, an info lookup is performed. If the info is not found in +the cache, then a query is sent. Trouble is that the info requested is +present in the same packet that triggered the lookup, and a query is not +necessary. But two problems caused the cache lookup to fail. + +1) The info was not yet in the cache. The call back was fired before +all answers in the packet were cached. + +2) The test for a cache hit did not work, because the cache hit test +uses a DNSEntry as the comparison object. But some of the objects in +the cache are descendents of DNSEntry and have their own __eq__() +defined which accesses fields only present on the descendent. Thus the +test can NEVER work since the descendent's __eq__() will be used. + +Also continuing the theme of some other recent pull requests, add three +_GLOBAL_DONE tests to avoid doing work after the attempted stop, and +thus avoid generating (harmless, but annoying) exceptions during +shutdown + +* Remove unnecessary packet send in ServiceInfo.request() + +When performing an info query via request(), a listener is started, and +a packet is formed. As the packet is formed, known answers are taken +from the cache and placed into the packet. Then the packet is sent. +The packet is self received (via multicast loopback, I assume). At that +point the listener is fired and the answers in the packet are propagated +back to the object that started the request. This is a really long way +around the barn. + +The PR queries the cache directly in request() and then calls +update_record(). If all of the information is in the cache, then no +packet is formed or sent or received. This approach was taken because, +for whatever reason, the reception of the packets on windows via the +loopback was proving to be unreliable. The method has the side benefit +of being a whole lot faster. + +This PR also incorporates the joins() from PR #30. In addition it moves +the two joins() in close() to their own thread because they can take +quite a while to execute. + +* Fix locking race condition in Engine.run() + +This fixes a race condition in which the receive engine was waiting +against its condition variable under a different lock than the one it +used to determine if it needed to wait. This was causing the code to +sometimes take 5 seconds to do anything useful. + +When fixing the race condition, decided to also fix the other +correctness issues in the loop which was likely causing the errors that +led to the inclusion of the 'except Exception' catch all. This in turn +allowed the use of EBADF error due to closing the socket during exit to +be used to get out of the select in a timely manner. + +Finally, this allowed reorganizing the shutdown code to shutdown from +the front to the back. That is to say, shutdown the recv socket first, +which then allows a clean join with the engine thread. After the engine +thread exits most everything else is inert as all callbacks have been +unwound. + +* Remove a now invalid test case + +With the restructure of shutdown, Listener() now needs to throw EBADF on +a closed socket to allow a timely and graceful shutdown. + +* Shutdown the service listeners in an organized fashion + +Also adds names to the various threads to make debugging easier. + +* Improve test coverage + +Add more needed shutdown cleanup found via additional test coverage. + +Force timeout calculation from milli to seconds to use floating point. + +* init ServiceInfo._properties + +* Add query support and test case for _services._dns-sd._udp.local. + +* pep8 cleanup + +* Add testcase and fixes for HInfo Record Generation + +The DNSHInfo packet generation code was broken. There was no test case for that +functionality, and adding a test case showed four issues. Two of which were +relative to PY3 string, one of which was a typoed reference to an attribute, +and finally the two fields present in the HInfo record were using the wrong +encoding, which is what necessitated the change from write_string() to +write_character_string(). ([`6b39c70`](https://github.com/python-zeroconf/python-zeroconf/commit/6b39c70fa1ed7cfac89e02e2b3764a9038b87267)) + +* Merge pull request #48 from stephenrauch/Find-Service-Types + +Find service types ([`1dfc40f`](https://github.com/python-zeroconf/python-zeroconf/commit/1dfc40f4da145a55d60a952df90301ee0e5d65c4)) + +* Add query support and test case for _services._dns-sd._udp.local. ([`cfbb157`](https://github.com/python-zeroconf/python-zeroconf/commit/cfbb1572e44c4d8af1b50cb62abc0d426fc8e3ea)) + +* Merge pull request #45 from stephenrauch/master + +Multiple fixes to speed up querys and remove exceptions at shutdown ([`183cd81`](https://github.com/python-zeroconf/python-zeroconf/commit/183cd81d9274bf28c642314df2f9e32f1f60020b)) + +* init ServiceInfo._properties ([`d909942`](https://github.com/python-zeroconf/python-zeroconf/commit/d909942e2c9479819e9113ffb3a354b1d99d6814)) + +* Improve test coverage + +Add more needed shutdown cleanup found via additional test coverage. + +Force timeout calculation from milli to seconds to use floating point. ([`75232cc`](https://github.com/python-zeroconf/python-zeroconf/commit/75232ccf28a820ee723db072951078eba31145a5)) + +* Shutdown the service listeners in an organized fashion + +Also adds names to the various threads to make debugging easier. ([`ad3c248`](https://github.com/python-zeroconf/python-zeroconf/commit/ad3c248e4b67d5d2e9a4448a56b4e4648284ecd4)) + +* Remove a now invalid test case + +With the restructure of shutdown, Listener() now needs to throw EBADF on +a closed socket to allow a timely and graceful shutdown. ([`7bbee59`](https://github.com/python-zeroconf/python-zeroconf/commit/7bbee590e553a1ff0e4dde3b1fdcf614b7e1ecd5)) + +* Fix locking race condition in Engine.run() + +This fixes a race condition in which the receive engine was waiting +against its condition variable under a different lock than the one it +used to determine if it needed to wait. This was causing the code to +sometimes take 5 seconds to do anything useful. + +When fixing the race condition, decided to also fix the other +correctness issues in the loop which was likely causing the errors that +led to the inclusion of the 'except Exception' catch all. This in turn +allowed the use of EBADF error due to closing the socket during exit to +be used to get out of the select in a timely manner. + +Finally, this allowed reorganizing the shutdown code to shutdown from +the front to the back. That is to say, shutdown the recv socket first, +which then allows a clean join with the engine thread. After the engine +thread exits most everything else is inert as all callbacks have been +unwound. ([`8a110f5`](https://github.com/python-zeroconf/python-zeroconf/commit/8a110f58b02825100f5bdb56c119495ae42ae54c)) + +* Remove unnecessary packet send in ServiceInfo.request() + +When performing an info query via request(), a listener is started, and +a packet is formed. As the packet is formed, known answers are taken +from the cache and placed into the packet. Then the packet is sent. +The packet is self received (via multicast loopback, I assume). At that +point the listener is fired and the answers in the packet are propagated +back to the object that started the request. This is a really long way +around the barn. + +The PR queries the cache directly in request() and then calls +update_record(). If all of the information is in the cache, then no +packet is formed or sent or received. This approach was taken because, +for whatever reason, the reception of the packets on windows via the +loopback was proving to be unreliable. The method has the side benefit +of being a whole lot faster. + +This PR also incorporates the joins() from PR #30. In addition it moves +the two joins() in close() to their own thread because they can take +quite a while to execute. ([`c49145c`](https://github.com/python-zeroconf/python-zeroconf/commit/c49145c35de09b2631d8a2b4751d787a6b4dc904)) + +* Fix ability for a cache lookup to match properly + +When querying for a service type, the response is processed. During the +processing, an info lookup is performed. If the info is not found in +the cache, then a query is sent. Trouble is that the info requested is +present in the same packet that triggered the lookup, and a query is not +necessary. But two problems caused the cache lookup to fail. + +1) The info was not yet in the cache. The call back was fired before +all answers in the packet were cached. + +2) The test for a cache hit did not work, because the cache hit test +uses a DNSEntry as the comparison object. But some of the objects in +the cache are descendents of DNSEntry and have their own __eq__() +defined which accesses fields only present on the descendent. Thus the +test can NEVER work since the descendent's __eq__() will be used. + +Also continuing the theme of some other recent pull requests, add three +_GLOBAL_DONE tests to avoid doing work after the attempted stop, and +thus avoid generating (harmless, but annoying) exceptions during +shutdown ([`d8562fd`](https://github.com/python-zeroconf/python-zeroconf/commit/d8562fd3546d6cd27b1ba9e95105ea534649a43e)) + + +## v0.17.5 (2016-03-14) + +### Unknown + +* Prepare release 0.17.5 ([`f33b8f9`](https://github.com/python-zeroconf/python-zeroconf/commit/f33b8f9c182245b14b9b73a86aefedcee4520eb5)) + +* resolve issue #38: size change during iteration ([`fd9d531`](https://github.com/python-zeroconf/python-zeroconf/commit/fd9d531f294e7fa5b9b934f192b061f56eaf1d37)) + +* Installation on system with ASCII encoding + +The default open function in python2 made a best effort to open text files of any encoding. +After 3.0 the encoding has to be set correctly and it defaults to the user preferences. ([`6007537`](https://github.com/python-zeroconf/python-zeroconf/commit/60075379d57664f94fa41a96dea7c7c64489ef3d)) + +* Revert "Switch from netifaces to psutil" + +psutil doesn't seem to work on pypy3: + + Traceback (most recent call last): + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/failure.py", line 39, in runTest + raise self.exc_val.with_traceback(self.tb) + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/loader.py", line 414, in loadTestsFromName + addr.filename, addr.module) + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/importer.py", line 47, in importFromPath + return self.importFromDir(dir_path, fqname) + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/importer.py", line 94, in importFromDir + mod = load_module(part_fqname, fh, filename, desc) + File "/home/travis/build/jstasiak/python-zeroconf/test_zeroconf.py", line 17, in + import zeroconf as r + File "/home/travis/build/jstasiak/python-zeroconf/zeroconf.py", line 35, in + import psutil + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/__init__.py", line 62, in + from . import _pslinux as _psplatform + File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_pslinux.py", line 23, in + from . import _psutil_linux as cext + ImportError: unable to load extension module + '/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_psutil_linux.pypy3-24.so': + /home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_psutil_linux.pypy3-24.so: undefined symbol: PyModule_GetState + +Additionally netifaces turns out to be possible to install on Python 3, +therefore making it necessary to investigate the original issue. + +This reverts commit dd907f2eed3768a3c1e3889af84b5dbeb700a1e7. ([`6349d19`](https://github.com/python-zeroconf/python-zeroconf/commit/6349d197b442209331a0ff8676541967f7142991)) + +* fix issue #23 race-condition on ServiceBrowser startup ([`30bd44f`](https://github.com/python-zeroconf/python-zeroconf/commit/30bd44f04f94a9b26622a7213dd9950ae57df21c)) + +* Switch from netifaces to psutil + +netifaces installation on Python 3.x is broken and there doesn't seem to +be any plan to release a working version on PyPI, instead of using its +fork I decided to use another package providing the required +information. + +This closes https://github.com/jstasiak/python-zeroconf/issues/31 + +[1] https://bitbucket.org/al45tair/netifaces/issues/13/0104-install-is-broken-on-python-3x ([`dd907f2`](https://github.com/python-zeroconf/python-zeroconf/commit/dd907f2eed3768a3c1e3889af84b5dbeb700a1e7)) + +* Fix multicast TTL and LOOP options on OpenBSD + +IP_MULTICAST_TTL and IP_MULTICAST_LOOP socket options on OpenBSD don't +accept int, only unsigned char. Otherwise you will get an error: +[Errno 22] Invalid argument. ([`0f46a06`](https://github.com/python-zeroconf/python-zeroconf/commit/0f46a0609931e6dc299c0473312e434e84abe7b0)) + + +## v0.17.4 (2015-09-22) + +### Unknown + +* Prepare release 0.17.4 ([`0b9093d`](https://github.com/python-zeroconf/python-zeroconf/commit/0b9093de863928d7f13092aaf2be1f0a33f4ead2)) + +* Support kernel versions <3.9 + +added catch of OSError +added catch of socket.error for python2 ([`023426e`](https://github.com/python-zeroconf/python-zeroconf/commit/023426e0f8982640f46bca3dfcd3abeee2cb832f)) + +* Make it explicit who says what in the readme ([`ddb1048`](https://github.com/python-zeroconf/python-zeroconf/commit/ddb10485ef17aec3f37ef70dcb37af167271bfe1)) + + +## v0.17.3 (2015-08-19) + +### Unknown + +* Make the package's status explicit ([`f29c0f4`](https://github.com/python-zeroconf/python-zeroconf/commit/f29c0f475be76f70ecbb1586deb4618180dd1969)) + +* Prepare release 0.17.3 ([`9c3a81a`](https://github.com/python-zeroconf/python-zeroconf/commit/9c3a81af84c3450459795e5fc5142300f9680804)) + +* Add a DNSText __repr__ test -# 0.39.5 +The test helps making sure the situation fixed by +e8299c0527c965f83c1326b18e484652a9eb829c doesn't happen again. ([`c7567d6`](https://github.com/python-zeroconf/python-zeroconf/commit/c7567d6b065d7460e2022b8cde5dd0b52a3828a7)) - - This is a stub version to initialize python-semantic-release +* Fix DNSText repr Python 3 issue - This version will not be published +Prevents following exception: +``` + File "/Users/paulus/dev/python/netdisco/lib/python3.4/site-packages/zeroconf.py", line 412, in __repr__ + return self.to_string(self.text[:7] + "...") +TypeError: can't concat bytes to str +``` ([`e8299c0`](https://github.com/python-zeroconf/python-zeroconf/commit/e8299c0527c965f83c1326b18e484652a9eb829c)) -# 0.39.4 - - Fix IP changes being missed by ServiceInfo (\#1102) @bdraco +## v0.17.2 (2015-07-12) -# 0.39.3 +### Unknown - - Fix port changes not being seen by ServiceInfo (\#1100) @bdraco +* Release version 0.17.2 ([`d1ee5ce`](https://github.com/python-zeroconf/python-zeroconf/commit/d1ee5ce7558060ea8d92f804172f67f960f814bb)) -# 0.39.2 +* Fix a typo, meant strictly lesser than 0.6 :< ([`dadbbfc`](https://github.com/python-zeroconf/python-zeroconf/commit/dadbbfc9e1787561981807d3e008433a107c1e5e)) - - Performance improvements for parsing incoming packet data (\#1095) - (\#1097) @bdraco +* Restrict flake8-import-order version -# 0.39.1 +There seems to be a bug in 0.6.x, see +https://github.com/public/flake8-import-order/issues/42 ([`4435a2a`](https://github.com/python-zeroconf/python-zeroconf/commit/4435a2a4ae1c0b0877785f1a5047f65bb80a14bd)) - - Performance improvements for constructing outgoing packet data - (\#1090) @bdraco +* Use enum-compat instead of enum34 directly -# 0.39.0 +This is in order for the package's installation to work on Python 3.4+, +solves the same issue as +https://github.com/jstasiak/python-zeroconf/pull/22. ([`ba89455`](https://github.com/python-zeroconf/python-zeroconf/commit/ba894559f43fa6955989b92533c06fd8e8b92c74)) -Technically backwards incompatible: - - Switch to using async\_timeout for timeouts (\#1081) @bdraco - - Significantly reduces the number of asyncio tasks that are created - when using ServiceInfo or - AsyncServiceInfo +## v0.17.1 (2015-04-10) -# 0.38.7 +### Unknown - - Performance improvements for parsing incoming packet data (\#1076) - @bdraco +* Restrict pep8 version as something depends on it ([`4dbd04b`](https://github.com/python-zeroconf/python-zeroconf/commit/4dbd04b807813384108ff8e4cb5291c2560eed6b)) -# 0.38.6 +* Bump version to 0.17.1 ([`0b8936b`](https://github.com/python-zeroconf/python-zeroconf/commit/0b8936b94011c0783c7d0469b9ebae76cd4d1976)) - - Performance improvements for fetching ServiceInfo (\#1068) @bdraco +* Fix some typos in the readme ([`7c64ebf`](https://github.com/python-zeroconf/python-zeroconf/commit/7c64ebf6129fb6c0c533a1fed618c9d5926d5100)) -# 0.38.5 +* Update README.rst ([`44fa62a`](https://github.com/python-zeroconf/python-zeroconf/commit/44fa62a738335781ecdd789ad636f82e6542ecd2)) - - Fix ServiceBrowsers not getting ServiceStateChange.Removed callbacks - on PTR record expire (\#1064) @bdraco - - ServiceBrowsers were only getting a - ServiceStateChange.Removed callback - when the record was sent with a TTL of 0. ServiceBrowsers now - correctly get a - ServiceStateChange.Removed callback - when the record expires as well. +* Update README.rst ([`a22484a`](https://github.com/python-zeroconf/python-zeroconf/commit/a22484af90c7c4cbdee849d2b75efab2772c3592)) - - Fix missing minimum version of python 3.7 (\#1060) @stevencrader +* Getting an EADDRNOTAVAIL error when adding an address to the multicast group on windows. ([`93d34f9`](https://github.com/python-zeroconf/python-zeroconf/commit/93d34f925cd8913ff6836f9393cdce15679e4794)) -# 0.38.4 - - Fix IP Address updates when hostname is uppercase (\#1057) @bdraco - - ServiceBrowsers would not callback updates when the ip address - changed if the hostname contained uppercase characters +## v0.17.0 (2015-04-10) -# 0.38.3 +### Unknown -Version bump only, no changes from 0.38.2 +* Do 0.17.0 release ([`a6d75b3`](https://github.com/python-zeroconf/python-zeroconf/commit/a6d75b3d63a0c13c63473910b832e6db12635e79)) -# 0.38.2 +* Advertise pypy3 support ([`4783611`](https://github.com/python-zeroconf/python-zeroconf/commit/4783611de72ac11bdbfea9e4324e58746a91e70a)) - - Make decode errors more helpful in finding the source of the bad - data (\#1052) @bdraco +* Handle recent flake8 change ([`0009b5e`](https://github.com/python-zeroconf/python-zeroconf/commit/0009b5ea2bca77f395eb2bacc69d0dcfa5dd37dc)) -# 0.38.1 +* Describe recent changes ([`5c32a27`](https://github.com/python-zeroconf/python-zeroconf/commit/5c32a27a6ae0cccf7af25961cd98560a5173b065)) - - Improve performance of query scheduler (\#1043) @bdraco - - Avoid linear type searches in ServiceBrowsers (\#1044) @bdraco +* Add pypy3 build ([`a298785`](https://github.com/python-zeroconf/python-zeroconf/commit/a298785cf63d26b184495f972c619d31515a1468)) -# 0.38.0 +* Restore old listener interface (and example) for now ([`c748294`](https://github.com/python-zeroconf/python-zeroconf/commit/c748294fdc6f3bf527f62d4c0cb76ace32890128)) - - Handle Service types that end with another service type (\#1041) - @apworks1 +* Fix test breakage ([`b5fb3e8`](https://github.com/python-zeroconf/python-zeroconf/commit/b5fb3e86a688f6161c1292ccdffeec9f455c1fbd)) -Backwards incompatible: +* Prepare for new release ([`275a22b`](https://github.com/python-zeroconf/python-zeroconf/commit/275a22b997331d499526293b98faff11ca6edea5)) - - Dropped Python 3.6 support (\#1009) @bdraco +* Move self test example out of main module ([`ac5a63e`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5a63ece96fbf9d64e41e7a4867cc1d8b2f6b96)) -# 0.37.0 +* Fix using binary strings as property values -Technically backwards incompatible: +Previously it'd fall trough and set the value to False ([`b443027`](https://github.com/python-zeroconf/python-zeroconf/commit/b4430274ba8355ceaadc2d89a84752f1ac1485e7)) - - Adding a listener that does not inherit from RecordUpdateListener - now logs an error (\#1034) @bdraco +* Reformat a bit ([`2190818`](https://github.com/python-zeroconf/python-zeroconf/commit/219081860d28e49b1ae71a78e1a0da459689ab9c)) - - The NotRunningException exception is now thrown when Zeroconf is not - running (\#1033) @bdraco - - Before this change the consumer would get a timeout or an - EventLoopBlocked exception when calling - ServiceInfo.\*request when the - instance had already been shutdown or had failed to startup. +* Make examples' output quiet by default ([`08e0dc2`](https://github.com/python-zeroconf/python-zeroconf/commit/08e0dc2c7c1551ffa9a1e7297112b0f46b7ccc4e)) - - The EventLoopBlocked exception is now thrown when a coroutine times - out (\#1032) @bdraco - - Previously - concurrent.futures.TimeoutError would - have been raised instead. This is never expected to happen during - normal operation. +* Change ServiceBrowser interface experimentally ([`d162e54`](https://github.com/python-zeroconf/python-zeroconf/commit/d162e54c6aad175505028aa7beb8a1a0cb7a231d)) -# 0.36.13 +* Handle exceptions better ([`7cad7a4`](https://github.com/python-zeroconf/python-zeroconf/commit/7cad7a43179e3f547796b125e3ed8169ef3f4157)) - - Unavailable interfaces are now skipped during socket bind (\#1028) - @bdraco +* Add some debug logging ([`451c072`](https://github.com/python-zeroconf/python-zeroconf/commit/451c0729e2490ac6283010ddcbbcc723d86e6765)) - - Downgraded incoming corrupt packet logging to debug (\#1029) @bdraco - - Warning about network traffic we have no control over is confusing - to users as they think there is something wrong with zeroconf +* Make the code nicer -# 0.36.12 +This includes: - - Prevented service lookups from deadlocking if time abruptly moves - backwards (\#1006) @bdraco - - The typical reason time moves backwards is via an ntp update +* rearranging code to make it more readable +* catching KeyError instead of all exceptions and making it obvious what + can possibly raise there +* renaming things ([`df88670`](https://github.com/python-zeroconf/python-zeroconf/commit/df88670963e8c3a1f11a6af026b484ff4343d271)) -# 0.36.11 +* Remove redundant parentheses ([`3775c47`](https://github.com/python-zeroconf/python-zeroconf/commit/3775c47d8cf3c941603fa393265b86d05f61b915)) -No functional changes from 0.36.10. This release corrects an error in -the README.rst file that prevented the build from uploading to PyPI +* Make examples nicer and make them show all logs ([`193ee64`](https://github.com/python-zeroconf/python-zeroconf/commit/193ee64d6212ff9a814b76b13f9ef46676025dc3)) -# 0.36.10 +* Remove duplicates from all interfaces list - - scope\_id is now stripped from IPv6 addresses if given (\#1020) - @StevenLooman - - cpython 3.9 allows a suffix %scope\_id in IPv6Address. This caused - an error with the existing code if it was not stripped +It has been mentioned in GH #12 that the list of all machine's network +interfaces can contain duplicates; it shouldn't break anything but +there's no need to open multiple sockets in such case. ([`af5e363`](https://github.com/python-zeroconf/python-zeroconf/commit/af5e363e7fcb392081dc98915defd93c5002c3fc)) - - Optimized decoding labels from incoming packets (\#1019) @bdraco +* Don't fail when the netmask is unknown ([`463428f`](https://github.com/python-zeroconf/python-zeroconf/commit/463428ff8550a4f0e12b60e6f6a35efedca31271)) -# 0.36.9 +* Skip host only network interfaces - - Ensure ServiceInfo orders newest addresses first (\#1012) @bdraco - - This change effectively restored the behavior before 1s cache flush - expire behavior described in rfc6762 section 10.2 was added for - callers that rely on this. +On Ubuntu Linux treating such interface (network mask 255.255.255.255) +would result in: -# 0.36.8 +* EADDRINUSE "Address already in use" when trying to add multicast group + membership using IP_ADD_MEMBERSHIP +* success when setting the interface as outgoing multicast interface + using IP_MULTICAST_IF +* EINVAL "Invalid argument" when trying to send multicast datagram using + socket with that interface set as the multicast outgoing interface ([`b5e9e94`](https://github.com/python-zeroconf/python-zeroconf/commit/b5e9e944e6f3c990862b3b03831bb988579ed340)) - - Fixed ServiceBrowser infinite loop when zeroconf is closed before it - is canceled (\#1008) @bdraco +* Configure logging during the tests ([`0208228`](https://github.com/python-zeroconf/python-zeroconf/commit/0208228d8c760f3672954f5434c2ea54d7fd4196)) -# 0.36.7 +* Use all network interfaces by default ([`193cf47`](https://github.com/python-zeroconf/python-zeroconf/commit/193cf47a1144afc9158f0075a886c1f754d96f18)) - - Improved performance of responding to queries (\#994) (\#996) - (\#997) @bdraco - - Improved log message when receiving an invalid or corrupt packet - (\#998) @bdraco +* Ignore EADDRINUSE when appropriate -# 0.36.6 +On some systems it's necessary to do so ([`0f7c64f`](https://github.com/python-zeroconf/python-zeroconf/commit/0f7c64f8cdacae34c227edd5da4f445ece12da89)) - - Improved performance of sending outgoing packets (\#990) @bdraco +* Export Error and InterfaceChoice ([`500a76b`](https://github.com/python-zeroconf/python-zeroconf/commit/500a76bb1332fe34b45e681c767baddfbece4916)) -# 0.36.5 +* Fix ServiceInfo repr and text on Python 3 - - Reduced memory usage for incoming and outgoing packets (\#987) - @bdraco +Closes #1 ([`f3fd4cd`](https://github.com/python-zeroconf/python-zeroconf/commit/f3fd4cd69e9707221d8bd5ee6b3bb86b0985f604)) -# 0.36.4 +* Add preliminary support for mulitple net interfaces ([`442a599`](https://github.com/python-zeroconf/python-zeroconf/commit/442a59967f7b0f2d5c2ef512874ad2ab13dedae4)) - - Improved performance of constructing outgoing packets (\#978) - (\#979) @bdraco - - Deferred parsing of incoming packets when it can be avoided (\#983) - @bdraco +* Rationalize error handling when sending data ([`a0ee3d6`](https://github.com/python-zeroconf/python-zeroconf/commit/a0ee3d62db7b5350a21091e37824e187ebf99348)) -# 0.36.3 +* Make Zeroconf.socket private ([`78449ef`](https://github.com/python-zeroconf/python-zeroconf/commit/78449ef1e07dc68b63bb68038cb66f22e083fdfe)) - - Improved performance of parsing incoming packets (\#975) @bdraco +* Refactor Condition usage to use context manager interface ([`8d32fa4`](https://github.com/python-zeroconf/python-zeroconf/commit/8d32fa4b12e1b52d72a7ba9588437c4c787e0ffd)) -# 0.36.2 +* Use six for Python 2/3 compatibility ([`f0c3979`](https://github.com/python-zeroconf/python-zeroconf/commit/f0c39797869175cf88d76c75d39835abb2052f88)) - - Include NSEC records for non-existent types when responding with - addresses (\#972) (\#971) @bdraco Implements RFC6762 sec 6.2 - () +* Use six for Python 2/3 compatibility ([`54ed4b7`](https://github.com/python-zeroconf/python-zeroconf/commit/54ed4b79bb8de9523b5a5b74a79b01c8aa2291a7)) -# 0.36.1 - - - Skip goodbye packets for addresses when there is another service - registered with the same name (\#968) @bdraco - - If a ServiceInfo that used the same server name as another - ServiceInfo was unregistered, goodbye packets would be sent for the - addresses and would cause the other service to be seen as offline. - - - Fixed equality and hash for dns records with the unique bit (\#969) - @bdraco - - These records should have the same hash and equality since the - unique bit (cache flush bit) is not considered when adding or - removing the records from the cache. - -# 0.36.0 - -Technically backwards incompatible: - - - Fill incomplete IPv6 tuples to avoid WinError on windows (\#965) - @lokesh2019 - - Fixed \#932 - -# 0.35.1 +* Refactor version detection in the setup script - - Only reschedule types if the send next time changes (\#958) @bdraco - - When the PTR response was seen again, the timer was being canceled - and rescheduled even if the timer was for the same time. While this - did not cause any breakage, it is quite inefficient. +This doesn't depend on zeroconf module being importable when setup is +ran ([`1c2205d`](https://github.com/python-zeroconf/python-zeroconf/commit/1c2205d5c9b364a825d51acd03add4de91cb645a)) - - Cache DNS record and question hashes (\#960) @bdraco - - The hash was being recalculated every time the object was being used - in a set or dict. Since the hashes are effectively immutable, we - only calculate them once now. +* Drop "zero dependencies" feature ([`d8c1ec8`](https://github.com/python-zeroconf/python-zeroconf/commit/d8c1ec8ee13191e8ec4412770994f0676ace442c)) -# 0.35.0 +* Stop dropping multicast group membership - - Reduced chance of accidental synchronization of ServiceInfo requests - (\#955) @bdraco - - Sort aggregated responses to increase chance of name compression - (\#954) @bdraco - -Technically backwards incompatible: - - - Send unicast replies on the same socket the query was received - (\#952) @bdraco - - When replying to a QU question, we do not know if the sending host - is reachable from all of the sending sockets. We now avoid this - problem by replying via the receiving socket. This was the existing - behavior when InterfaceChoice.Default - is set. - - This change extends the unicast relay behavior to used with - InterfaceChoice.Default to apply when - InterfaceChoice.All or interfaces are - explicitly passed when instantiating a - Zeroconf instance. - - Fixes \#951 - -# 0.34.3 - - - Fix sending immediate multicast responses (\#949) @bdraco - -# 0.34.2 - - - Coalesce aggregated multicast answers (\#945) @bdraco - - When the random delay is shorter than the last scheduled response, - answers are now added to the same outgoing time group. - - This reduces traffic when we already know we will be sending a group - of answers inside the random delay window described in - datatracker.ietf.org/doc/html/rfc6762\#section-6.3 - - - Ensure ServiceInfo requests can be answered inside the default - timeout with network protection (\#946) @bdraco - - Adjust the time windows to ensure responses that have triggered the - protection against against excessive packet flooding due to software - bugs or malicious attack described in RFC6762 section 6 can respond - in under 1350ms to ensure ServiceInfo can ask two questions within - the default timeout of 3000ms - -# 0.34.1 - - - Ensure multicast aggregation sends responses within 620ms (\#942) - @bdraco - - Responses that trigger the protection against against excessive - packet flooding due to software bugs or malicious attack described - in RFC6762 section 6 could cause the multicast aggregation response - to be delayed longer than 620ms (The maximum random delay of 120ms - and 500ms additional for aggregation). - - Only responses that trigger the protection are delayed longer than - 620ms - -# 0.34.0 - - - Implemented Multicast Response Aggregation (\#940) @bdraco - - Responses are now aggregated when possible per rules in RFC6762 - section 6.4 - - Responses that trigger the protection against against excessive - packet flooding due to software bugs or malicious attack described - in RFC6762 section 6 are delayed instead of discarding as it was - causing responders that implement Passive Observation Of Failures - (POOF) to evict the records. - - Probe responses are now always sent immediately as there were cases - where they would fail to be answered in time to defend a name. - -# 0.33.4 - - - Ensure zeroconf can be loaded when the system disables IPv6 (\#933) - @che0 - -# 0.33.3 - - - Added support for forward dns compression pointers (\#934) @bdraco - - Provide sockname when logging a protocol error (\#935) @bdraco - -# 0.33.2 - - - Handle duplicate goodbye answers in the same packet (\#928) @bdraco - - Solves an exception being thrown when we tried to remove the known - answer from the cache when the second goodbye answer in the same - packet was processed - - Fixed \#926 - - - Skip ipv6 interfaces that return ENODEV (\#930) @bdraco - -# 0.33.1 - - - Version number change only with less restrictive directory - permissions - - Fixed \#923 - -# 0.33.0 - -This release eliminates all threading locks as all non-threadsafe -operations now happen in the event loop. - - - Let connection\_lost close the underlying socket (\#918) @bdraco - - The socket was closed during shutdown before asyncio's - connection\_lost handler had a chance to close it which resulted in - a traceback on windows. - - Fixed \#917 - -Technically backwards incompatible: - - - Removed duplicate unregister\_all\_services code (\#910) @bdraco - - Calling Zeroconf.close from same asyncio event loop zeroconf is - running in will now skip unregister\_all\_services and log a warning - as this a blocking operation and is not async safe and never has - been. - - Use AsyncZeroconf instead, or for legacy code call - async\_unregister\_all\_services before Zeroconf.close - -# 0.32.1 - - - Increased timeout in ServiceInfo.request to handle loaded systems - (\#895) @bdraco - - It can take a few seconds for a loaded system to run the - async\_request coroutine when the - event loop is busy, or the system is CPU bound (example being Home - Assistant startup). We now add an additional - \_LOADED\_SYSTEM\_TIMEOUT (10s) to - the run\_coroutine\_threadsafe calls - to ensure the coroutine has the total amount of time to run up to - its internal timeout (default of 3000ms). - - Ten seconds is a bit large of a timeout; however, it is only used in - cases where we wrap other timeouts. We now expect the only instance - the run\_coroutine\_threadsafe result - timeout will happen in a production circumstance is when someone is - running a ServiceInfo.request() in a - thread and another thread calls - Zeroconf.close() at just the right - moment that the future is never completed unless the system is so - loaded that it is nearly unresponsive. - - The timeout for - run\_coroutine\_threadsafe is the - maximum time a thread can cleanly shut down when zeroconf is closed - out in another thread, which should always be longer than the - underlying thread operation. - -# 0.32.0 - -This release offers 100% line and branch coverage. - - - Made ServiceInfo first question QU (\#852) @bdraco - - We want an immediate response when requesting with ServiceInfo by - asking a QU question; most responders will not delay the response - and respond right away to our question. This also improves - compatibility with split networks as we may not have been able to - see the response otherwise. If the responder has not multicast the - record recently, it may still choose to do so in addition to - responding via unicast - - Reduces traffic when there are multiple zeroconf instances running - on the network running ServiceBrowsers - - If we don't get an answer on the first try, we ask a QM question in - the event, we can't receive a unicast response for some reason - - This change puts ServiceInfo inline with ServiceBrowser which also - asks the first question as QU since ServiceInfo is commonly called - from ServiceBrowser callbacks - - - Limited duplicate packet suppression to 1s intervals (\#841) @bdraco - - Only suppress duplicate packets that happen within the same second. - Legitimate queriers will retry the question if they are suppressed. - The limit was reduced to one second to be in line with rfc6762 - - - Made multipacket known answer suppression per interface (\#836) - @bdraco - - The suppression was happening per instance of Zeroconf instead of - per interface. Since the same network can be seen on multiple - interfaces (usually and wifi and ethernet), this would confuse the - multi-packet known answer supression since it was not expecting to - get the same data more than once - - - New ServiceBrowsers now request QU in the first outgoing when - unspecified (\#812) @bdraco - - When we - start a ServiceBrowser and zeroconf has just started up, the known - answer list will be small. By asking a QU question first, it is - likely that we have a large known answer list by the time we ask the - QM question a second later (current default which is likely too low - but would be a breaking change to increase). This reduces the amount - of traffic on the network, and has the secondary advantage that most - responders will answer a QU question without the typical delay - answering QM questions. - - - IPv6 link-local addresses are now qualified with scope\_id (\#343) - @ibygrave - - When a service is advertised on an IPv6 address where the scope is - link local, i.e. fe80::/64 (see RFC 4007) the resolved IPv6 address - must be extended with the scope\_id that identifies through the "%" - symbol the local interface to be used when routing to that address. - A new API parsed\_scoped\_addresses() - is provided to return qualified addresses to avoid breaking - compatibility on the existing parsed\_addresses(). - - - Network adapters that are disconnected are now skipped (\#327) - @ZLJasonG - - - Fixed listeners missing initial packets if Engine starts too quickly - (\#387) @bdraco - - When manually creating a zeroconf.Engine object, it is no longer - started automatically. It must manually be started by calling - .start() on the created object. - - The Engine thread is now started after all the listeners have been - added to avoid a race condition where packets could be missed at - startup. - - - Fixed answering matching PTR queries with the ANY query (\#618) - @bdraco - - - Fixed lookup of uppercase names in the registry (\#597) @bdraco - - If the ServiceInfo was registered with an uppercase name and the - query was for a lowercase name, it would not be found and - vice-versa. - - - Fixed unicast responses from any source port (\#598) @bdraco - - Unicast responses were only being sent if the source port was 53, - this prevented responses when testing with dig: - - > dig -p 5353 @224.0.0.251 media-12.local - - The above query will now see a response - - - Fixed queries for AAAA records not being answered (\#616) @bdraco - - - Removed second level caching from ServiceBrowsers (\#737) @bdraco - - The ServiceBrowser had its own cache of the last time it saw a - service that was reimplementing the DNSCache and presenting a source - of truth problem that lead to unexpected queries when the two - disagreed. - - - Fixed server cache not being case-insensitive (\#731) @bdraco - - If the server name had uppercase chars and any of the matching - records were lowercase, and the server would not be found - - - Fixed cache handling of records with different TTLs (\#729) @bdraco - - There should only be one unique record in the cache at a time as - having multiple unique records will different TTLs in the cache can - result in unexpected behavior since some functions returned all - matching records and some fetched from the right side of the list to - return the newest record. Instead we now store the records in a dict - to ensure that the newest record always replaces the same unique - record, and we never have a source of truth problem determining the - TTL of a record from the cache. - - - Fixed ServiceInfo with multiple A records (\#725) @bdraco - - If there were multiple A records for the host, ServiceInfo would - always return the last one that was in the incoming packet, which - was usually not the one that was wanted. - - - Fixed stale unique records expiring too quickly (\#706) @bdraco - - Records now expire 1s in the future instead of instant removal. - - tools.ietf.org/html/rfc6762\#section-10.2 Queriers receiving a - Multicast DNS response with a TTL of zero SHOULD NOT immediately - delete the record from the cache, but instead record a TTL of 1 and - then delete the record one second later. In the case of multiple - Multicast DNS responders on the network described in Section 6.6 - above, if one of the responders shuts down and incorrectly sends - goodbye packets for its records, it gives the other cooperating - responders one second to send out their own response to "rescue" the - records before they expire and are deleted. - - - Fixed exception when unregistering a service multiple times (\#679) - @bdraco - - - Added an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to - zeroconf.asyncio (\#658) @bdraco - - - Fixed interface\_index\_to\_ip6\_address not skiping ipv4 adapters - (\#651) @bdraco - - - Added async\_unregister\_all\_services to AsyncZeroconf (\#649) - @bdraco - - - Fixed services not being removed from the registry when calling - unregister\_all\_services (\#644) @bdraco - - There was a race condition where a query could be answered for a - service in the registry, while goodbye packets which could result in - a fresh record being broadcast after the goodbye if a query came in - at just the right time. To avoid this, we now remove the services - from the registry right after we generate the goodbye packet - - - Fixed zeroconf exception on load when the system disables IPv6 - (\#624) @bdraco - - - Fixed the QU bit missing from for probe queries (\#609) @bdraco - - The bit should be set per - datatracker.ietf.org/doc/html/rfc6762\#section-8.1 - - - Fixed the TC bit missing for query packets where the known answers - span multiple packets (\#494) @bdraco - - - Fixed packets not being properly separated when exceeding maximum - size (\#498) @bdraco - - Ensure that questions that exceed the max packet size are moved to - the next packet. This fixes DNSQuestions being sent in multiple - packets in violation of: - datatracker.ietf.org/doc/html/rfc6762\#section-7.2 - - Ensure only one resource record is sent when a record exceeds - \_MAX\_MSG\_TYPICAL - datatracker.ietf.org/doc/html/rfc6762\#section-17 - - - Fixed PTR questions asked in uppercase not being answered (\#465) - @bdraco - - - Added Support for context managers in Zeroconf and AsyncZeroconf - (\#284) @shenek - - - Implemented an AsyncServiceBrowser to compliment the sync - ServiceBrowser (\#429) @bdraco - - - Added async\_get\_service\_info to AsyncZeroconf and async\_request - to AsyncServiceInfo (\#408) @bdraco - - - Implemented allowing passing in a sync Zeroconf instance to - AsyncZeroconf (\#406) @bdraco - - - Fixed IPv6 setup under MacOS when binding to "" (\#392) @bdraco - - - Fixed ZeroconfServiceTypes.find not always cancels the - ServiceBrowser (\#389) @bdraco - - There was a short window where the ServiceBrowser thread could be - left running after Zeroconf is closed because the .join() was never - waited for when a new Zeroconf object was created - - - Fixed duplicate packets triggering duplicate updates (\#376) @bdraco - - If TXT or SRV records update was already processed and then received - again, it was possible for a second update to be called back in the - ServiceBrowser - - - Fixed ServiceStateChange.Updated event happening for IPs that - already existed (\#375) @bdraco - - - Fixed RFC6762 Section 10.2 paragraph 2 compliance (\#374) @bdraco - - - Reduced length of ServiceBrowser thread name with many types (\#373) - @bdraco - - - Fixed empty answers being added in ServiceInfo.request (\#367) - @bdraco - - - Fixed ServiceInfo not populating all AAAA records (\#366) @bdraco - - Use get\_all\_by\_details to ensure all records are loaded into - addresses. - - Only load A/AAAA records from the cache once in load\_from\_cache if - there is a SRV record present - - Move duplicate code that checked if the ServiceInfo was complete - into its own function - - - Fixed a case where the cache list can change during iteration - (\#363) @bdraco - - - Return task objects created by AsyncZeroconf (\#360) @nocarryr - -Traffic Reduction: - - - Added support for handling QU questions (\#621) @bdraco - - Implements RFC 6762 sec 5.4: Questions Requesting Unicast Responses - datatracker.ietf.org/doc/html/rfc6762\#section-5.4 - - - Implemented protect the network against excessive packet flooding - (\#619) @bdraco - - - Additionals are now suppressed when they are already in the answers - section (\#617) @bdraco - - - Additionals are no longer included when the answer is suppressed by - known-answer suppression (\#614) @bdraco - - - Implemented multi-packet known answer supression (\#687) @bdraco - - Implements datatracker.ietf.org/doc/html/rfc6762\#section-7.2 - - - Implemented efficient bucketing of queries with known answers - (\#698) @bdraco - - - Implemented duplicate question suppression (\#770) @bdraco - - - -Technically backwards incompatible: - - - Update internal version check to match docs (3.6+) (\#491) @bdraco - - Python version earlier then 3.6 were likely broken with zeroconf - already, however, the version is now explicitly checked. - - - Update python compatibility as PyPy3 7.2 is required (\#523) @bdraco - -Backwards incompatible: - - - Drop oversize packets before processing them (\#826) @bdraco - - Oversized packets can quickly overwhelm the system and deny service - to legitimate queriers. In practice, this is usually due to broken - mDNS implementations rather than malicious actors. - - - Guard against excessive ServiceBrowser queries from PTR records - significantly lowerthan recommended (\#824) @bdraco - - We now enforce a minimum TTL for PTR records to avoid - ServiceBrowsers generating excessive queries refresh queries. Apple - uses a 15s minimum TTL, however, we do not have the same level of - rate limit and safeguards, so we use 1/4 of the recommended value. - - - RecordUpdateListener now uses async\_update\_records instead of - update\_record (\#419, \#726) @bdraco - - This allows the listener to receive all the records that have been - updated in a single transaction such as a packet or cache expiry. - - update\_record has been deprecated in favor of - async\_update\_records A compatibility shim exists to ensure classes - that use RecordUpdateListener as a base class continue to have - update\_record called, however, they should be updated as soon as - possible. - - A new method async\_update\_records\_complete is now called on each - listener when all listeners have completed processing updates and - the cache has been updated. This allows ServiceBrowsers to delay - calling handlers until they are sure the cache has been updated as - its a common pattern to call for ServiceInfo when a ServiceBrowser - handler fires. - - The async\_ prefix was chosen to make it clear that these functions - run in the eventloop and should never do blocking I/O. Before 0.32+ - these functions ran in a select() loop and should not have been - doing any blocking I/O, but it was not clear to implementors that - I/O would block the loop. - - - Pass both the new and old records to async\_update\_records (\#792) - @bdraco - - Pass the old\_record (cached) as the value and the new\_record - (wire) to async\_update\_records instead of forcing each consumer to - check the cache since we will always have the old\_record when - generating the async\_update\_records call. This avoids the overhead - of multiple cache lookups for each listener. - -# 0.31.0 - - - Separated cache loading from I/O in ServiceInfo and fixed cache - lookup (\#356), thanks to J. Nick Koston. - - The ServiceInfo class gained a load\_from\_cache() method to only - fetch information from Zeroconf cache (if it exists) with no IO - performed. Additionally this should reduce IO in cases where cache - lookups were previously incorrectly failing. - -# 0.30.0 - - - Some nice refactoring work including removal of the Reaper thread, - thanks to J. Nick Koston. - - Fixed a Windows-specific The requested address is not valid in its - context regression, thanks to Timothee ‘TTimo’ Besset and J. Nick - Koston. - - Provided an asyncio-compatible service registration layer (in the - zeroconf.asyncio module), thanks to J. Nick Koston. - -# 0.29.0 - - - A single socket is used for listening on responding when - InterfaceChoice.Default is chosen. - Thanks to J. Nick Koston. - -Backwards incompatible: - - - Dropped Python 3.5 support - -# 0.28.8 - - - Fixed the packet generation when multiple packets are necessary, - previously invalid packets were generated sometimes. Patch thanks to - J. Nick Koston. - -# 0.28.7 - - - Fixed the IPv6 address rendering in the browser example, thanks to - Alexey Vazhnov. - - Fixed a crash happening when a service is added or removed during - handle\_response and improved exception handling, thanks to J. Nick - Koston. - -# 0.28.6 - - - Loosened service name validation when receiving from the network - this lets us handle some real world devices previously causing - errors, thanks to J. Nick Koston. - -# 0.28.5 - - - Enabled ignoring duplicated messages which decreases CPU usage, - thanks to J. Nick Koston. - - Fixed spurious AttributeError: module 'unittest' has no attribute - 'mock' in tests. - -# 0.28.4 - - - Improved cache reaper performance significantly, thanks to J. Nick - Koston. - - Added ServiceListener to \_\_all\_\_ as it's part of the public API, - thanks to Justin Nesselrotte. +It'll be taken care of by socket being closed ([`f6425d1`](https://github.com/python-zeroconf/python-zeroconf/commit/f6425d1d727edfa124264bcabeffd77397809965)) -# 0.28.3 +* Remove dead code ([`88f5a51`](https://github.com/python-zeroconf/python-zeroconf/commit/88f5a5193ba2ab0eefc99481ccc6a1b911d8dbea)) - - Reduced a time an internal lock is held which should eliminate - deadlocks in high-traffic networks, thanks to J. Nick Koston. +* Stop using Zeroconf.group attribute ([`903cb78`](https://github.com/python-zeroconf/python-zeroconf/commit/903cb78d3ff7bc8762bf23910562b8f5042c2f85)) -# 0.28.2 +* Remove some unused methods ([`80e8e10`](https://github.com/python-zeroconf/python-zeroconf/commit/80e8e1008bc28c8ab9ca966b89109146112d0edd)) - - Stopped asking questions we already have answers for in cache, - thanks to Paul Daumlechner. - - Removed initial delay before querying for service info, thanks to - Erik Montnemery. +* Refactor exception handling here ([`4b8f68b`](https://github.com/python-zeroconf/python-zeroconf/commit/4b8f68b39230bb9cc3c202395b58cc822b8fe862)) -# 0.28.1 +* Update README.rst ([`8f18609`](https://github.com/python-zeroconf/python-zeroconf/commit/8f1860956ee9c86b7ba095fc1293919933e1c0ad)) - - Fixed a resource leak connected to using ServiceBrowser with - multiple types, thanks to - 10. Nick Koston. +* Release as 0.16.0 ([`4e54b67`](https://github.com/python-zeroconf/python-zeroconf/commit/4e54b6738a490dcc7d2f9e7e1040c5da53727155)) -# 0.28.0 +* Tune logging ([`05c3c02`](https://github.com/python-zeroconf/python-zeroconf/commit/05c3c02044d2b4bff946e00803d0ddb2619f0927)) - - Improved Windows support when using socket errno checks, thanks to - Sandy Patterson. - - Added support for passing text addresses to ServiceInfo. - - Improved logging (includes fixing an incorrect logging call) - - Improved Windows compatibility by using Adapter.index from ifaddr, - thanks to PhilippSelenium. - - Improved Windows compatibility by stopping using - socket.if\_nameindex. - - Fixed an OS X edge case which should also eliminate a memory leak, - thanks to Emil Styrke. +* Migrate from clazz to class_ ([`4a67e12`](https://github.com/python-zeroconf/python-zeroconf/commit/4a67e124cd8f8c4d19f8c6c4a455d075bb948362)) -Technically backwards incompatible: +* Migrate more camel case names to snake case ([`92e4713`](https://github.com/python-zeroconf/python-zeroconf/commit/92e47132dc761a9a722caec261ae53de1785838f)) - - `ifaddr` 0.1.7 or newer is required now. +* Switch to snake case and clean up import order -## 0.27.1 +Closes #2 ([`5429748`](https://github.com/python-zeroconf/python-zeroconf/commit/5429748190950a5daf7e9cf91de824dfbd06ee7a)) - - Improved the logging situation (includes fixing a false-positive - "packets() made no progress adding records", thanks to Greg Badros) +* Rationalize exception handling a bit and setup logging ([`ada563c`](https://github.com/python-zeroconf/python-zeroconf/commit/ada563c5a1f6d7c54f2ae5c495503079c395438f)) -## 0.27.0 +* Update README.rst ([`47ff62b`](https://github.com/python-zeroconf/python-zeroconf/commit/47ff62bae1fd69ffd953c82bd480e4770bfee97b)) - - Large multi-resource responses are now split into separate packets - which fixes a bad mdns-repeater/ChromeCast Audio interaction ending - with ChromeCast Audio crash (and possibly some others) and improves - RFC 6762 compliance, thanks to Greg Badros - - Added a warning presented when the listener passed to ServiceBrowser - lacks update\_service() callback - - Added support for finding all services available in the browser - example, thanks to Perry Kunder +* Update README.rst ([`b290965`](https://github.com/python-zeroconf/python-zeroconf/commit/b290965ecd589ca4feb1f88a4232d1ec2725dc44)) -Backwards incompatible: +* Create universal wheels ([`bf97c14`](https://github.com/python-zeroconf/python-zeroconf/commit/bf97c1459a9d91d6aa88d7bf34c5f8b4cd3cedc5)) - - Removed previously deprecated ServiceInfo address constructor - parameter and property -## 0.26.3 +## v0.15.1 (2014-07-10) - - Improved readability of logged incoming data, thanks to Erik - Montnemery - - Threads are given unique names now to aid debugging, thanks to Erik - Montnemery - - Fixed a regression where get\_service\_info() called within a - listener add\_service method would deadlock, timeout and incorrectly - return None, fix thanks to Erik Montnemery, but Matt Saxon and - Hmmbob were also involved in debugging it. +### Unknown -## 0.26.2 +* Bump version to 0.15.1 ([`9e81863`](https://github.com/python-zeroconf/python-zeroconf/commit/9e81863de37e2ab972d5a76a1dc2d5c517f83cc6)) - - Added support for multiple types to ServiceBrowser, thanks to J. - Nick Koston - - Fixed a race condition where a listener gets a message before the - lock is created, thanks to - 10. Nick Koston +* Update README.rst ([`161743e`](https://github.com/python-zeroconf/python-zeroconf/commit/161743ea387c961d3554488239f93df4b39be18c)) -## 0.26.1 +* Add coverage badge to the readme ([`8502a7e`](https://github.com/python-zeroconf/python-zeroconf/commit/8502a7e1c9770a42e44b4f1beb34c887212e7d48)) - - Fixed a performance regression introduced in 0.26.0, thanks to J. - Nick Koston (this is close in spirit to an optimization made in - 0.24.5 by the same author) +* Send coverage to coveralls ([`1d90a9f`](https://github.com/python-zeroconf/python-zeroconf/commit/1d90a9f91f87753a1ea649ce5da1bc6a7da4013d)) -## 0.26.0 +* Fix socket.error handling - - Fixed a regression where service update listener wasn't called on IP - address change (it's called on SRV/A/AAAA record changes now), - thanks to Matt Saxon +This closes #4 ([`475e80b`](https://github.com/python-zeroconf/python-zeroconf/commit/475e80b90e96364a183c63f09fa3858f34aa3646)) -Technically backwards incompatible: +* Add test_coverage make target ([`89531e6`](https://github.com/python-zeroconf/python-zeroconf/commit/89531e641f15b24a60f9fb2e9f71a7aa8450363a)) - - Service update hook is no longer called on service addition (service - added hook is still called), this is related to the fix above +* Add PyPI version badge to the readme ([`4c852d4`](https://github.com/python-zeroconf/python-zeroconf/commit/4c852d424d07925ae01c24a51ffc36ecae49b48d)) -## 0.25.1 +* Refactor integration test to use events ([`922eab0`](https://github.com/python-zeroconf/python-zeroconf/commit/922eab05596b72d141d459e83146a4cdb6c84389)) - - Eliminated 5s hangup when calling Zeroconf.close(), thanks to Erik - Montnemery +* Fix readme formatting ([`7b23734`](https://github.com/python-zeroconf/python-zeroconf/commit/7b23734356f85ccaa6ca66ffaeea8484a2d45d3d)) -## 0.25.0 +* Update README.rst ([`83fd618`](https://github.com/python-zeroconf/python-zeroconf/commit/83fd618328aff29892c71f9ba5b9ff983fe4a202)) - - Reverted uniqueness assertions when browsing, they caused a - regression +* Refactor browser example ([`8328aed`](https://github.com/python-zeroconf/python-zeroconf/commit/8328aed1444781b6fac854eb722ae0fef14a3cc4)) -Backwards incompatible: +* Update README.rst ([`49af263`](https://github.com/python-zeroconf/python-zeroconf/commit/49af26350390484bc6f4b66dab4f6b004040cd4a)) - - Rationalized handling of TXT records. Non-bytes values are converted - to str and encoded to bytes using UTF-8 now, None values mean - value-less attributes. When receiving TXT records no decoding is - performed now, keys are always bytes and values are either bytes or - None in value-less attributes. +* Bump version to 0.15 ([`77bcadd`](https://github.com/python-zeroconf/python-zeroconf/commit/77bcaddbd1964fb0b494e98ec3ae6d66ea42c509)) -## 0.24.5 +* Add myself to authors ([`b9f886b`](https://github.com/python-zeroconf/python-zeroconf/commit/b9f886bf2815c86c7004e123146293c48ea68f1e)) - - Fixed issues with shared records being used where they shouldn't be - (TXT, SRV, A records are unique now), thanks to Matt Saxon - - Stopped unnecessarily excluding host-only interfaces from - InterfaceChoice.all as they don't forbid multicast, thanks to - Andreas Oberritter - - Fixed repr() of IPv6 DNSAddress, thanks to Aldo Hoeben - - Removed duplicate update messages sent to listeners, thanks to Matt - Saxon - - Added support for cooperating responders, thanks to Matt Saxon - - Optimized handle\_response cache check, thanks to J. Nick Koston - - Fixed memory leak in DNSCache, thanks to J. Nick Koston +* Reuse one Zeroconf instance in browser example ([`1ee00b3`](https://github.com/python-zeroconf/python-zeroconf/commit/1ee00b318eab386b709351ffae81c8293f4e6d4d)) -## 0.24.4 +* Update README.rst ([`fba4215`](https://github.com/python-zeroconf/python-zeroconf/commit/fba4215be1804a13e454e609ed6df2cf98e149f2)) - - Fixed resetting TTL in DNSRecord.reset\_ttl(), thanks to Matt Saxon - - Improved various DNS class' string representations, thanks to Jay - Hogg +* Update README.rst ([`c7bfe63`](https://github.com/python-zeroconf/python-zeroconf/commit/c7bfe63f9a7eff9a1ede0ac63a329a316d3192ab)) -## 0.24.3 +* Rename examples ([`3502198`](https://github.com/python-zeroconf/python-zeroconf/commit/3502198768062b49564121b48a792ce5e7b7b288)) - - Fixed import-time "TypeError: 'ellipsis' object is not iterable." on - CPython 3.5.2 +* Refactor examples ([`2ce95f5`](https://github.com/python-zeroconf/python-zeroconf/commit/2ce95f52e7a02c7f1113ba7ebee3c89babb9a26e)) -## 0.24.2 +* Update README.rst ([`6a7cd31`](https://github.com/python-zeroconf/python-zeroconf/commit/6a7cd3197ee6ae5690b29b6543fc86d1b1a420d8)) - - Added support for AWDL interface on macOS (needed and used by the - opendrop project but should be useful in general), thanks to Milan - Stute - - Added missing type hints +* Advertise Python 3 support ([`d330918`](https://github.com/python-zeroconf/python-zeroconf/commit/d330918970d719d6b26a3f81e83dbb8b8adac0a4)) -## 0.24.1 +* Update README.rst ([`6aae20e`](https://github.com/python-zeroconf/python-zeroconf/commit/6aae20e1c1bef8413573139d62d3d2b889fe8776)) - - Applied some significant performance optimizations, thanks to Jaime - van Kessel for the patch and to Ghostkeeper for performance - measurements - - Fixed flushing outdated cache entries when incoming record is - unique, thanks to Michael Hu - - Fixed handling updates of TXT records (they'd not get recorded - previously), thanks to Michael Hu +* Move examples to examples directory ([`c83891c`](https://github.com/python-zeroconf/python-zeroconf/commit/c83891c9dd2f20e8dee44f1b412a536d20cbcbe3)) -## 0.24.0 +* Fix regression introduced with Python 3 compat ([`0a0f7e0`](https://github.com/python-zeroconf/python-zeroconf/commit/0a0f7e0e72d7f9ed08231d94b66ff44bcff60151)) - - Added IPv6 support, thanks to Dmitry Tantsur - - Added additional recommended records to PTR responses, thanks to - Scott Mertz - - Added handling of ENOTCONN being raised during shutdown when using - Eventlet, thanks to Tamás Nepusz - - Included the py.typed marker in the package so that type checkers - know to use type hints from the source code, thanks to Dmitry - Tantsur +* Mark threads as daemonic (at least for now) ([`b8cfc79`](https://github.com/python-zeroconf/python-zeroconf/commit/b8cfc7996941afded5c9c7e7903378279590b20f)) -## 0.23.0 +* Update README.rst ([`cd7ca98`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7ca98010044eb965bc988c23a8be59e09eb69a)) - - Added support for MyListener call getting updates to service TXT - records, thanks to Matt Saxon - - Added support for multiple addresses when publishing a service, - getting/setting single address has become deprecated. Change thanks - to Dmitry Tantsur +* Add Python 3 support ([`9a99aa7`](https://github.com/python-zeroconf/python-zeroconf/commit/9a99aa727f4e041a726aed3736c0a8ab625c4cb6)) -Backwards incompatible: +* Update README.rst ([`09a1f4f`](https://github.com/python-zeroconf/python-zeroconf/commit/09a1f4f9d76f64cc8c85f0525e05bdac53de210c)) - - Dropped Python 3.4 support +* Update README.rst ([`6feec34`](https://github.com/python-zeroconf/python-zeroconf/commit/6feec3459d2561f00402d627ea91a8a4981ad309)) -## 0.22.0 +* Tune package description ([`b819174`](https://github.com/python-zeroconf/python-zeroconf/commit/b8191741d4ef8e347f6dd138fa48da5aec9b6549)) - - A lot of maintenance work (tooling, typing coverage and - improvements, spelling) done, thanks to Ville Skyttä - - Provided saner defaults in ServiceInfo's constructor, thanks to - Jorge Miranda - - Fixed service removal packets not being sent on shutdown, thanks to - Andrew Bonney - - Added a way to define TTL-s through ServiceInfo contructor - parameters, thanks to Andrew Bonney +* Gitignore build/ ([`0ef1b0d`](https://github.com/python-zeroconf/python-zeroconf/commit/0ef1b0d3481b68a752efe822ff4e9ce8356bcffa)) -Technically backwards incompatible: +* Add setup.py ([`916bd38`](https://github.com/python-zeroconf/python-zeroconf/commit/916bd38ddb48a959c597ae1763193b4c2c74334f)) - - Adjusted query intervals to match RFC 6762, thanks to Andrew Bonney - - Made default TTL-s match RFC 6762, thanks to Andrew Bonney +* Update README.rst ([`35eced3`](https://github.com/python-zeroconf/python-zeroconf/commit/35eced310fbe1782fd87eb33e7f4befcb0a78499)) -## 0.21.3 +* Run actual tests on Travis ([`f8cea82`](https://github.com/python-zeroconf/python-zeroconf/commit/f8cea82177cea3577d2b4f70fec32e85229abdce)) - - This time really allowed incoming service names to contain - underscores (patch released as part of 0.21.0 was defective) +* Advertise Python 2.6 and PyPy support ([`43b182c`](https://github.com/python-zeroconf/python-zeroconf/commit/43b182cce40bcb21eb1e052a0bc42bf367a963ca)) -## 0.21.2 +* Move readme to README.rst ([`fd3401e`](https://github.com/python-zeroconf/python-zeroconf/commit/fd3401efb55ae91324d12ba80affd2f3b3ebcf5e)) - - Fixed import-time typing-related TypeError when older typing version - is used +* Move readme to README.rst ([`353b700`](https://github.com/python-zeroconf/python-zeroconf/commit/353b700df79b49c49db62e0a6e6eb0eae3ccb444)) -## 0.21.1 +* Stop catching BaseExceptions ([`41a013c`](https://github.com/python-zeroconf/python-zeroconf/commit/41a013c8a051b3f80018f37d4f254263cc890a68)) - - Fixed installation on Python 3.4 (we use typing now but there was no - explicit dependency on it) +* Set up Travis build ([`a2a6125`](https://github.com/python-zeroconf/python-zeroconf/commit/a2a6125dd03d9a810dac72163d545e413387217b)) -## 0.21.0 +* PEP8ize and clean up ([`e2964ed`](https://github.com/python-zeroconf/python-zeroconf/commit/e2964ed48263e72159e95cb0691af0dcb9ba498b)) - - Added an error message when importing the package using unsupported - Python version - - Fixed TTL handling for published service - - Implemented unicast support - - Fixed WSL (Windows Subsystem for Linux) compatibility - - Fixed occasional UnboundLocalError issue - - Fixed UTF-8 multibyte name compression - - Switched from netifaces to ifaddr (pure Python) - - Allowed incoming service names to contain underscores +* Updated for 0.14. ([`83aa0f3`](https://github.com/python-zeroconf/python-zeroconf/commit/83aa0f3803cdf79470f4a754c7b9ab616544eea1)) -## 0.20.0 +* Although SOL_IP is considered more correct here, it's undefined on some +systems, where IPPROTO_IP is available. (Both equate to 0.) Reported by +Mike Erdely. ([`443aca8`](https://github.com/python-zeroconf/python-zeroconf/commit/443aca867d694432d466d20bdf7c49ebc7a4e684)) - - Dropped support for Python 2 (this includes PyPy) and 3.3 - - Fixed some class' equality operators - - ServiceBrowser entries are being refreshed when 'stale' now - - Cache returns new records first now instead of last +* Obsolete comment. ([`eee7196`](https://github.com/python-zeroconf/python-zeroconf/commit/eee7196626773eae2dc0dc1a68de03a99d778139)) -## 0.19.1 +* Really these should be network order. ([`5e10a20`](https://github.com/python-zeroconf/python-zeroconf/commit/5e10a20a9cb6bbc09356cbf957f3f7fa3e169ff2)) - - Allowed installation with netifaces \>= 0.10.6 (a bug that was - concerning us got fixed) +* Docstrings for examples; shorter timeout; struct.unpack() vs. ord(). ([`0884d6a`](https://github.com/python-zeroconf/python-zeroconf/commit/0884d6a56afc6fb559b6c90a923762393187e50a)) -## 0.19.0 +* Make examples executable. ([`5e5e78e`](https://github.com/python-zeroconf/python-zeroconf/commit/5e5e78e27240e7e03d1c8aa96ee0e1f7877d0d5d)) - - Technically backwards incompatible - restricted netifaces dependency - version to work around a bug, see - for details +* Unneeded. ([`2ac738f`](https://github.com/python-zeroconf/python-zeroconf/commit/2ac738f84bbcf29d03bad289cb243182ecdf48d6)) -## 0.18.0 +* getText() is redundant with getProperties(). ([`a115187`](https://github.com/python-zeroconf/python-zeroconf/commit/a11518726321b15059be255b6329cba591887197)) - - Dropped Python 2.6 support - - Improved error handling inside code executed when Zeroconf object is - being closed +* Allow graceful exit from announcement test. ([`0f3b413`](https://github.com/python-zeroconf/python-zeroconf/commit/0f3b413b269f8b95b6f8073ba39d11f156ae632c)) -## 0.17.7 +* More readable display in browser; automatically quit after giving ten +seconds to respond. ([`eee4530`](https://github.com/python-zeroconf/python-zeroconf/commit/eee4530d7b8216338634282f3097cb96932aa28e)) - - Better Handling of DNS Incoming Packets parsing exceptions - - Many exceptions will now log a warning the first time they are seen - - Catch and log sendto() errors - - Fix/Implement duplicate name change - - Fix overly strict name validation introduced in 0.17.6 - - Greatly improve handling of oversized packets including: - - Implement name compression per RFC1035 - - Limit size of generated packets to 9000 bytes as per RFC6762 - - Better handle over sized incoming packets - - Increased test coverage to 95% +* New names, numbers. ([`2a000c5`](https://github.com/python-zeroconf/python-zeroconf/commit/2a000c589302147129eed990c842b38ac61f7514)) -## 0.17.6 +* Updated FSF address. ([`4e39602`](https://github.com/python-zeroconf/python-zeroconf/commit/4e396025ed666775973d54a50b69e8f635e28658)) - - Many improvements to address race conditions and exceptions during - ZC() startup and shutdown, thanks to: morpav, veawor, justingiorgi, - herczy, stephenrauch - - Added more test coverage: strahlex, stephenrauch - - Stephen Rauch contributed: - - Speed up browser startup - - Add ZeroconfServiceTypes() query class to discover all - advertised service types - - Add full validation for service names, types and subtypes - - Fix for subtype browsing - - Fix DNSHInfo support +* De-DOSification. ([`1dc3436`](https://github.com/python-zeroconf/python-zeroconf/commit/1dc3436e6357b66d0bb53f9b285f123b164984da)) -## 0.17.5 +* Lowercase imports. ([`e292868`](https://github.com/python-zeroconf/python-zeroconf/commit/e292868f9c7e817cb04dfce2d545f45db4041e5e)) - - Fixed OpenBSD compatibility, thanks to Alessio Sergi - - Fixed race condition on ServiceBrowser startup, thanks to gbiddison - - Fixed installation on some Python 3 systems, thanks to Per Sandström - - Fixed "size change during iteration" bug on Python 3, thanks to - gbiddison +* The great lowercasing. ([`5541813`](https://github.com/python-zeroconf/python-zeroconf/commit/5541813fbb8e1d7b233d09ee2d20ac0ca322a9f2)) -## 0.17.4 +* Renamed tests. ([`4bb88b0`](https://github.com/python-zeroconf/python-zeroconf/commit/4bb88b0952833b84c15c85190c0a9cac01922cbe)) - - Fixed support for Linux kernel versions \< 3.9 (thanks to Giovanni - Harting and Luckydonald, GitHub pull request \#26) +* Replaced unwrapped "lgpl.txt" with traditional "COPYING". ([`ad6b1ec`](https://github.com/python-zeroconf/python-zeroconf/commit/ad6b1ecf9fa71a5ec14f7a08fc3d6a689a19e6d2)) -## 0.17.3 +* Don't need range() here. ([`b36e7d5`](https://github.com/python-zeroconf/python-zeroconf/commit/b36e7d5dd5922b1739911878b29aba921ec9ecb6)) - - Fixed DNSText repr on Python 3 (it'd crash when the text was longer - than 10 bytes), thanks to Paulus Schoutsen for the patch, GitHub - pull request \#24 +* testNumbersAnswers() was identical to testNumbersQuestions(). +(Presumably it was intended to test addAnswer() instead...) ([`416054d`](https://github.com/python-zeroconf/python-zeroconf/commit/416054d407013af8678928b949d6579df4044d46)) -## 0.17.2 +* Extraneous spaces. ([`f6615a9`](https://github.com/python-zeroconf/python-zeroconf/commit/f6615a9d7632f3510d2f0a36cab155ac753141ab)) - - Fixed installation on Python 3.4.3+ (was failing because of enum34 - dependency which fails to install on 3.4.3+, changed to depend on - enum-compat instead; thanks to Michael Brennan for the original - patch, GitHub pull request \#22) +* Moved history to README; updated version number, etc. ([`015bae2`](https://github.com/python-zeroconf/python-zeroconf/commit/015bae258b5ce73a2a12361e4c9295107126963c)) -## 0.17.1 +* Meaningless. ([`6147a6e`](https://github.com/python-zeroconf/python-zeroconf/commit/6147a6ed20222851ba4438dd65366f907b4c189f)) - - Fixed EADDRNOTAVAIL when attempting to use dummy network interfaces - on Windows, thanks to daid +* Also unexceptional. ([`c36e3af`](https://github.com/python-zeroconf/python-zeroconf/commit/c36e3af2f6e0ea857f383f9b014f50b65fca641c)) -## 0.17.0 +* If name isn't in self.names, it's unexceptional. (And yes, I actually +tested, and this is faster.) ([`f772d4e`](https://github.com/python-zeroconf/python-zeroconf/commit/f772d4e5e208431378bf01d75eddc7df5119dff7)) - - Added some Python dependencies so it's not zero-dependencies anymore - - Improved exception handling (it'll be quieter now) - - Messages are listened to and sent using all available network - interfaces by default (configurable); thanks to Marcus Müller - - Started using logging more freely - - Fixed a bug with binary strings as property values being converted - to False (); - thanks to Dr. Seuss - - Added new `ServiceBrowser` event handler interface (see the - examples) - - PyPy3 now officially supported - - Fixed ServiceInfo repr on Python 3, thanks to Yordan Miladinov +* Excess spaces; don't use "len" as a label. After eblot. ([`df986ee`](https://github.com/python-zeroconf/python-zeroconf/commit/df986eed46e3ec7dadc6604d0b26e4fcf0b6291a)) -## 0.16.0 +* Outdated docs. ([`21d7c95`](https://github.com/python-zeroconf/python-zeroconf/commit/21d7c950f50827bc8ac6dd18fb0577c11b5cefac)) - - Set up Python logging and started using it - - Cleaned up code style (includes migrating from camel case to snake - case) +* Untab the test programs. ([`c13e4fa`](https://github.com/python-zeroconf/python-zeroconf/commit/c13e4fab3b0b95674fbc93cd2ac30fd2ba462a24)) -## 0.15.1 +* Remove the comment about the test programs. ([`8adab79`](https://github.com/python-zeroconf/python-zeroconf/commit/8adab79a64a73e76841b37e53e55fe8aad8eb580)) - - Fixed handling closed socket (GitHub \#4) +* Allow for the failure of getServiceInfo(). Not sure why it's happening, +though. ([`0a05f42`](https://github.com/python-zeroconf/python-zeroconf/commit/0a05f423ad591454a25c515d811556d10e5fc99f)) -## 0.15 +* Don't test for NonLocalNameException, since I killed it. ([`d89ddfc`](https://github.com/python-zeroconf/python-zeroconf/commit/d89ddfcecc7b336aa59a4ff784cb8b810772d24f)) - - Forked by Jakub Stasiak - - Made Python 3 compatible - - Added setup script, made installable by pip and uploaded to PyPI - - Set up Travis build - - Reformatted the code and moved files around - - Stopped catching BaseException in several places, that could hide - errors - - Marked threads as daemonic, they won't keep application alive now +* Describe this fork. ([`656f959`](https://github.com/python-zeroconf/python-zeroconf/commit/656f959c26310629953cc661ffad681194295131)) -## 0.14 +* Write only a byte. ([`d346107`](https://github.com/python-zeroconf/python-zeroconf/commit/d34610768812906ff07974c1314f6073b431d96e)) - - Fix for SOL\_IP undefined on some systems - thanks Mike Erdely. - - Cleaned up examples. - - Lowercased module name. +* Although beacons _should_ fit within single packets, maybe we should allow for the possibility that they won't? (Or, does this even make sense with sendto()?) ([`ac91642`](https://github.com/python-zeroconf/python-zeroconf/commit/ac91642b0ea90a3c84b605e19d562b897e2cd1fd)) -## 0.13 +* Update the version to indicate a fork. ([`a81f3ab`](https://github.com/python-zeroconf/python-zeroconf/commit/a81f3ababc585acca4bacc51a832703286ec5cfb)) - - Various minor changes; see git for details. - - No longer compatible with Python 2.2. Only tested with 2.5-2.7. - - Fork by William McBrine. +* HHHHHH -> 6H ([`9a94953`](https://github.com/python-zeroconf/python-zeroconf/commit/9a949532484a55e52f1d2f14eb27277a5133ce29)) -## 0.12 +* In Zeroconf, use the same method of determining the default IP as elsewhere, instead of the unreliable gethostbyname(gethostname()) method (but fall back to that). ([`f6d4731`](https://github.com/python-zeroconf/python-zeroconf/commit/f6d47316a47d9d04539f1a4215dd7eec06c33d4c)) - - allow selection of binding interface - - typo fix - Thanks A. M. Kuchlingi - - removed all use of word 'Rendezvous' - this is an API change +* More again. ([`2420505`](https://github.com/python-zeroconf/python-zeroconf/commit/24205054309e110238fc5a986cdc27b17c44abef)) -## 0.11 +* More. ([`b8baed3`](https://github.com/python-zeroconf/python-zeroconf/commit/b8baed3a2876c126cac65a7d95bb88661b31483c)) - - correction to comments for addListener method - - support for new record types seen from OS X - - IPv6 address - - hostinfo - - ignore unknown DNS record types - - fixes to name decoding - - works alongside other processes using port 5353 (e.g. on Mac OS X) - - tested against Mac OS X 10.3.2's mDNSResponder - - corrections to removal of list entries for service browser +* Minor style things for Zeroconf (use True/False instead of 1/0, etc.). ([`173350e`](https://github.com/python-zeroconf/python-zeroconf/commit/173350e415e66c9629d553f820677453bdbe5724)) -## 0.10 +* Clearer. ([`3e718b5`](https://github.com/python-zeroconf/python-zeroconf/commit/3e718b55becd883324bf40eda700431b302a0da8)) - - Jonathon Paisley contributed these corrections: - - always multicast replies, even when query is unicast - - correct a pointer encoding problem - - can now write records in any order - - traceback shown on failure - - better TXT record parsing - - server is now separate from name - - can cancel a service browser - - modified some unit tests to accommodate these changes +* 80-column fixes for Zeroconf. ([`e5d930b`](https://github.com/python-zeroconf/python-zeroconf/commit/e5d930bb681f5544827fc0c9f37daa778dec5930)) -## 0.09 +* Minor simplification of the pack/unpack routines in Zeroconf. ([`e814dd1`](https://github.com/python-zeroconf/python-zeroconf/commit/e814dd1e6848d8c7ec03660d347ea4a34390c37d)) - - remove all records on service unregistration - - fix DOS security problem with readName +* Skip unknown resource records in Zeroconf -- https://bugs.launchpad.net/pyzeroconf/+bug/498411 ([`488de88`](https://github.com/python-zeroconf/python-zeroconf/commit/488de8826ddd58646358900d057a4a1632492948)) -## 0.08 +* Some people are reporting bogus data coming back from Zeroconf scans, causing exceptions. ([`fe77e37`](https://github.com/python-zeroconf/python-zeroconf/commit/fe77e371cc68ea211508908e6180867c420ca042)) - - changed licensing to LGPL +* Don't need the string module here. ([`f76529c`](https://github.com/python-zeroconf/python-zeroconf/commit/f76529c685868dcdb62b6477f15ecb1122310cc5)) -## 0.07 +* Suppress EBADF errors in Zeroconf.py. ([`4c8aac9`](https://github.com/python-zeroconf/python-zeroconf/commit/4c8aac95613df62d001bd7192ec75247a2bb9b9d)) - - faster shutdown on engine - - pointer encoding of outgoing names - - ServiceBrowser now works - - new unit tests +* This doesn't seem to be necessary, and it's generating a lot of exceptions... ([`f80df7b`](https://github.com/python-zeroconf/python-zeroconf/commit/f80df7b0f8b9124970e109c51f7a49b7bd75906c)) -## 0.06 +* Untab Zeroconf. ([`892a4f0`](https://github.com/python-zeroconf/python-zeroconf/commit/892a4f095c23379a6cf5a0ef31521f9f90cb5276)) - - small improvements with unit tests - - added defined exception types - - new style objects - - fixed hostname/interface problem - - fixed socket timeout problem - - fixed add\_service\_listener() typo bug - - using select() for socket reads - - tested on Debian unstable with Python 2.2.2 +* has_key() is deprecated. ([`f998e39`](https://github.com/python-zeroconf/python-zeroconf/commit/f998e39cbb8d2c5556c10203957ff6a9ab2f546d)) -## 0.05 +* The initial version I committed to HME for Python back in 2008. This is +a step back in some respects (re-inserting tabs that will be undone a +couple patches hence), so that I can apply the patches going forward. ([`d952a9c`](https://github.com/python-zeroconf/python-zeroconf/commit/d952a9c117ae539cf4778d76618fe813b10a9a34)) - - ensure case insensitivty on domain names - - support for unicast DNS queries +* Remove the executable bit. ([`f0d095d`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d095d0f1c2767be6da47f885f5ed019e9fa363)) -## 0.04 +* Removed pyc file ([`38d0a18`](https://github.com/python-zeroconf/python-zeroconf/commit/38d0a184c13772dae3c14d3c46a30c68497c54db)) - - added some unit tests - - added \_\_ne\_\_ adjuncts where required - - ensure names end in '.local.' - - timeout on receiving socket for clean shutdown +* First commit ([`c3a39f8`](https://github.com/python-zeroconf/python-zeroconf/commit/c3a39f874a5c10e91ee2315271f13ae74ee381fd)) diff --git a/pyproject.toml b/pyproject.toml index 7bd2960ff..4a9ca6833 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.135.0" +version = "0.136.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 58bda33d4..ec3a682f2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.135.0" +__version__ = "0.136.0" __license__ = "LGPL" From 857e7423dee3e3e6e2c5d3049a47ddb28a5e6cfb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:39:08 -0600 Subject: [PATCH 1121/1433] chore(deps-dev): bump setuptools from 75.2.0 to 75.3.0 (#1437) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1c99cd116..f32c8580f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -320,23 +320,23 @@ pytest = ">=7.0.0" [[package]] name = "setuptools" -version = "75.2.0" +version = "75.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, - {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, + {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, + {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, ] [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] [[package]] name = "tomli" From 06637f4c3b848d939b7a8e83c1204d96f60174bf Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 15 Nov 2024 16:40:20 +0100 Subject: [PATCH 1122/1433] chore(pre-commit): reenable codespell and disable false positives (#1432) --- .github/workflows/ci.yml | 6 +++--- .pre-commit-config.yaml | 8 ++++---- pyproject.toml | 4 +--- tests/services/test_browser.py | 2 +- tests/services/test_info.py | 10 +++++----- tests/test_asyncio.py | 2 +- tests/test_core.py | 4 ++-- tests/test_handlers.py | 22 +++++++++++----------- tests/test_protocol.py | 6 +++--- 9 files changed, 31 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2359c4202..309707e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.9" - - uses: pre-commit/action@v2.0.3 + python-version: "3.12" + - uses: pre-commit/action@v3.0.1 # Make sure commit messages follow the conventional commits convention: # https://www.conventionalcommits.org diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b50394d7..61669a36b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,10 +44,10 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - # - repo: https://github.com/codespell-project/codespell - # rev: v2.2.1 - # hooks: - # - id: codespell + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: diff --git a/pyproject.toml b/pyproject.toml index 4a9ca6833..42b9ee0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,4 @@ requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.8', "poetry-core>=1.5.2" build-backend = "poetry.core.masonry.api" [tool.codespell] -skip = '*.po,*.ts,./tests,./bench' -count = '' -quiet-level = 3 +ignore-words-list = ["additionals", "HASS"] diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index dc9b14353..0afc5ebc2 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -770,7 +770,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): @pytest.mark.asyncio async def test_asking_qm_questions(): - """Verify explictly asking QM questions.""" + """Verify explicitly asking QM questions.""" type_ = "_quservice._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 9d4a4958f..9a5cbb7d1 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -82,7 +82,7 @@ def test_service_info_rejects_non_matching_updates(self): service_server, addresses=[service_address], ) - # Verify backwards compatiblity with calling with None + # Verify backwards compatibility with calling with None info.async_update_records(zc, now, []) # Matching updates info.async_update_records( @@ -572,7 +572,7 @@ def get_service_info_helper(zc, type, name): helper_thread.start() wait_time = 1 - # Expext query for SRV, TXT, A, AAAA + # Expect query for SRV, TXT, A, AAAA send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 4 @@ -582,7 +582,7 @@ def get_service_info_helper(zc, type, name): assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None - # Expext no further queries + # Expect no further queries last_sent = None send_event.clear() _inject_response( @@ -1006,7 +1006,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): def test_asking_qu_questions(): - """Verify explictly asking QU questions.""" + """Verify explicitly asking QU questions.""" type_ = "_quservice._tcp.local." zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) @@ -1030,7 +1030,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): def test_asking_qm_questions(): - """Verify explictly asking QM questions.""" + """Verify explicitly asking QM questions.""" type_ = "_quservice._tcp.local." zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index a765a50a4..54a8b400c 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1331,7 +1331,7 @@ async def test_legacy_unicast_response(run_isolated): protocol.datagram_received(query.packets()[0], ("127.0.0.1", 6503)) calls = send_mock.mock_calls - # Verify the response is sent back on the socket it was recieved from + # Verify the response is sent back on the socket it was received from assert calls == [call(ANY, "127.0.0.1", 6503, (), protocol.transport)] outgoing = send_mock.call_args[0][0] assert isinstance(outgoing, DNSOutgoing) diff --git a/tests/test_core.py b/tests/test_core.py index fc2685fa5..5159d2d0f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -348,7 +348,7 @@ def test_goodbye_all_services(): second_packet = out.packets() assert second_packet == first_packet - # Verify the registery is empty + # Verify the registry is empty out3 = zc.generate_unregister_all_services() assert out3 is None assert zc.registry.async_get_service_infos() == [] @@ -676,7 +676,7 @@ async def test_open_close_twice_from_async() -> None: """Test we can close twice from a coroutine when using Zeroconf. Ideally callers switch to using AsyncZeroconf, however there will - be a peroid where they still call the sync wrapper that we want + be a period where they still call the sync wrapper that we want to ensure will not deadlock on shutdown. This test is expected to throw warnings about tasks being destroyed diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 50816d2b7..b98ef407b 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -110,7 +110,7 @@ def _process_outgoing_packet(out): assert question_answers _process_outgoing_packet(construct_outgoing_multicast_answers(question_answers.mcast_aggregate)) - # The additonals should all be suppresed since they are all in the answers section + # The additonals should all be suppressed since they are all in the answers section # There will be one NSEC additional to indicate the lack of AAAA record # assert nbr_answers == 4 and nbr_additionals == 1 and nbr_authorities == 0 @@ -685,7 +685,7 @@ def _validate_complete_response(answers): assert not question_answers.mcast_aggregate _validate_complete_response(question_answers.mcast_now) - # With QU set and an authorative answer (probe) should respond to both unitcast + # With QU set and an authoritative answer (probe) should respond to both unitcast # and multicast since the response hasn't been seen since 75% of the ttl query = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(info.type, const._TYPE_PTR, const._CLASS_IN) @@ -744,7 +744,7 @@ def test_known_answer_supression(): now = current_time_millis() _clear_cache(zc) - # Test PTR supression + # Test PTR suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) @@ -768,7 +768,7 @@ def test_known_answer_supression(): assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second - # Test A supression + # Test A suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(server_name, const._TYPE_A, const._CLASS_IN) generated.add_question(question) @@ -809,7 +809,7 @@ def test_known_answer_supression(): assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second - # Test SRV supression + # Test SRV suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) @@ -833,7 +833,7 @@ def test_known_answer_supression(): assert not question_answers.mcast_aggregate assert not question_answers.mcast_aggregate_last_second - # Test TXT supression + # Test TXT suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(registration_name, const._TYPE_TXT, const._CLASS_IN) generated.add_question(question) @@ -914,7 +914,7 @@ def test_multi_packet_known_answer_supression(): now = current_time_millis() _clear_cache(zc) - # Test PTR supression + # Test PTR suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) @@ -976,7 +976,7 @@ def test_known_answer_supression_service_type_enumeration_query(): now = current_time_millis() _clear_cache(zc) - # Test PTR supression + # Test PTR suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME, const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) @@ -1062,7 +1062,7 @@ def test_upper_case_enumeration_query(): zc.registry.async_add(info2) _clear_cache(zc) - # Test PTR supression + # Test PTR suppression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion(const._SERVICE_TYPE_ENUMERATION_NAME.upper(), const._TYPE_PTR, const._CLASS_IN) generated.add_question(question) @@ -1579,7 +1579,7 @@ async def test_duplicate_goodbye_answers_in_packet(): @pytest.mark.asyncio async def test_response_aggregation_timings(run_isolated): - """Verify multicast respones are aggregated.""" + """Verify multicast responses are aggregated.""" type_ = "_mservice._tcp.local." type_2 = "_mservice2._tcp.local." type_3 = "_mservice3._tcp.local." @@ -1949,7 +1949,7 @@ async def test_future_answers_are_removed_on_send(): # The answer should get removed because we just sent it assert info.dns_pointer() not in outgoing_queue.queue[0].answers - # But the one we have not sent yet shoudl still go out later + # But the one we have not sent yet should still go out later assert info2.dns_pointer() in outgoing_queue.queue[0].answers diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ee9ed9300..8f124c175 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -261,7 +261,7 @@ def test_dns_hinfo(self): self.assertRaises(r.NamePartTooLongException, generated.packets) def test_many_questions(self): - """Test many questions get seperated into multiple packets.""" + """Test many questions get separated into multiple packets.""" generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for i in range(100): @@ -281,7 +281,7 @@ def test_many_questions(self): assert len(parsed2.questions) == 15 def test_many_questions_with_many_known_answers(self): - """Test many questions and known answers get seperated into multiple packets.""" + """Test many questions and known answers get separated into multiple packets.""" generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) questions = [] for _ in range(30): @@ -319,7 +319,7 @@ def test_many_questions_with_many_known_answers(self): assert not parsed3.truncated def test_massive_probe_packet_split(self): - """Test probe with many authorative answers.""" + """Test probe with many authoritative answers.""" generated = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) questions = [] for _ in range(30): From 0df2fc3b13fcd016d8608afbb968f442ac4bfb12 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:40:39 -0600 Subject: [PATCH 1123/1433] chore(pre-commit.ci): pre-commit autoupdate (#1427) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 61669a36b..782d07ba0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v3.29.1 + rev: v3.30.1 hooks: - id: commitizen stages: [commit-msg] @@ -34,12 +34,12 @@ repos: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/asottile/pyupgrade - rev: v3.18.0 + rev: v3.19.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.0 + rev: v0.7.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -53,7 +53,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.12.1 + rev: v1.13.0 hooks: - id: mypy additional_dependencies: [] From 287b03ff8259f6e031ceb0c975c24427c706c707 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 15 Nov 2024 16:41:19 +0100 Subject: [PATCH 1124/1433] chore(dependabot): automatic Github Actions updates (#1435) --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d866e392..ba2becff2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,16 @@ version: 2 updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(ci): " + groups: + github-actions: + patterns: + - "*" - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: From aff9d1f39a4218a6f420b1504aad70ab78bc3254 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 15 Nov 2024 16:42:27 +0100 Subject: [PATCH 1125/1433] chore(tests): remove outdated python 3.7 compatibility code (#1431) --- tests/test_core.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 5159d2d0f..71245a5f7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -13,12 +13,7 @@ import unittest import unittest.mock from typing import Tuple, Union, cast -from unittest.mock import Mock, patch - -if sys.version_info[:3][1] < 8: - AsyncMock = Mock -else: - from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -748,7 +743,6 @@ def _background_register(): @pytest.mark.asyncio -@unittest.skipIf(sys.version_info[:3][1] < 8, "Requires Python 3.8 or later to patch _async_setup") @patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): From f59989270d96b2d84ceb0b2d85d961e613cb7d32 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 15 Nov 2024 16:42:48 +0100 Subject: [PATCH 1126/1433] chore(pre-commit): bump pyupgrade to required python 3.8 (#1429) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 782d07ba0..61ab26f72 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: rev: v3.19.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.7.3 hooks: From 16f9527551f244f7454bd547a475644fcc0e9a25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:43:35 -0600 Subject: [PATCH 1127/1433] chore(deps): bump async-timeout from 4.0.3 to 5.0.1 (#1438) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index f32c8580f..209d6187a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "async-timeout" -version = "4.0.3" +version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] [[package]] From 23b1958715fcc913c9be92378cce6d1c9b86c38e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 21 Nov 2024 07:38:56 -0600 Subject: [PATCH 1128/1433] chore(pre-commit.ci): pre-commit autoupdate (#1442) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 61ab26f72..1e3e75567 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v3.30.1 + rev: v3.31.0 hooks: - id: commitizen stages: [commit-msg] @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 483d0673d4ae3eec37840452723fc1839a6cc95c Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 21 Nov 2024 14:39:39 +0100 Subject: [PATCH 1129/1433] fix(docs): update python to 3.8 (#1430) --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 8929f417b..7899fad9e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ PyPI (installable, stable distributions): https://pypi.org/project/zeroconf. You pip install zeroconf -python-zeroconf works with CPython 3.6+ and PyPy 3 implementing Python 3.6+. +python-zeroconf works with CPython 3.8+ and PyPy 3 implementing Python 3.8+. Contents -------- From f637c75f638ba20c193e58ff63c073a4003430b9 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 21 Nov 2024 14:39:57 +0100 Subject: [PATCH 1130/1433] fix(ci): run release workflow only on main repository (#1441) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 309707e1d..0b837157a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: - test - lint - commitlint + if: ${{ github.repository_owner }} == "python-zeroconf" runs-on: ubuntu-latest environment: release From d5e8550dfb3db4e52651b106d7ed0216baf0b253 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 21 Nov 2024 13:48:52 +0000 Subject: [PATCH 1131/1433] 0.136.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a15e049a5..c6954e9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.136.1 (2024-11-21) + +### Bug Fixes + +* fix(ci): run release workflow only on main repository (#1441) ([`f637c75`](https://github.com/python-zeroconf/python-zeroconf/commit/f637c75f638ba20c193e58ff63c073a4003430b9)) + +* fix(docs): update python to 3.8 (#1430) ([`483d067`](https://github.com/python-zeroconf/python-zeroconf/commit/483d0673d4ae3eec37840452723fc1839a6cc95c)) + + ## v0.136.0 (2024-10-26) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 42b9ee0ea..32aade30c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.136.0" +version = "0.136.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ec3a682f2..b5c4612b0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.136.0" +__version__ = "0.136.1" __license__ = "LGPL" From 2ea705d850c1cb096c87372d5ec855f684603d01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Nov 2024 07:55:35 -0600 Subject: [PATCH 1132/1433] fix: retrigger release from failed github workflow (#1443) From 1b0d2f5f8bb7d360a466db72dadc63817f7cef10 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 21 Nov 2024 14:05:42 +0000 Subject: [PATCH 1133/1433] 0.136.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6954e9f0..7e80bfb7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.136.2 (2024-11-21) + +### Bug Fixes + +* fix: retrigger release from failed github workflow (#1443) ([`2ea705d`](https://github.com/python-zeroconf/python-zeroconf/commit/2ea705d850c1cb096c87372d5ec855f684603d01)) + + ## v0.136.1 (2024-11-21) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 32aade30c..1603963f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.136.1" +version = "0.136.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b5c4612b0..9df63ad16 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.136.1" +__version__ = "0.136.2" __license__ = "LGPL" From 84596e07b01873c359bae4c4bd298c9367d9d9c3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:08:22 -0600 Subject: [PATCH 1134/1433] chore(pre-commit.ci): pre-commit autoupdate (#1444) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- src/zeroconf/__init__.py | 28 ++++++++-------- src/zeroconf/_dns.py | 8 ++--- src/zeroconf/_engine.py | 10 +++--- src/zeroconf/_handlers/answers.py | 4 +-- .../_handlers/multicast_outgoing_queue.py | 8 ++--- src/zeroconf/_handlers/query_handler.py | 22 ++++++------- src/zeroconf/_handlers/record_manager.py | 2 +- src/zeroconf/_listener.py | 14 ++++---- src/zeroconf/_protocol/incoming.py | 24 +++++++------- src/zeroconf/_protocol/outgoing.py | 16 +++++----- src/zeroconf/_services/browser.py | 30 ++++++++--------- src/zeroconf/_services/info.py | 32 +++++++++---------- src/zeroconf/_services/registry.py | 2 +- src/zeroconf/_transport.py | 4 +-- src/zeroconf/_utils/ipaddress.py | 4 +-- src/zeroconf/asyncio.py | 4 +-- 17 files changed, 107 insertions(+), 107 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e3e75567..3dfc075e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.8.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 9df63ad16..e93eb4d2f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,27 +88,27 @@ __all__ = [ - "__version__", - "Zeroconf", - "ServiceInfo", - "ServiceBrowser", - "ServiceListener", + "AbstractMethodException", + "BadTypeInNameException", "DNSQuestionType", - "InterfaceChoice", - "ServiceStateChange", - "IPVersion", - "ZeroconfServiceTypes", - "RecordUpdate", - "RecordUpdateListener", - "current_time_millis", # Exceptions "Error", - "AbstractMethodException", - "BadTypeInNameException", "EventLoopBlocked", + "IPVersion", "IncomingDecodeError", + "InterfaceChoice", "NamePartTooLongException", "NonUniqueNameException", "NotRunningException", + "RecordUpdate", + "RecordUpdateListener", + "ServiceBrowser", + "ServiceInfo", + "ServiceListener", "ServiceNameAlreadyRegistered", + "ServiceStateChange", + "Zeroconf", + "ZeroconfServiceTypes", + "__version__", + "current_time_millis", ] diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 15daa709b..fe48a2f47 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -64,7 +64,7 @@ class DNSQuestionType(enum.Enum): class DNSEntry: """A DNS entry""" - __slots__ = ("key", "name", "type", "class_", "unique") + __slots__ = ("class_", "key", "name", "type", "unique") def __init__(self, name: str, type_: int, class_: int) -> None: self.name = name @@ -157,7 +157,7 @@ def __repr__(self) -> str: class DNSRecord(DNSEntry): """A DNS record - like a DNS entry, but has a TTL""" - __slots__ = ("ttl", "created") + __slots__ = ("created", "ttl") # TODO: Switch to just int ttl def __init__( @@ -421,7 +421,7 @@ def __repr__(self) -> str: class DNSService(DNSRecord): """A DNS service record""" - __slots__ = ("_hash", "priority", "weight", "port", "server", "server_key") + __slots__ = ("_hash", "port", "priority", "server", "server_key", "weight") def __init__( self, @@ -542,7 +542,7 @@ def __repr__(self) -> str: class DNSRRSet: """A set of dns records with a lookup to get the ttl.""" - __slots__ = ("_records", "_lookup") + __slots__ = ("_lookup", "_records") def __init__(self, records: List[DNSRecord]) -> None: """Create an RRset from records sets.""" diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index e807d9eff..05f8c948c 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -45,15 +45,15 @@ class AsyncEngine: """An engine wraps sockets in the event loop.""" __slots__ = ( + "_cleanup_timer", + "_listen_socket", + "_respond_sockets", "loop", - "zc", "protocols", "readers", - "senders", "running_event", - "_listen_socket", - "_respond_sockets", - "_cleanup_timer", + "senders", + "zc", ) def __init__( diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index bab2d7490..7ddde1976 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -44,7 +44,7 @@ class QuestionAnswers: """A group of answers to a question.""" - __slots__ = ("ucast", "mcast_now", "mcast_aggregate", "mcast_aggregate_last_second") + __slots__ = ("mcast_aggregate", "mcast_aggregate_last_second", "mcast_now", "ucast") def __init__( self, @@ -71,7 +71,7 @@ def __repr__(self) -> str: class AnswerGroup: """A group of answers scheduled to be sent at the same time.""" - __slots__ = ("send_after", "send_before", "answers") + __slots__ = ("answers", "send_after", "send_before") def __init__( self, diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index afcefc017..caf6470b1 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -45,12 +45,12 @@ class MulticastOutgoingQueue: """An outgoing queue used to aggregate multicast responses.""" __slots__ = ( - "zc", - "queue", - "_multicast_delay_random_min", - "_multicast_delay_random_max", "_additional_delay", "_aggregation_delay", + "_multicast_delay_random_max", + "_multicast_delay_random_min", + "queue", + "zc", ) def __init__(self, zeroconf: "Zeroconf", additional_delay: _int, max_aggregation_delay: _int) -> None: diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index 3acb1b445..ccfc7a771 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -71,7 +71,7 @@ class _AnswerStrategy: - __slots__ = ("question", "strategy_type", "types", "services") + __slots__ = ("question", "services", "strategy_type", "types") def __init__( self, @@ -91,15 +91,15 @@ class _QueryResponse: """A pair for unicast and multicast DNSOutgoing responses.""" __slots__ = ( - "_is_probe", - "_questions", - "_now", - "_cache", "_additionals", - "_ucast", - "_mcast_now", + "_cache", + "_is_probe", "_mcast_aggregate", "_mcast_aggregate_last_second", + "_mcast_now", + "_now", + "_questions", + "_ucast", ) def __init__(self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float) -> None: @@ -191,12 +191,12 @@ class QueryHandler: """Query the ServiceRegistry.""" __slots__ = ( - "zc", - "registry", "cache", - "question_history", - "out_queue", "out_delay_queue", + "out_queue", + "question_history", + "registry", + "zc", ) def __init__(self, zc: "Zeroconf") -> None: diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 53ab3ed11..0bb049966 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -40,7 +40,7 @@ class RecordManager: """Process records into the cache and notify listeners.""" - __slots__ = ("zc", "cache", "listeners") + __slots__ = ("cache", "listeners", "zc") def __init__(self, zeroconf: "Zeroconf") -> None: """Init the record manager.""" diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 4490965f7..1980a8201 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -55,17 +55,17 @@ class AsyncListener: the read() method called when a socket is available for reading.""" __slots__ = ( - "zc", - "_registry", - "_record_manager", + "_deferred", "_query_handler", + "_record_manager", + "_registry", + "_timers", "data", - "last_time", "last_message", - "transport", + "last_time", "sock_description", - "_deferred", - "_timers", + "transport", + "zc", ) def __init__(self, zc: "Zeroconf") -> None: diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index f7b1d773e..d678c977e 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -70,25 +70,25 @@ class DNSIncoming: """Object representation of an incoming DNS packet""" __slots__ = ( - "_did_read_others", - "flags", - "offset", - "data", - "view", + "_answers", "_data_len", + "_did_read_others", + "_has_qu_question", "_name_cache", - "_questions", - "_answers", - "id", - "_num_questions", + "_num_additionals", "_num_answers", "_num_authorities", - "_num_additionals", - "valid", + "_num_questions", + "_questions", + "data", + "flags", + "id", "now", + "offset", "scope_id", "source", - "_has_qu_question", + "valid", + "view", ) def __init__( diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 9e9a5c870..b2eb9230d 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -77,20 +77,20 @@ class DNSOutgoing: """Object representation of an outgoing packet""" __slots__ = ( - "flags", + "additionals", + "allow_long", + "answers", + "authorities", + "data", "finished", + "flags", "id", "multicast", - "packets_data", "names", - "data", + "packets_data", + "questions", "size", - "allow_long", "state", - "questions", - "answers", - "authorities", - "additionals", ) def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 303615280..42aaa1ac8 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -107,10 +107,10 @@ class _ScheduledPTRQuery: __slots__ = ( "alias", - "name", - "ttl", "cancelled", "expire_time_millis", + "name", + "ttl", "when_millis", ) @@ -189,7 +189,7 @@ def __gt__(self, other: "_ScheduledPTRQuery") -> bool: class _DNSPointerOutgoingBucket: """A DNSOutgoing bucket.""" - __slots__ = ("now_millis", "out", "bytes") + __slots__ = ("bytes", "now_millis", "out") def __init__(self, now_millis: float, multicast: bool) -> None: """Create a bucket to wrap a DNSOutgoing.""" @@ -328,20 +328,20 @@ class QueryScheduler: """ __slots__ = ( - "_zc", - "_types", "_addr", - "_port", - "_multicast", + "_clock_resolution_millis", "_first_random_delay_interval", - "_min_time_between_queries_millis", "_loop", - "_startup_queries_sent", + "_min_time_between_queries_millis", + "_multicast", + "_next_run", "_next_scheduled_for_alias", + "_port", "_query_heap", - "_next_run", - "_clock_resolution_millis", "_question_type", + "_startup_queries_sent", + "_types", + "_zc", ) def __init__( @@ -556,15 +556,15 @@ class _ServiceBrowserBase(RecordUpdateListener): """Base class for ServiceBrowser.""" __slots__ = ( - "types", - "zc", "_cache", "_loop", "_pending_handlers", + "_query_sender_task", "_service_state_changed", - "query_scheduler", "done", - "_query_sender_task", + "query_scheduler", + "types", + "zc", ) def __init__( diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 8a85ad103..f47addf6b 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -138,28 +138,28 @@ class ServiceInfo(RecordUpdateListener): """ __slots__ = ( - "text", - "type", - "_name", - "key", + "_decoded_properties", + "_dns_address_cache", + "_dns_pointer_cache", + "_dns_service_cache", + "_dns_text_cache", + "_get_address_and_nsec_records_cache", "_ipv4_addresses", "_ipv6_addresses", + "_name", + "_new_records_futures", + "_properties", + "host_ttl", + "interface_index", + "key", + "other_ttl", "port", - "weight", "priority", "server", "server_key", - "_properties", - "_decoded_properties", - "host_ttl", - "other_ttl", - "interface_index", - "_new_records_futures", - "_dns_pointer_cache", - "_dns_service_cache", - "_dns_text_cache", - "_dns_address_cache", - "_get_address_and_nsec_records_cache", + "text", + "type", + "weight", ) def __init__( diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 05ee14cb8..4100c690e 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -35,7 +35,7 @@ class ServiceRegistry: the event loop as it is not thread safe. """ - __slots__ = ("_services", "types", "servers", "has_entries") + __slots__ = ("_services", "has_entries", "servers", "types") def __init__( self, diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index f28c0029e..b08110943 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -29,11 +29,11 @@ class _WrappedTransport: """A wrapper for transports.""" __slots__ = ( - "transport", + "fileno", "is_ipv6", "sock", - "fileno", "sock_name", + "transport", ) def __init__( diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index 72bb9ce83..8dc1f7979 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -39,7 +39,7 @@ class ZeroconfIPv4Address(IPv4Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified", "_is_loopback", "__hash__", "zc_integer") + __slots__ = ("__hash__", "_is_link_local", "_is_loopback", "_is_unspecified", "_str", "zc_integer") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv4 address.""" @@ -72,7 +72,7 @@ def is_loopback(self) -> bool: class ZeroconfIPv6Address(IPv6Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified", "_is_loopback", "__hash__", "zc_integer") + __slots__ = ("__hash__", "_is_link_local", "_is_loopback", "_is_unspecified", "_str", "zc_integer") def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a new IPv6 address.""" diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 134ea3e0f..926ef5099 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -35,9 +35,9 @@ from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME __all__ = [ - "AsyncZeroconf", - "AsyncServiceInfo", "AsyncServiceBrowser", + "AsyncServiceInfo", + "AsyncZeroconf", "AsyncZeroconfServiceTypes", ] From 88dcd31b31a66f41c093c265e2655d06a55bae4f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:23:13 -1000 Subject: [PATCH 1135/1433] chore(pre-commit.ci): pre-commit autoupdate (#1447) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3dfc075e8..2ddedf0a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v3.31.0 + rev: v4.1.0 hooks: - id: commitizen stages: [commit-msg] @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.8.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 581807b2b28099c5e32608a50b40f9fbced9c41a Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 19 Dec 2024 00:24:38 +0100 Subject: [PATCH 1136/1433] chore(ci): increase tested pypy to 3.9 and 3.10 (#1450) chore(ci): increase tested pypy to 3.9 and 3.10 --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b837157a..f9f95d4aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,8 @@ jobs: - "3.11" - "3.12" - "3.13" - - "pypy-3.8" - "pypy-3.9" + - "pypy-3.10" os: - ubuntu-latest - macos-latest @@ -56,14 +56,14 @@ jobs: extension: use_cython - os: windows-latest extension: use_cython - - os: windows-latest - python-version: "pypy-3.8" - os: windows-latest python-version: "pypy-3.9" - - os: macos-latest - python-version: "pypy-3.8" + - os: windows-latest + python-version: "pypy-3.10" - os: macos-latest python-version: "pypy-3.9" + - os: macos-latest + python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 From e34feb6c19407c4374395d1b64213aa22f3b46af Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 19 Dec 2024 00:25:24 +0100 Subject: [PATCH 1137/1433] chore(pre-commit): remove duplicated hook (#1448) --- .pre-commit-config.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ddedf0a2..0dcc6b6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first @@ -24,10 +23,10 @@ repos: - id: check-toml - id: check-xml - id: check-yaml + - id: debug-statements - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - - id: debug-statements - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: From 1ec95c72146078dbf7915a97b22bd699361c8d28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Dec 2024 13:25:48 -1000 Subject: [PATCH 1138/1433] chore(deps-dev): bump pytest from 8.3.3 to 8.3.4 (#1446) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 209d6187a..dfd5ba1e9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -248,13 +248,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] From 5d285107fbf5ca5a2332c08e7b508f11330b9e60 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:55:10 -1000 Subject: [PATCH 1139/1433] chore(pre-commit.ci): pre-commit autoupdate (#1454) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0dcc6b6b5..7f19ac500 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,12 +33,12 @@ repos: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -52,7 +52,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.1 hooks: - id: mypy additional_dependencies: [] From 7b4a29b660afdde54538831bf93038fbd87459fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 10:08:44 -1000 Subject: [PATCH 1140/1433] chore: drop Python 3.8 support (#1455) --- .github/workflows/ci.yml | 3 +- README.rst | 4 +- poetry.lock | 210 +++++++++++++++++++++------------------ pyproject.toml | 3 +- 4 files changed, 119 insertions(+), 101 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f95d4aa..aa32bba01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" - "3.9" - "3.10" - "3.11" @@ -185,7 +184,7 @@ jobs: uses: pypa/cibuildwheel@v2.21.3 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* *p38-*_aarch64 cp38-*_arm64 *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 diff --git a/README.rst b/README.rst index eba4d7feb..f16b7c2f1 100644 --- a/README.rst +++ b/README.rst @@ -45,8 +45,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.8+ -* PyPy3.8 7.3+ +* CPython 3.9+ +* PyPy 3.9+ Versioning ---------- diff --git a/poetry.lock b/poetry.lock index dfd5ba1e9..9b8dd7116 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,83 +24,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.1" +version = "7.6.10" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, - {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, - {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, - {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, - {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, - {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, - {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, - {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, - {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, - {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, - {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, - {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, - {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, - {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, - {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, - {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, - {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, - {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, - {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, - {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, - {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, - {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, - {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, - {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, - {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, - {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, - {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, - {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, - {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, - {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, - {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, - {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, - {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, - {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, - {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, - {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, - {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, - {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, + {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, + {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"}, + {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"}, + {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"}, + {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"}, + {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"}, + {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"}, + {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"}, + {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"}, + {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"}, + {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"}, + {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"}, + {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"}, + {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"}, + {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"}, + {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"}, + {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"}, + {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"}, + {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"}, + {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"}, + {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"}, + {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"}, + {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"}, + {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"}, + {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"}, + {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"}, + {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"}, + {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"}, + {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"}, + {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"}, + {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"}, + {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"}, + {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"}, + {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"}, + {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"}, + {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"}, + {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"}, + {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"}, ] [package.dependencies] @@ -186,13 +176,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -222,13 +212,13 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.2" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -320,36 +310,66 @@ pytest = ">=7.0.0" [[package]] name = "setuptools" -version = "75.3.0" +version = "75.7.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"}, - {file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"}, + {file = "setuptools-75.7.0-py3-none-any.whl", hash = "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183"}, + {file = "setuptools-75.7.0.tar.gz", hash = "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "tomli" -version = "2.0.1" +version = "2.2.1" description = "A lil' TOML parser" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [metadata] lock-version = "2.0" -python-versions = "^3.8" -content-hash = "778ccbd9b059daea1ccbc3a93e0186fa30737e8c5234cdc04edf505a1f71606a" +python-versions = "^3.9" +content-hash = "6882a0df6f35a4a996584f0a1842c26ae6727c6d0a71eff64df0e23387d58e5d" diff --git a/pyproject.toml b/pyproject.toml index 1603963f7..4c9a3b104 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ classifiers=[ 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', @@ -70,7 +69,7 @@ match = "(?!master$)" prerelease = true [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" async-timeout = {version = ">=3.0.0", python = "<3.11"} ifaddr = ">=0.1.7" From 9f6af54e52a5a5689f8ba615e7a2c6593dbcf461 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 10:28:38 -1000 Subject: [PATCH 1141/1433] chore: enable codspeed benchmarks (#1456) --- .github/workflows/ci.yml | 19 ++ poetry.lock | 244 +++++++++++++++++++++++- pyproject.toml | 1 + tests/benchmarks/__init__.py | 0 tests/benchmarks/test_incoming.py | 185 ++++++++++++++++++ tests/benchmarks/test_outgoing.py | 168 ++++++++++++++++ tests/benchmarks/test_txt_properties.py | 19 ++ 7 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 tests/benchmarks/__init__.py create mode 100644 tests/benchmarks/test_incoming.py create mode 100644 tests/benchmarks/test_outgoing.py create mode 100644 tests/benchmarks/test_txt_properties.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa32bba01..60d99f977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,25 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python 3.12 + uses: actions/setup-python@v4 + with: + python-version: 3.12 + - uses: snok/install-poetry@v1.3.4 + - name: Install Dependencies + run: | + REQUIRE_CYTHON=1 poetry install --only=main,dev + shell: bash + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + with: + token: ${{ secrets.CODSPEED_TOKEN }} + run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks + release: needs: - test diff --git a/poetry.lock b/poetry.lock index 9b8dd7116..e4a5fae5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,85 @@ files = [ {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "colorama" version = "0.4.6" @@ -199,6 +278,29 @@ files = [ {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, ] +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -210,6 +312,41 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "packaging" version = "24.2" @@ -236,6 +373,31 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pytest" version = "8.3.4" @@ -276,6 +438,37 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-codspeed" +version = "3.1.0" +description = "Pytest plugin to create CodSpeed benchmarks" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_codspeed-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb7c16e5a64cb30bad30f5204c7690f3cbc9ae5b9839ce187ef1727aa5d2d9c"}, + {file = "pytest_codspeed-3.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d23910893c22ceef6efbdf85d80e803b7fb4a231c9e7676ab08f5ddfc228438"}, + {file = "pytest_codspeed-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb1495a633a33e15268a1f97d91a4809c868de06319db50cf97b4e9fa426372c"}, + {file = "pytest_codspeed-3.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd8a54b99207bd25a4c3f64d9a83ac0f3def91cdd87204ca70a49f822ba919c"}, + {file = "pytest_codspeed-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4d1ac896ebaea5b365e69b41319b4d09b57dab85ec6234f6ff26116b3795f03"}, + {file = "pytest_codspeed-3.1.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f0c1857a0a6cce6a23c49f98c588c2eef66db353c76ecbb2fb65c1a2b33a8d5"}, + {file = "pytest_codspeed-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4731a7cf1d8d38f58140d51faa69b7c1401234c59d9759a2507df570c805b11"}, + {file = "pytest_codspeed-3.1.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f2e4b63260f65493b8d42c8167f831b8ed90788f81eb4eb95a103ee6aa4294"}, + {file = "pytest_codspeed-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db44099b3f1ec1c9c41f0267c4d57d94e31667f4cb3fb4b71901561e8ab8bc98"}, + {file = "pytest_codspeed-3.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a533c1ad3cc60f07be432864c83d1769ce2877753ac778e1bfc5a9821f5c6ddf"}, + {file = "pytest_codspeed-3.1.0.tar.gz", hash = "sha256:f29641d27b4ded133b1058a4c859e510a2612ad4217ef9a839ba61750abd2f8a"}, +] + +[package.dependencies] +cffi = ">=1.17.1" +importlib-metadata = {version = ">=8.5.0", markers = "python_version < \"3.10\""} +pytest = ">=3.8" +rich = ">=13.8.1" + +[package.extras] +compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] +lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.6.5,<0.7.0)"] +test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -308,6 +501,25 @@ files = [ [package.dependencies] pytest = ">=7.0.0" +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "setuptools" version = "75.7.0" @@ -369,7 +581,37 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "zipp" +version = "3.21.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "6882a0df6f35a4a996584f0a1842c26ae6727c6d0a71eff64df0e23387d58e5d" +content-hash = "b2255f56e331fb25e626030bf4ad11e7424d28cb1b7dd0310b9c704ee39bb0e1" diff --git a/pyproject.toml b/pyproject.toml index 4c9a3b104..ce61d0a4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ pytest-asyncio = ">=0.20.3,<0.25.0" cython = "^3.0.5" setuptools = ">=65.6.3,<76.0.0" pytest-timeout = "^2.1.0" +pytest-codspeed = "^3.1.0" [tool.ruff] target-version = "py38" diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/benchmarks/test_incoming.py b/tests/benchmarks/test_incoming.py new file mode 100644 index 000000000..6285c19f1 --- /dev/null +++ b/tests/benchmarks/test_incoming.py @@ -0,0 +1,185 @@ +"""Benchmark for DNSIncoming.""" + +import socket +from typing import List + +from pytest_codspeed import BenchmarkFixture + +from zeroconf import ( + DNSAddress, + DNSIncoming, + DNSNsec, + DNSOutgoing, + DNSService, + DNSText, + const, +) + + +def generate_packets() -> List[bytes]: + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + out.add_additional_answer( + DNSNsec( + record["name"], # type: ignore + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["name"], # type: ignore + [const._TYPE_TXT, const._TYPE_SRV], + ) + ) + + return out.packets() + + +packets = generate_packets() + + +def test_parse_incoming_message(benchmark: BenchmarkFixture) -> None: + @benchmark + def parse_incoming_message() -> None: + for packet in packets: + DNSIncoming(packet).answers # noqa: B018 + break diff --git a/tests/benchmarks/test_outgoing.py b/tests/benchmarks/test_outgoing.py new file mode 100644 index 000000000..5b7ee164a --- /dev/null +++ b/tests/benchmarks/test_outgoing.py @@ -0,0 +1,168 @@ +"""Benchmark for DNSOutgoing.""" + +import socket + +from pytest_codspeed import BenchmarkFixture + +from zeroconf import DNSAddress, DNSOutgoing, DNSService, DNSText, const +from zeroconf._protocol.outgoing import State + + +def generate_packets() -> DNSOutgoing: + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + return out + + +out = generate_packets() + + +def test_parse_outgoing_message(benchmark: BenchmarkFixture) -> None: + @benchmark + def make_outgoing_message() -> None: + out.packets() + out.state = State.init.value + out.finished = False + out._reset_for_next_packet() diff --git a/tests/benchmarks/test_txt_properties.py b/tests/benchmarks/test_txt_properties.py new file mode 100644 index 000000000..ad75ab359 --- /dev/null +++ b/tests/benchmarks/test_txt_properties.py @@ -0,0 +1,19 @@ +from pytest_codspeed import BenchmarkFixture + +from zeroconf import ServiceInfo + +info = ServiceInfo( + "_test._tcp.local.", + "test._test._tcp.local.", + properties=( + b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==" + ), +) + + +def test_txt_properties(benchmark: BenchmarkFixture) -> None: + @benchmark + def process_properties() -> None: + info._properties = None + info.properties # noqa: B018 From 783c1b37d1372c90dfce658c66d03aa753afbf49 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 11:31:25 -1000 Subject: [PATCH 1142/1433] feat: speed up parsing incoming records (#1458) --- src/zeroconf/_dns.pxd | 20 ++++++- src/zeroconf/_dns.py | 92 +++++++++++++++++++++++++---- src/zeroconf/_protocol/incoming.pxd | 12 +++- src/zeroconf/_protocol/incoming.py | 31 +++++++--- 4 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index d4116a66a..6e432a77a 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -30,7 +30,7 @@ cdef class DNSEntry: cdef public cython.uint class_ cdef public bint unique - cdef _set_class(self, cython.uint class_) + cdef _fast_init_entry(self, str name, cython.uint type_, cython.uint class_) cdef bint _dns_entry_matches(self, DNSEntry other) @@ -38,6 +38,8 @@ cdef class DNSQuestion(DNSEntry): cdef public cython.int _hash + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_) + cpdef bint answered_by(self, DNSRecord rec) cdef class DNSRecord(DNSEntry): @@ -45,6 +47,8 @@ cdef class DNSRecord(DNSEntry): cdef public cython.float ttl cdef public double created + cdef _fast_init_record(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, double created) + cdef bint _suppressed_by_answer(self, DNSRecord answer) @cython.locals( @@ -69,9 +73,11 @@ cdef class DNSRecord(DNSEntry): cdef class DNSAddress(DNSRecord): cdef public cython.int _hash - cdef public object address + cdef public bytes address cdef public object scope_id + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, bytes address, object scope_id, double created) + cdef bint _eq(self, DNSAddress other) cpdef write(self, DNSOutgoing out) @@ -83,6 +89,8 @@ cdef class DNSHinfo(DNSRecord): cdef public str cpu cdef public str os + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str cpu, str os, double created) + cdef bint _eq(self, DNSHinfo other) cpdef write(self, DNSOutgoing out) @@ -93,6 +101,8 @@ cdef class DNSPointer(DNSRecord): cdef public str alias cdef public str alias_key + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str alias, double created) + cdef bint _eq(self, DNSPointer other) cpdef write(self, DNSOutgoing out) @@ -102,6 +112,8 @@ cdef class DNSText(DNSRecord): cdef public cython.int _hash cdef public bytes text + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, bytes text, double created) + cdef bint _eq(self, DNSText other) cpdef write(self, DNSOutgoing out) @@ -115,6 +127,8 @@ cdef class DNSService(DNSRecord): cdef public str server cdef public str server_key + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, cython.uint priority, cython.uint weight, cython.uint port, str server, double created) + cdef bint _eq(self, DNSService other) cpdef write(self, DNSOutgoing out) @@ -125,6 +139,8 @@ cdef class DNSNsec(DNSRecord): cdef public object next_name cdef public cython.list rdtypes + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str next_name, cython.list rdtypes, double created) + cdef bint _eq(self, DNSNsec other) cpdef write(self, DNSOutgoing out) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index fe48a2f47..471376e91 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -67,12 +67,13 @@ class DNSEntry: __slots__ = ("class_", "key", "name", "type", "unique") def __init__(self, name: str, type_: int, class_: int) -> None: + self._fast_init_entry(name, type_, class_) + + def _fast_init_entry(self, name: str, type_: _int, class_: _int) -> None: + """Fast init for reuse.""" self.name = name self.key = name.lower() self.type = type_ - self._set_class(class_) - - def _set_class(self, class_: _int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 @@ -111,7 +112,11 @@ class DNSQuestion(DNSEntry): __slots__ = ("_hash",) def __init__(self, name: str, type_: int, class_: int) -> None: - super().__init__(name, type_, class_) + self._fast_init(name, type_, class_) + + def _fast_init(self, name: str, type_: _int, class_: _int) -> None: + """Fast init for reuse.""" + self._fast_init_entry(name, type_, class_) self._hash = hash((self.key, type_, self.class_)) def answered_by(self, rec: "DNSRecord") -> bool: @@ -168,9 +173,13 @@ def __init__( ttl: Union[float, int], created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_) + self._fast_init_record(name, type_, class_, ttl, created or current_time_millis()) + + def _fast_init_record(self, name: str, type_: _int, class_: _int, ttl: _float, created: _float) -> None: + """Fast init for reuse.""" + self._fast_init_entry(name, type_, class_) self.ttl = ttl - self.created = created or current_time_millis() + self.created = created def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" @@ -248,7 +257,20 @@ def __init__( scope_id: Optional[int] = None, created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init(name, type_, class_, ttl, address, scope_id, created or current_time_millis()) + + def _fast_init( + self, + name: str, + type_: _int, + class_: _int, + ttl: _float, + address: bytes, + scope_id: Optional[_int], + created: _float, + ) -> None: + """Fast init for reuse.""" + self._fast_init_record(name, type_, class_, ttl, created) self.address = address self.scope_id = scope_id self._hash = hash((self.key, type_, self.class_, address, scope_id)) @@ -300,7 +322,13 @@ def __init__( os: str, created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init(name, type_, class_, ttl, cpu, os, created or current_time_millis()) + + def _fast_init( + self, name: str, type_: _int, class_: _int, ttl: _float, cpu: str, os: str, created: _float + ) -> None: + """Fast init for reuse.""" + self._fast_init_record(name, type_, class_, ttl, created) self.cpu = cpu self.os = os self._hash = hash((self.key, type_, self.class_, cpu, os)) @@ -341,7 +369,12 @@ def __init__( alias: str, created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init(name, type_, class_, ttl, alias, created or current_time_millis()) + + def _fast_init( + self, name: str, type_: _int, class_: _int, ttl: _float, alias: str, created: _float + ) -> None: + self._fast_init_record(name, type_, class_, ttl, created) self.alias = alias self.alias_key = alias.lower() self._hash = hash((self.key, type_, self.class_, self.alias_key)) @@ -391,7 +424,12 @@ def __init__( text: bytes, created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init(name, type_, class_, ttl, text, created or current_time_millis()) + + def _fast_init( + self, name: str, type_: _int, class_: _int, ttl: _float, text: bytes, created: _float + ) -> None: + self._fast_init_record(name, type_, class_, ttl, created) self.text = text self._hash = hash((self.key, type_, self.class_, text)) @@ -435,7 +473,23 @@ def __init__( server: str, created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init( + name, type_, class_, ttl, priority, weight, port, server, created or current_time_millis() + ) + + def _fast_init( + self, + name: str, + type_: _int, + class_: _int, + ttl: _float, + priority: _int, + weight: _int, + port: _int, + server: str, + created: _float, + ) -> None: + self._fast_init_record(name, type_, class_, ttl, created) self.priority = priority self.weight = weight self.port = port @@ -483,12 +537,24 @@ def __init__( name: str, type_: int, class_: int, - ttl: int, + ttl: Union[int, float], next_name: str, rdtypes: List[int], created: Optional[float] = None, ) -> None: - super().__init__(name, type_, class_, ttl, created) + self._fast_init(name, type_, class_, ttl, next_name, rdtypes, created or current_time_millis()) + + def _fast_init( + self, + name: str, + type_: _int, + class_: _int, + ttl: _float, + next_name: str, + rdtypes: List[_int], + created: _float, + ) -> None: + self._fast_init_record(name, type_, class_, ttl, created) self.next_name = next_name self.rdtypes = sorted(rdtypes) self._hash = hash((self.key, type_, self.class_, next_name, *self.rdtypes)) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index bb4383036..feaa2a02e 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -97,7 +97,7 @@ cdef class DNSIncoming: ) cdef void _read_others(self) - @cython.locals(offset="unsigned int") + @cython.locals(offset="unsigned int", question=DNSQuestion) cdef _read_questions(self) @cython.locals( @@ -109,9 +109,15 @@ cdef class DNSIncoming: @cython.locals( name_start="unsigned int", - offset="unsigned int" + offset="unsigned int", + address_rec=DNSAddress, + pointer_rec=DNSPointer, + text_rec=DNSText, + srv_rec=DNSService, + hinfo_rec=DNSHinfo, + nsec_rec=DNSNsec, ) - cdef _read_record(self, object domain, unsigned int type_, unsigned int class_, unsigned int ttl, unsigned int length) + cdef _read_record(self, str domain, unsigned int type_, unsigned int class_, unsigned int ttl, unsigned int length) @cython.locals( offset="unsigned int", diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index d678c977e..5347f50d3 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -246,7 +246,8 @@ def _read_questions(self) -> None: # The question has 2 unsigned shorts in network order type_ = view[offset] << 8 | view[offset + 1] class_ = view[offset + 2] << 8 | view[offset + 3] - question = DNSQuestion(name, type_, class_) + question = DNSQuestion.__new__(DNSQuestion) + question._fast_init(name, type_, class_) if question.unique: # QU questions use the same bit as unique self._has_qu_question = True questions.append(question) @@ -306,11 +307,17 @@ def _read_record( ) -> Optional[DNSRecord]: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: - return DNSAddress(domain, type_, class_, ttl, self._read_string(4), None, self.now) + address_rec = DNSAddress.__new__(DNSAddress) + address_rec._fast_init(domain, type_, class_, ttl, self._read_string(4), None, self.now) + return address_rec if type_ in (_TYPE_CNAME, _TYPE_PTR): - return DNSPointer(domain, type_, class_, ttl, self._read_name(), self.now) + pointer_rec = DNSPointer.__new__(DNSPointer) + pointer_rec._fast_init(domain, type_, class_, ttl, self._read_name(), self.now) + return pointer_rec if type_ == _TYPE_TXT: - return DNSText(domain, type_, class_, ttl, self._read_string(length), self.now) + text_rec = DNSText.__new__(DNSText) + text_rec._fast_init(domain, type_, class_, ttl, self._read_string(length), self.now) + return text_rec if type_ == _TYPE_SRV: view = self.view offset = self.offset @@ -319,7 +326,8 @@ def _read_record( priority = view[offset] << 8 | view[offset + 1] weight = view[offset + 2] << 8 | view[offset + 3] port = view[offset + 4] << 8 | view[offset + 5] - return DNSService( + srv_rec = DNSService.__new__(DNSService) + srv_rec._fast_init( domain, type_, class_, @@ -330,8 +338,10 @@ def _read_record( self._read_name(), self.now, ) + return srv_rec if type_ == _TYPE_HINFO: - return DNSHinfo( + hinfo_rec = DNSHinfo.__new__(DNSHinfo) + hinfo_rec._fast_init( domain, type_, class_, @@ -340,8 +350,10 @@ def _read_record( self._read_character_string(), self.now, ) + return hinfo_rec if type_ == _TYPE_AAAA: - return DNSAddress( + address_rec = DNSAddress.__new__(DNSAddress) + address_rec._fast_init( domain, type_, class_, @@ -350,9 +362,11 @@ def _read_record( self.scope_id, self.now, ) + return address_rec if type_ == _TYPE_NSEC: name_start = self.offset - return DNSNsec( + nsec_rec = DNSNsec.__new__(DNSNsec) + nsec_rec._fast_init( domain, type_, class_, @@ -361,6 +375,7 @@ def _read_record( self._read_bitmap(name_start + length), self.now, ) + return nsec_rec # Try to ignore types we don't know about # Skip the payload for the resource record so the next # records can be parsed correctly From 6a48fac061bf5921b2df5729881661de8baa5dd4 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 6 Jan 2025 21:40:49 +0000 Subject: [PATCH 1143/1433] 0.137.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e80bfb7f..f5efa70ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.137.0 (2025-01-06) + +### Features + +* feat: speed up parsing incoming records (#1458) ([`783c1b3`](https://github.com/python-zeroconf/python-zeroconf/commit/783c1b37d1372c90dfce658c66d03aa753afbf49)) + + ## v0.136.2 (2024-11-21) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index ce61d0a4c..65e0c40d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.136.2" +version = "0.137.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index e93eb4d2f..3e14b846e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.136.2" +__version__ = "0.137.0" __license__ = "LGPL" From 4ff48a01bc76c82e5710aafaf6cf6e79c069cd85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 11:58:29 -1000 Subject: [PATCH 1144/1433] fix: move wheel builds to macos-13 (#1459) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60d99f977..f61255186 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-2019, macos-12, macos-latest] + os: [ubuntu-latest, windows-2019, macos-13, macos-latest] steps: - uses: actions/checkout@v3 From 9dc0eff4f9c605b4281970b044ec2a4cdf9aa27d Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 6 Jan 2025 22:16:19 +0000 Subject: [PATCH 1145/1433] 0.137.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5efa70ff..383537244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.137.1 (2025-01-06) + +### Bug Fixes + +* fix: move wheel builds to macos-13 (#1459) ([`4ff48a0`](https://github.com/python-zeroconf/python-zeroconf/commit/4ff48a01bc76c82e5710aafaf6cf6e79c069cd85)) + + ## v0.137.0 (2025-01-06) ### Features diff --git a/pyproject.toml b/pyproject.toml index 65e0c40d8..a1a41ae76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.137.0" +version = "0.137.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3e14b846e..46bfa4d21 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.137.0" +__version__ = "0.137.1" __license__ = "LGPL" From be05f0dc4f6b2431606031a7bb24585728d15f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jan 2025 13:34:41 -1000 Subject: [PATCH 1146/1433] fix: split wheel builds to avoid timeout (#1461) --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f61255186..520bf35ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-2019, macos-13, macos-latest] + musl: ["", "musllinux"] + exclude: + - os: windows-2019 + musl: "musllinux" + - os: macos-13 + musl: "musllinux" + - os: macos-latest + musl: "musllinux" steps: - uses: actions/checkout@v3 @@ -199,11 +207,23 @@ jobs: with: platforms: arm64 - - name: Build wheels - uses: pypa/cibuildwheel@v2.21.3 + - name: Build wheels (non-musl) + uses: pypa/cibuildwheel@v2.22.0 + if: matrix.musl == '' + # to supply options, put them in 'env', like: + env: + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux* + CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc + CIBW_ARCHS_LINUX: auto aarch64 + CIBW_BUILD_VERBOSITY: 3 + REQUIRE_CYTHON: 1 + + - name: Build wheels (musl) + uses: pypa/cibuildwheel@v2.22.0 + if: matrix.musl == 'musllinux' # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux*_aarch64 + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *manylinux* CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: auto aarch64 CIBW_BUILD_VERBOSITY: 3 From 44e92d48071dbbab592623b511968447cee13548 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 6 Jan 2025 23:48:59 +0000 Subject: [PATCH 1147/1433] 0.137.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 383537244..d313ef3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.137.2 (2025-01-06) + +### Bug Fixes + +* fix: split wheel builds to avoid timeout (#1461) ([`be05f0d`](https://github.com/python-zeroconf/python-zeroconf/commit/be05f0dc4f6b2431606031a7bb24585728d15f01)) + + ## v0.137.1 (2025-01-06) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index a1a41ae76..8343292d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.137.1" +version = "0.137.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 46bfa4d21..6d2467158 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.137.1" +__version__ = "0.137.2" __license__ = "LGPL" From bd845386ffc36a2762cdccdc6490234557b9036e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jan 2025 12:23:26 -1000 Subject: [PATCH 1148/1433] chore: add codspeed badge (#1463) --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index f16b7c2f1..297d80804 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,10 @@ python-zeroconf .. image:: https://codecov.io/gh/python-zeroconf/python-zeroconf/branch/master/graph/badge.svg :target: https://codecov.io/gh/python-zeroconf/python-zeroconf +.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json + :target: https://codspeed.io/python-zeroconf/python-zeroconf + :alt: Codspeed.io status for python-zeroconf + `Documentation `_. This is fork of pyzeroconf, Multicast DNS Service Discovery for Python, From d46fe855e9a3d6f16df8c7f51d1d06c61b8e5694 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Wed, 8 Jan 2025 21:13:10 +0100 Subject: [PATCH 1149/1433] chore: remove legacy code = (3, 9, 0) - _IPVersion_All_value = IPVersion.All.value _IPVersion_V4Only_value = IPVersion.V4Only.value # https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 @@ -250,7 +247,7 @@ def addresses(self, value: List[bytes]) -> None: self._get_address_and_nsec_records_cache = None for address in value: - if IPADDRESS_SUPPORTS_SCOPE_ID and len(address) == 16 and self.interface_index is not None: + if len(address) == 16 and self.interface_index is not None: addr = ip_bytes_and_scope_to_address(address, self.interface_index) else: addr = cached_ip_addresses(address) diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index 8dc1f7979..64cdfb638 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -20,22 +20,15 @@ USA """ -import sys -from functools import lru_cache +from functools import cache, lru_cache from ipaddress import AddressValueError, IPv4Address, IPv6Address, NetmaskValueError from typing import Any, Optional, Union from .._dns import DNSAddress from ..const import _TYPE_AAAA -if sys.version_info >= (3, 9, 0): - from functools import cache -else: - cache = lru_cache(maxsize=None) - bytes_ = bytes int_ = int -IPADDRESS_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) class ZeroconfIPv4Address(IPv4Address): @@ -128,7 +121,7 @@ def get_ip_address_object_from_record( record: DNSAddress, ) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: """Get the IP address object from the record.""" - if IPADDRESS_SUPPORTS_SCOPE_ID and record.type == _TYPE_AAAA and record.scope_id: + if record.type == _TYPE_AAAA and record.scope_id: return ip_bytes_and_scope_to_address(record.address, record.scope_id) return cached_ip_addresses_wrapper(record.address) @@ -146,7 +139,7 @@ def ip_bytes_and_scope_to_address( def str_without_scope_id(addr: Union[ZeroconfIPv4Address, ZeroconfIPv6Address]) -> str: """Return the string representation of the address without the scope id.""" - if IPADDRESS_SUPPORTS_SCOPE_ID and addr.version == 6: + if addr.version == 6: address_str = str(addr) return address_str.partition("%")[0] return str(addr) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 9a5cbb7d1..ad05f8249 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -7,7 +7,6 @@ import logging import os import socket -import sys import threading import unittest from ipaddress import ip_address @@ -704,7 +703,6 @@ def test_multiple_addresses(): assert info.addresses == [address, address] assert info.parsed_addresses() == [address_parsed, address_parsed] assert info.parsed_scoped_addresses() == [address_parsed, address_parsed] - ipaddress_supports_scope_id = sys.version_info >= (3, 9, 0) if has_working_ipv6() and not os.environ.get("SKIP_IPV6"): address_v6_parsed = "2001:db8::1" @@ -751,9 +749,7 @@ def test_multiple_addresses(): assert info.ip_addresses_by_version(r.IPVersion.All) == [ ip_address(address), ip_address(address_v6), - ip_address(address_v6_ll_scoped_parsed) - if ipaddress_supports_scope_id - else ip_address(address_v6_ll), + ip_address(address_v6_ll_scoped_parsed), ] assert info.addresses_by_version(r.IPVersion.V4Only) == [address] assert info.ip_addresses_by_version(r.IPVersion.V4Only) == [ip_address(address)] @@ -763,9 +759,7 @@ def test_multiple_addresses(): ] assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ ip_address(address_v6), - ip_address(address_v6_ll_scoped_parsed) - if ipaddress_supports_scope_id - else ip_address(address_v6_ll), + ip_address(address_v6_ll_scoped_parsed), ] assert info.parsed_addresses() == [ address_parsed, @@ -780,16 +774,15 @@ def test_multiple_addresses(): assert info.parsed_scoped_addresses() == [ address_parsed, address_v6_parsed, - address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, + address_v6_ll_scoped_parsed, ] assert info.parsed_scoped_addresses(r.IPVersion.V4Only) == [address_parsed] assert info.parsed_scoped_addresses(r.IPVersion.V6Only) == [ address_v6_parsed, - address_v6_ll_scoped_parsed if ipaddress_supports_scope_id else address_v6_ll_parsed, + address_v6_ll_scoped_parsed, ] -@unittest.skipIf(sys.version_info < (3, 9, 0), "Requires newer python") def test_scoped_addresses_from_cache(): type_ = "_http._tcp.local." registration_name = f"scoped.{type_}" diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index ddade4867..35d119130 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -2,10 +2,6 @@ """Unit tests for zeroconf._utils.ipaddress.""" -import sys - -import pytest - from zeroconf import const from zeroconf._dns import DNSAddress from zeroconf._utils import ipaddress @@ -52,7 +48,6 @@ def test_cached_ip_addresses_wrapper(): assert ipv6.is_unspecified is True -@pytest.mark.skipif(sys.version_info < (3, 9, 0), reason="scope_id is not supported") def test_get_ip_address_object_from_record(): """Test the get_ip_address_object_from_record.""" # not link local From 45c82d1bbcc1de163487e6b55ccdb528ee952cc0 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Wed, 8 Jan 2025 21:16:25 +0100 Subject: [PATCH 1150/1433] chore(git): make scripts with shebang executable (#1433) Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 1 + examples/async_apple_scanner.py | 2 +- examples/async_browser.py | 2 +- examples/async_registration.py | 3 ++- examples/async_service_info_request.py | 3 ++- examples/browser.py | 2 +- examples/registration.py | 2 +- examples/resolver.py | 2 +- examples/self_test.py | 2 +- tests/conftest.py | 3 --- tests/services/test_browser.py | 3 --- tests/services/test_info.py | 3 --- tests/services/test_registry.py | 3 --- tests/services/test_types.py | 3 --- tests/test_asyncio.py | 3 --- tests/test_cache.py | 3 --- tests/test_core.py | 3 --- tests/test_dns.py | 3 --- tests/test_engine.py | 3 --- tests/test_exceptions.py | 3 --- tests/test_handlers.py | 3 --- tests/test_history.py | 3 --- tests/test_init.py | 3 --- tests/test_listener.py | 3 --- tests/test_logger.py | 3 --- tests/test_protocol.py | 3 --- tests/test_services.py | 3 --- tests/test_updates.py | 3 --- tests/utils/test_asyncio.py | 3 --- tests/utils/test_ipaddress.py | 2 -- tests/utils/test_name.py | 3 --- tests/utils/test_net.py | 3 --- 32 files changed, 11 insertions(+), 76 deletions(-) mode change 100644 => 100755 examples/async_apple_scanner.py mode change 100644 => 100755 examples/async_browser.py mode change 100644 => 100755 examples/async_registration.py mode change 100644 => 100755 examples/async_service_info_request.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7f19ac500..5c4c252fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ repos: - id: check-case-conflict - id: check-docstring-first - id: check-json + - id: check-shebang-scripts-are-executable - id: check-toml - id: check-xml - id: check-yaml diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py old mode 100644 new mode 100755 index 29eb5f70f..1d2c53067 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Scan for apple devices.""" diff --git a/examples/async_browser.py b/examples/async_browser.py old mode 100644 new mode 100755 index bc5f252ee..78be3a4c5 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Example of browsing for a service. diff --git a/examples/async_registration.py b/examples/async_registration.py old mode 100644 new mode 100755 index a75b5566a..56cb91f21 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -1,4 +1,5 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python + """Example of announcing 250 services (in this case, a fake HTTP server).""" import argparse diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py old mode 100644 new mode 100755 index 318647566..b904fd897 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -1,4 +1,5 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python + """Example of perodic dump of homekit services. This example is useful when a user wants an ondemand diff --git a/examples/browser.py b/examples/browser.py index aebf3f5d4..4e7b76103 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Example of browsing for a service. diff --git a/examples/registration.py b/examples/registration.py index 5be9f45d7..1c42d890c 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Example of announcing a service (in this case, a fake HTTP server)""" diff --git a/examples/resolver.py b/examples/resolver.py index e7a11f820..1b74f97ef 100755 --- a/examples/resolver.py +++ b/examples/resolver.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Example of resolving a service with a known name""" diff --git a/examples/self_test.py b/examples/self_test.py index 35f83b062..1fec3921a 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python import logging import socket diff --git a/tests/conftest.py b/tests/conftest.py index 5dfd900f7..ba49cef6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """conftest for zeroconf tests.""" import threading diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 0afc5ebc2..f3b977fb5 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._services.browser.""" import asyncio diff --git a/tests/services/test_info.py b/tests/services/test_info.py index ad05f8249..5573eed1c 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._services.info.""" import asyncio diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index d3f60179a..999e422c0 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._services.registry.""" import socket diff --git a/tests/services/test_types.py b/tests/services/test_types.py index f50ea42c1..811b22c53 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._services.types.""" import logging diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 54a8b400c..2471733b6 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for aio.py.""" import asyncio diff --git a/tests/test_cache.py b/tests/test_cache.py index 363fcb0e6..b39a58c84 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._cache.""" import logging diff --git a/tests/test_core.py b/tests/test_core.py index 71245a5f7..820559689 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._core""" import asyncio diff --git a/tests/test_dns.py b/tests/test_dns.py index 95d4b5532..f44affc8a 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._dns.""" import logging diff --git a/tests/test_engine.py b/tests/test_engine.py index 88307e320..79560d9ce 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._engine""" import asyncio diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 1373d6c38..1f5bd7387 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._exceptions""" import logging diff --git a/tests/test_handlers.py b/tests/test_handlers.py index b98ef407b..7b7abcea8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._handlers""" import asyncio diff --git a/tests/test_history.py b/tests/test_history.py index 659e67f8e..c604d3832 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for _history.py.""" from typing import Set diff --git a/tests/test_init.py b/tests/test_init.py index d7a012245..3ae695c55 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf.py""" import logging diff --git a/tests/test_listener.py b/tests/test_listener.py index f6752af78..f5af91f82 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._listener""" import logging diff --git a/tests/test_logger.py b/tests/test_logger.py index 7a9b48676..ecaf9dd01 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for logger.py.""" import logging diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8f124c175..1feb64c58 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._protocol""" import copy diff --git a/tests/test_services.py b/tests/test_services.py index 7cc075e78..908782c7b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._services.""" import logging diff --git a/tests/test_updates.py b/tests/test_updates.py index 2ebaee89d..1af85736b 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._updates.""" import logging diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 7b086fbc1..f22d85ede 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._utils.asyncio.""" import asyncio diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index 35d119130..c6f63aafc 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - """Unit tests for zeroconf._utils.ipaddress.""" from zeroconf import const diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index c814e094d..6f2c6b138 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._utils.name.""" import socket diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index a89ea565c..17212af23 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python - - """Unit tests for zeroconf._utils.net.""" import errno From ebbb2afccabd3841a3cb0a39824b49773cc6258a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jan 2025 10:29:36 -1000 Subject: [PATCH 1151/1433] feat: improve performance of processing incoming records (#1467) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/zeroconf/_handlers/record_manager.pxd | 4 ++-- src/zeroconf/_handlers/record_manager.py | 8 ++++++-- src/zeroconf/_record_update.pxd | 2 ++ src/zeroconf/_record_update.py | 8 +++++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 5be2c283b..d4e068c2e 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -6,12 +6,11 @@ from .._dns cimport DNSQuestion, DNSRecord from .._protocol.incoming cimport DNSIncoming from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis - +from .._record_update cimport RecordUpdate cdef cython.float _DNS_PTR_MIN_TTL cdef cython.uint _TYPE_PTR cdef object _ADDRESS_RECORD_TYPES -cdef object RecordUpdate cdef bint TYPE_CHECKING cdef object _TYPE_PTR @@ -31,6 +30,7 @@ cdef class RecordManager: record=DNSRecord, answers=cython.list, maybe_entry=DNSRecord, + rec_update=RecordUpdate ) cpdef void async_updates_from_response(self, DNSIncoming msg) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 0bb049966..5f25ceb11 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -120,11 +120,15 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: address_adds.append(record) else: other_adds.append(record) - updates.append(RecordUpdate(record, maybe_entry)) + rec_update = RecordUpdate.__new__(RecordUpdate) + rec_update._fast_init(record, maybe_entry) + updates.append(rec_update) # This is likely a goodbye since the record is # expired and exists in the cache elif maybe_entry is not None: - updates.append(RecordUpdate(record, maybe_entry)) + rec_update = RecordUpdate.__new__(RecordUpdate) + rec_update._fast_init(record, maybe_entry) + updates.append(rec_update) removes.add(record) if unique_types: diff --git a/src/zeroconf/_record_update.pxd b/src/zeroconf/_record_update.pxd index d1b18cbe0..1562299b2 100644 --- a/src/zeroconf/_record_update.pxd +++ b/src/zeroconf/_record_update.pxd @@ -8,3 +8,5 @@ cdef class RecordUpdate: cdef public DNSRecord new cdef public DNSRecord old + + cdef void _fast_init(self, object new, object old) diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index 880b7a1b2..912ab6f1d 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -24,12 +24,18 @@ from ._dns import DNSRecord +_DNSRecord = DNSRecord + class RecordUpdate: __slots__ = ("new", "old") - def __init__(self, new: DNSRecord, old: Optional[DNSRecord] = None): + def __init__(self, new: DNSRecord, old: Optional[DNSRecord] = None) -> None: """RecordUpdate represents a change in a DNS record.""" + self._fast_init(new, old) + + def _fast_init(self, new: _DNSRecord, old: Optional[_DNSRecord]) -> None: + """Fast init for RecordUpdate.""" self.new = new self.old = old From afd4517f7ca9147a3cfbaef979e01ff81fd639d7 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 8 Jan 2025 20:30:53 +0000 Subject: [PATCH 1152/1433] 0.138.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d313ef3e9..a2610460a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.138.0 (2025-01-08) + +### Features + +* feat: improve performance of processing incoming records (#1467) + +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ([`ebbb2af`](https://github.com/python-zeroconf/python-zeroconf/commit/ebbb2afccabd3841a3cb0a39824b49773cc6258a)) + + ## v0.137.2 (2025-01-06) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 8343292d9..b2905ec90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.137.2" +version = "0.138.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6d2467158..ffc066bb8 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.137.2" +__version__ = "0.138.0" __license__ = "LGPL" From e05055c584ca46080990437b2b385a187bc48458 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jan 2025 11:48:24 -1000 Subject: [PATCH 1153/1433] fix: ensure cache does not return stale created and ttl values (#1469) --- src/zeroconf/_cache.pxd | 15 ++++++-- src/zeroconf/_cache.py | 24 +++++++------ tests/test_cache.py | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index d44174667..a1402c22b 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -47,12 +47,13 @@ cdef class DNSCache: ) cpdef list async_all_by_details(self, str name, unsigned int type_, unsigned int class_) - cpdef cython.dict async_entries_with_name(self, str name) + cpdef list async_entries_with_name(self, str name) - cpdef cython.dict async_entries_with_server(self, str name) + cpdef list async_entries_with_server(self, str name) @cython.locals( cached_entry=DNSRecord, + records=dict ) cpdef DNSRecord get_by_details(self, str name, unsigned int type_, unsigned int class_) @@ -79,7 +80,15 @@ cdef class DNSCache: ) cpdef void async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) - cpdef entries_with_name(self, str name) + @cython.locals( + entries=dict + ) + cpdef list entries_with_name(self, str name) + + @cython.locals( + entries=dict + ) + cpdef list entries_with_server(self, str server) @cython.locals( record=DNSRecord, diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index f34c4c16b..b6c9b82d9 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -149,26 +149,26 @@ def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DN matches: List[DNSRecord] = [] if records is None: return matches - for record in records: + for record in records.values(): if type_ == record.type and class_ == record.class_: matches.append(record) return matches - def async_entries_with_name(self, name: str) -> Dict[DNSRecord, DNSRecord]: + def async_entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a dict of entries whose key matches the name. This function is not threadsafe and must be called from the event loop. """ - return self.cache.get(name.lower()) or {} + return self.entries_with_name(name) - def async_entries_with_server(self, name: str) -> Dict[DNSRecord, DNSRecord]: + def async_entries_with_server(self, name: str) -> List[DNSRecord]: """Returns a dict of entries whose key matches the server. This function is not threadsafe and must be called from the event loop. """ - return self.service_cache.get(name.lower()) or {} + return self.entries_with_server(name) # The below functions are threadsafe and do not need to be run in the # event loop, however they all make copies so they significantly @@ -179,7 +179,7 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: matching entry.""" if isinstance(entry, _UNIQUE_RECORD_TYPES): return self.cache.get(entry.key, {}).get(entry) - for cached_entry in reversed(list(self.cache.get(entry.key, []))): + for cached_entry in reversed(list(self.cache.get(entry.key, {}).values())): if entry.__eq__(cached_entry): return cached_entry return None @@ -200,7 +200,7 @@ def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRe records = self.cache.get(key) if records is None: return None - for cached_entry in reversed(list(records)): + for cached_entry in reversed(list(records.values())): if type_ == cached_entry.type and class_ == cached_entry.class_: return cached_entry return None @@ -211,15 +211,19 @@ def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRe records = self.cache.get(key) if records is None: return [] - return [entry for entry in list(records) if type_ == entry.type and class_ == entry.class_] + return [entry for entry in list(records.values()) if type_ == entry.type and class_ == entry.class_] def entries_with_server(self, server: str) -> List[DNSRecord]: """Returns a list of entries whose server matches the name.""" - return list(self.service_cache.get(server.lower(), [])) + if entries := self.service_cache.get(server.lower()): + return list(entries.values()) + return [] def entries_with_name(self, name: str) -> List[DNSRecord]: """Returns a list of entries whose key matches the name.""" - return list(self.cache.get(name.lower(), [])) + if entries := self.cache.get(name.lower()): + return list(entries.values()) + return [] def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: now = current_time_millis() diff --git a/tests/test_cache.py b/tests/test_cache.py index b39a58c84..63f233734 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -279,3 +279,82 @@ def test_name(self): cache = r.DNSCache() cache.async_add_records([record1, record2]) assert cache.names() == ["irrelevant"] + + +def test_async_entries_with_name_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=1.0) + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + assert next(iter(cache.async_entries_with_name("a"))) is record2 + + +def test_async_entries_with_server_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSService("a", const._TYPE_SRV, const._CLASS_IN, 1, 1, 1, 1, "a", created=1.0) + record2 = r.DNSService("a", const._TYPE_SRV, const._CLASS_IN, 1, 1, 1, 1, "a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + assert next(iter(cache.async_entries_with_server("a"))) is record2 + + +def test_async_get_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=1.0) + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + assert cache.get(record2) is record2 + + +def test_async_get_returns_newest_nsec_record(): + cache = r.DNSCache() + record1 = r.DNSNsec("a", const._TYPE_NSEC, const._CLASS_IN, 1, "a", [], created=1.0) + record2 = r.DNSNsec("a", const._TYPE_NSEC, const._CLASS_IN, 1, "a", [], created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + assert cache.get(record2) is record2 + + +def test_get_by_details_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=1.0) + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + assert cache.get_by_details("a", const._TYPE_A, const._CLASS_IN) is record2 + + +def test_get_all_by_details_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=1.0) + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + records = cache.get_all_by_details("a", const._TYPE_A, const._CLASS_IN) + assert len(records) == 1 + assert records[0] is record2 + + +def test_async_get_all_by_details_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=1.0) + record2 = r.DNSAddress("a", const._TYPE_A, const._CLASS_IN, 1, b"a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + records = cache.async_all_by_details("a", const._TYPE_A, const._CLASS_IN) + assert len(records) == 1 + assert records[0] is record2 + + +def test_async_get_unique_returns_newest_record(): + cache = r.DNSCache() + record1 = r.DNSPointer("a", const._TYPE_PTR, const._CLASS_IN, 1, "a", created=1.0) + record2 = r.DNSPointer("a", const._TYPE_PTR, const._CLASS_IN, 1, "a", created=2.0) + cache.async_add_records([record1]) + cache.async_add_records([record2]) + record = cache.async_get_unique(record1) + assert record is record2 + record = cache.async_get_unique(record2) + assert record is record2 From 6de7bb6315ddd909a60fd0c7a02dadf04b408454 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 8 Jan 2025 21:57:38 +0000 Subject: [PATCH 1154/1433] 0.138.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2610460a..00c797c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.138.1 (2025-01-08) + +### Bug Fixes + +* fix: ensure cache does not return stale created and ttl values (#1469) ([`e05055c`](https://github.com/python-zeroconf/python-zeroconf/commit/e05055c584ca46080990437b2b385a187bc48458)) + + ## v0.138.0 (2025-01-08) ### Features diff --git a/pyproject.toml b/pyproject.toml index b2905ec90..61481c40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.138.0" +version = "0.138.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index ffc066bb8..cf1464908 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.138.0" +__version__ = "0.138.1" __license__ = "LGPL" From 09db1848957b34415f364b7338e4adce99b57abc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jan 2025 14:16:02 -1000 Subject: [PATCH 1155/1433] feat: implement heapq for tracking cache expire times (#1465) --- src/zeroconf/_cache.pxd | 16 ++- src/zeroconf/_cache.py | 62 +++++++++++- src/zeroconf/_dns.pxd | 4 +- src/zeroconf/_dns.py | 12 +-- src/zeroconf/_handlers/record_manager.py | 12 +-- tests/services/test_browser.py | 4 +- tests/services/test_info.py | 2 +- tests/test_cache.py | 123 +++++++++++++++++++++++ tests/test_dns.py | 55 +++++----- tests/test_handlers.py | 34 +++++-- 10 files changed, 263 insertions(+), 61 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index a1402c22b..7f78a736e 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -11,10 +11,14 @@ from ._dns cimport ( DNSText, ) +cdef object heappop +cdef object heappush +cdef object heapify cdef object _UNIQUE_RECORD_TYPES cdef unsigned int _TYPE_PTR cdef cython.uint _ONE_SECOND +cdef unsigned int _MIN_SCHEDULED_RECORD_EXPIRATION @cython.locals( record_cache=dict, @@ -26,6 +30,8 @@ cdef class DNSCache: cdef public cython.dict cache cdef public cython.dict service_cache + cdef public list _expire_heap + cdef public dict _expirations cpdef bint async_add_records(self, object entries) @@ -65,7 +71,8 @@ cdef class DNSCache: @cython.locals( store=cython.dict, - service_record=DNSService + service_record=DNSService, + when=object ) cdef bint _async_add(self, DNSRecord record) @@ -95,3 +102,10 @@ cdef class DNSCache: now=double ) cpdef current_entry_with_name_and_alias(self, str name, str alias) + + cpdef void _async_set_created_ttl( + self, + DNSRecord record, + double now, + cython.float ttl + ) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index b6c9b82d9..a43bdc5c5 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -20,6 +20,7 @@ USA """ +from heapq import heapify, heappop, heappush from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast from ._dns import ( @@ -43,6 +44,11 @@ _float = float _int = int +# The minimum number of scheduled record expirations before we start cleaning up +# the expiration heap. This is a performance optimization to avoid cleaning up the +# heap too often when there are only a few scheduled expirations. +_MIN_SCHEDULED_RECORD_EXPIRATION = 100 + def _remove_key(cache: _DNSRecordCacheType, key: _str, record: _DNSRecord) -> None: """Remove a key from a DNSRecord cache @@ -60,6 +66,8 @@ class DNSCache: def __init__(self) -> None: self.cache: _DNSRecordCacheType = {} + self._expire_heap: List[Tuple[float, DNSRecord]] = [] + self._expirations: Dict[DNSRecord, float] = {} self.service_cache: _DNSRecordCacheType = {} # Functions prefixed with async_ are NOT threadsafe and must @@ -81,6 +89,12 @@ def _async_add(self, record: _DNSRecord) -> bool: store = self.cache.setdefault(record.key, {}) new = record not in store and not isinstance(record, DNSNsec) store[record] = record + when = record.created + (record.ttl * 1000) + if self._expirations.get(record) != when: + # Avoid adding duplicates to the heap + heappush(self._expire_heap, (when, record)) + self._expirations[record] = when + if isinstance(record, DNSService): service_record = record self.service_cache.setdefault(record.server_key, {})[service_record] = service_record @@ -108,6 +122,7 @@ def _async_remove(self, record: _DNSRecord) -> None: service_record = record _remove_key(self.service_cache, service_record.server_key, service_record) _remove_key(self.cache, record.key, record) + self._expirations.pop(record, None) def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: """Remove multiple records. @@ -121,8 +136,44 @@ def async_expire(self, now: _float) -> List[DNSRecord]: """Purge expired entries from the cache. This function must be run in from event loop. + + :param now: The current time in milliseconds. """ - expired = [record for records in self.cache.values() for record in records if record.is_expired(now)] + if not (expire_heap_len := len(self._expire_heap)): + return [] + + expired: List[DNSRecord] = [] + # Find any expired records and add them to the to-delete list + while self._expire_heap: + when, record = self._expire_heap[0] + if when > now: + break + heappop(self._expire_heap) + # Check if the record hasn't been re-added to the heap + # with a different expiration time as it will be removed + # later when it reaches the top of the heap and its + # expiration time is met. + if self._expirations.get(record) == when: + expired.append(record) + + # If the expiration heap grows larger than the number expirations + # times two, we clean it up to avoid keeping expired entries in + # the heap and consuming memory. We guard this with a minimum + # threshold to avoid cleaning up the heap too often when there are + # only a few scheduled expirations. + if ( + expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION + and expire_heap_len > len(self._expirations) * 2 + ): + # Remove any expired entries from the expiration heap + # that do not match the expiration time in the expirations + # as it means the record has been re-added to the heap + # with a different expiration time. + self._expire_heap = [ + entry for entry in self._expire_heap if self._expirations.get(entry[1]) == entry[0] + ] + heapify(self._expire_heap) + self.async_remove_records(expired) return expired @@ -256,4 +307,11 @@ def async_mark_unique_records_older_than_1s_to_expire( created_double = record.created if (now - created_double > _ONE_SECOND) and record not in answers_rrset: # Expire in 1s - record.set_created_ttl(now, 1) + self._async_set_created_ttl(record, now, 1) + + def _async_set_created_ttl(self, record: DNSRecord, now: _float, ttl: _float) -> None: + """Set the created time and ttl of a record.""" + # It would be better if we made a copy instead of mutating the record + # in place, but records currently don't have a copy method. + record._set_created_ttl(now, ttl) + self._async_add(record) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 6e432a77a..e41ac4c34 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -66,9 +66,7 @@ cdef class DNSRecord(DNSEntry): cpdef bint is_recent(self, double now) - cpdef reset_ttl(self, DNSRecord other) - - cpdef set_created_ttl(self, double now, cython.float ttl) + cdef _set_created_ttl(self, double now, cython.float ttl) cdef class DNSAddress(DNSRecord): diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 471376e91..4fc8d2d65 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -185,6 +185,9 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException + def __lt__(self, other: "DNSRecord") -> bool: + return self.ttl < other.ttl + def suppressed_by(self, msg: "DNSIncoming") -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" @@ -222,13 +225,10 @@ def is_recent(self, now: _float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" return self.created + (_RECENT_TIME_MS * self.ttl) > now - def reset_ttl(self, other) -> None: # type: ignore[no-untyped-def] - """Sets this record's TTL and created time to that of - another record.""" - self.set_created_ttl(other.created, other.ttl) - - def set_created_ttl(self, created: _float, ttl: Union[float, int]) -> None: + def _set_created_ttl(self, created: _float, ttl: Union[float, int]) -> None: """Set the created and ttl of a record.""" + # It would be better if we made a copy instead of mutating the record + # in place, but records currently don't have a copy method. self.created = created self.ttl = ttl diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index 5f25ceb11..d4e2792c8 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -103,7 +103,8 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: record, _DNS_PTR_MIN_TTL, ) - record.set_created_ttl(record.created, _DNS_PTR_MIN_TTL) + # Safe because the record is never in the cache yet + record._set_created_ttl(record.created, _DNS_PTR_MIN_TTL) if record.unique: # https://tools.ietf.org/html/rfc6762#section-10.2 unique_types.add((record.name, record_type, record.class_)) @@ -113,13 +114,10 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: maybe_entry = cache.async_get_unique(record) if not record.is_expired(now): - if maybe_entry is not None: - maybe_entry.reset_ttl(record) + if record_type in _ADDRESS_RECORD_TYPES: + address_adds.append(record) else: - if record_type in _ADDRESS_RECORD_TYPES: - address_adds.append(record) - else: - other_adds.append(record) + other_adds.append(record) rec_update = RecordUpdate.__new__(RecordUpdate) rec_update._fast_init(record, maybe_entry) updates.append(rec_update) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index f3b977fb5..ba5ae52e5 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1509,9 +1509,9 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de ) # Force the ttl to be 1 second now = current_time_millis() - for cache_record in zc.cache.cache.values(): + for cache_record in list(zc.cache.cache.values()): for record in cache_record: - record.set_created_ttl(now, 1) + zc.cache._async_set_created_ttl(record, now, 1) time.sleep(0.3) info.port = 400 diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 5573eed1c..1b16fef83 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -242,7 +242,7 @@ def test_service_info_rejects_expired_records(self): ttl, b"\x04ff=0\x04ci=3\x04sf=0\x0bsh=6fLM5A==", ) - expired_record.set_created_ttl(1000, 1) + zc.cache._async_set_created_ttl(expired_record, 1000, 1) info.async_update_records(zc, now, [RecordUpdate(expired_record, None)]) assert info.properties[b"ci"] == b"2" zc.close() diff --git a/tests/test_cache.py b/tests/test_cache.py index 63f233734..99de9827f 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,6 +3,9 @@ import logging import unittest import unittest.mock +from heapq import heapify, heappop + +import pytest import zeroconf as r from zeroconf import const @@ -358,3 +361,123 @@ def test_async_get_unique_returns_newest_record(): assert record is record2 record = cache.async_get_unique(record2) assert record is record2 + + +@pytest.mark.asyncio +async def test_cache_heap_cleanup() -> None: + """Test that the heap gets cleaned up when there are many old expirations.""" + cache = r.DNSCache() + # The heap should not be cleaned up when there are less than 100 expiration changes + min_records_to_cleanup = 100 + now = r.current_time_millis() + name = "heap.local." + ttl_seconds = 100 + ttl_millis = ttl_seconds * 1000 + + for i in range(min_records_to_cleanup): + record = r.DNSAddress(name, const._TYPE_A, const._CLASS_IN, ttl_seconds, b"1", created=now + i) + cache.async_add_records([record]) + + assert len(cache._expire_heap) == min_records_to_cleanup + assert len(cache.async_entries_with_name(name)) == 1 + + # Now that we reached the minimum number of cookies to cleanup, + # add one more cookie to trigger the cleanup + record = r.DNSAddress( + name, const._TYPE_A, const._CLASS_IN, ttl_seconds, b"1", created=now + min_records_to_cleanup + ) + expected_expire_time = record.created + ttl_millis + cache.async_add_records([record]) + assert len(cache.async_entries_with_name(name)) == 1 + entry = next(iter(cache.async_entries_with_name(name))) + assert (entry.created + ttl_millis) == expected_expire_time + assert entry is record + + # Verify that the heap has been cleaned up + assert len(cache.async_entries_with_name(name)) == 1 + cache.async_expire(now) + + heap_copy = cache._expire_heap.copy() + heapify(heap_copy) + # Ensure heap order is maintained + assert cache._expire_heap == heap_copy + + # The heap should have been cleaned up + assert len(cache._expire_heap) == 1 + assert len(cache.async_entries_with_name(name)) == 1 + + entry = next(iter(cache.async_entries_with_name(name))) + assert entry is record + + assert (entry.created + ttl_millis) == expected_expire_time + + cache.async_expire(expected_expire_time) + assert not cache.async_entries_with_name(name), cache._expire_heap + + +@pytest.mark.asyncio +async def test_cache_heap_multi_name_cleanup() -> None: + """Test cleanup with multiple names.""" + cache = r.DNSCache() + # The heap should not be cleaned up when there are less than 100 expiration changes + min_records_to_cleanup = 100 + now = r.current_time_millis() + name = "heap.local." + name2 = "heap2.local." + ttl_seconds = 100 + ttl_millis = ttl_seconds * 1000 + + for i in range(min_records_to_cleanup): + record = r.DNSAddress(name, const._TYPE_A, const._CLASS_IN, ttl_seconds, b"1", created=now + i) + cache.async_add_records([record]) + expected_expire_time = record.created + ttl_millis + + for i in range(5): + record = r.DNSAddress( + name2, const._TYPE_A, const._CLASS_IN, ttl_seconds, bytes((i,)), created=now + i + ) + cache.async_add_records([record]) + + assert len(cache._expire_heap) == min_records_to_cleanup + 5 + assert len(cache.async_entries_with_name(name)) == 1 + assert len(cache.async_entries_with_name(name2)) == 5 + + cache.async_expire(now) + # The heap and expirations should have been cleaned up + assert len(cache._expire_heap) == 1 + 5 + assert len(cache._expirations) == 1 + 5 + + cache.async_expire(expected_expire_time) + assert not cache.async_entries_with_name(name), cache._expire_heap + + +@pytest.mark.asyncio +async def test_cache_heap_pops_order() -> None: + """Test cache heap is popped in order.""" + cache = r.DNSCache() + # The heap should not be cleaned up when there are less than 100 expiration changes + min_records_to_cleanup = 100 + now = r.current_time_millis() + name = "heap.local." + name2 = "heap2.local." + ttl_seconds = 100 + + for i in range(min_records_to_cleanup): + record = r.DNSAddress(name, const._TYPE_A, const._CLASS_IN, ttl_seconds, b"1", created=now + i) + cache.async_add_records([record]) + + for i in range(5): + record = r.DNSAddress( + name2, const._TYPE_A, const._CLASS_IN, ttl_seconds, bytes((i,)), created=now + i + ) + cache.async_add_records([record]) + + assert len(cache._expire_heap) == min_records_to_cleanup + 5 + assert len(cache.async_entries_with_name(name)) == 1 + assert len(cache.async_entries_with_name(name2)) == 5 + + start_ts = 0.0 + while cache._expire_heap: + ts, _ = heappop(cache._expire_heap) + assert ts >= start_ts + start_ts = ts diff --git a/tests/test_dns.py b/tests/test_dns.py index f44affc8a..e9c4dc09f 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -96,34 +96,6 @@ def test_dns_record_abc(self): with pytest.raises((r.AbstractMethodException, TypeError)): record.write(None) # type: ignore[arg-type] - def test_dns_record_reset_ttl(self): - start = r.current_time_millis() - record = r.DNSRecord( - "irrelevant", - const._TYPE_SRV, - const._CLASS_IN, - const._DNS_HOST_TTL, - created=start, - ) - later = start + 1000 - record2 = r.DNSRecord( - "irrelevant", - const._TYPE_SRV, - const._CLASS_IN, - const._DNS_HOST_TTL, - created=later, - ) - now = r.current_time_millis() - - assert record.created != record2.created - assert record.get_remaining_ttl(now) != record2.get_remaining_ttl(now) - - record.reset_ttl(record2) - - assert record.ttl == record2.ttl - assert record.created == record2.created - assert record.get_remaining_ttl(now) == record2.get_remaining_ttl(now) - def test_service_info_dunder(self): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" @@ -233,6 +205,33 @@ def test_dns_record_hashablity_does_not_consider_ttl(): assert len(record_set) == 1 +def test_dns_record_hashablity_does_not_consider_created(): + """Test DNSRecord are hashable and created is not considered.""" + + # Verify the TTL is not considered in the hash + record1 = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same", created=1.0 + ) + record2 = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same", created=2.0 + ) + + record_set = {record1, record2} + assert len(record_set) == 1 + + record_set.add(record1) + assert len(record_set) == 1 + + record3_dupe = r.DNSAddress( + "irrelevant", const._TYPE_A, const._CLASS_IN, const._DNS_HOST_TTL, b"same", created=3.0 + ) + assert record2 == record3_dupe + assert record2.__hash__() == record3_dupe.__hash__() + + record_set.add(record3_dupe) + assert len(record_set) == 1 + + def test_dns_record_hashablity_does_not_consider_unique(): """Test DNSRecord are hashable and unique is ignored.""" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 7b7abcea8..8cf5cc9a8 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1139,10 +1139,9 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Add the A record to the cache with 50% ttl remaining a_record = info.dns_addresses()[0] - a_record.set_created_ttl(current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) + zc.cache._async_set_created_ttl(a_record, current_time_millis() - (a_record.ttl * 1000 / 2), a_record.ttl) assert not a_record.is_recent(current_time_millis()) info._dns_address_cache = None # we are mutating the record so clear the cache - zc.cache.async_add_records([a_record]) # With QU should respond to only unicast when the answer has been recently multicast # even if the additional has not been recently multicast @@ -1190,9 +1189,10 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # Remove the 100% PTR record and add a 50% PTR record zc.cache.async_remove_records([ptr_record]) - ptr_record.set_created_ttl(current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl) + zc.cache._async_set_created_ttl( + ptr_record, current_time_millis() - (ptr_record.ttl * 1000 / 2), ptr_record.ttl + ) assert not ptr_record.is_recent(current_time_millis()) - zc.cache.async_add_records([ptr_record]) # With QU should respond to only multicast since the has less # than 75% of its ttl remaining query = r.DNSOutgoing(const._FLAGS_QR_QUERY) @@ -1312,10 +1312,13 @@ async def test_cache_flush_bit(): for record in new_records: assert zc.cache.async_get_unique(record) is not None - cached_records = [zc.cache.async_get_unique(record) for record in new_records] - for cached_record in cached_records: - assert cached_record is not None - cached_record.created = current_time_millis() - 1500 + cached_record_group = [ + zc.cache.async_all_by_details(record.name, record.type, record.class_) for record in new_records + ] + for cached_records in cached_record_group: + for cached_record in cached_records: + assert cached_record is not None + cached_record.created = current_time_millis() - 1500 fresh_address = socket.inet_aton("4.4.4.4") info.addresses = [fresh_address] @@ -1325,9 +1328,18 @@ async def test_cache_flush_bit(): out.add_answer_at_time(answer, 0) for packet in out.packets(): zc.record_manager.async_updates_from_response(r.DNSIncoming(packet)) - for cached_record in cached_records: - assert cached_record is not None - assert cached_record.ttl == 1 + + cached_record_group = [ + zc.cache.async_all_by_details(record.name, record.type, record.class_) for record in new_records + ] + for cached_records in cached_record_group: + for cached_record in cached_records: + # the new record should not be set to 1 + if cached_record == answer: + assert cached_record.ttl != 1 + continue + assert cached_record is not None + assert cached_record.ttl == 1 for entry in zc.cache.async_all_by_details(server_name, const._TYPE_A, const._CLASS_IN): assert isinstance(entry, r.DNSAddress) From 2d1ffed0a8a7932388943dfe454a65a65cfa420c Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 9 Jan 2025 00:25:23 +0000 Subject: [PATCH 1156/1433] 0.139.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c797c42..b1e28c16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v0.139.0 (2025-01-09) + +### Features + +* feat: implement heapq for tracking cache expire times (#1465) ([`09db184`](https://github.com/python-zeroconf/python-zeroconf/commit/09db1848957b34415f364b7338e4adce99b57abc)) + + ## v0.138.1 (2025-01-08) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 61481c40f..8343c5e01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.138.1" +version = "0.139.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index cf1464908..2c4004ab6 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.138.1" +__version__ = "0.139.0" __license__ = "LGPL" From 35949881fb13057236de788756304c2de8d31ff9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jan 2025 16:45:48 -1000 Subject: [PATCH 1157/1433] chore: cleanup unused vars in pxd files (#1470) --- src/zeroconf/_services/info.pxd | 1 - src/zeroconf/_utils/ipaddress.pxd | 1 - 2 files changed, 2 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 6f1bef712..53abe62a6 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -47,7 +47,6 @@ cdef cython.set _ADDRESS_RECORD_TYPES cdef unsigned int _DUPLICATE_QUESTION_INTERVAL cdef bint TYPE_CHECKING -cdef bint IPADDRESS_SUPPORTS_SCOPE_ID cdef object cached_ip_addresses cdef object randint diff --git a/src/zeroconf/_utils/ipaddress.pxd b/src/zeroconf/_utils/ipaddress.pxd index 098c6ff9a..01d381640 100644 --- a/src/zeroconf/_utils/ipaddress.pxd +++ b/src/zeroconf/_utils/ipaddress.pxd @@ -1,5 +1,4 @@ cdef bint TYPE_CHECKING -cdef bint IPADDRESS_SUPPORTS_SCOPE_ID from .._dns cimport DNSAddress From 0dad54307d84875a0afef68d0a2f878c76e2e7e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jan 2025 11:32:39 -1000 Subject: [PATCH 1158/1433] chore: add benchmark for sending packets (#1471) --- tests/benchmarks/helpers.py | 153 +++++++++++++++++++++++++++++ tests/benchmarks/test_outgoing.py | 156 +----------------------------- tests/benchmarks/test_send.py | 22 +++++ 3 files changed, 178 insertions(+), 153 deletions(-) create mode 100644 tests/benchmarks/helpers.py create mode 100644 tests/benchmarks/test_send.py diff --git a/tests/benchmarks/helpers.py b/tests/benchmarks/helpers.py new file mode 100644 index 000000000..e701e0b64 --- /dev/null +++ b/tests/benchmarks/helpers.py @@ -0,0 +1,153 @@ +"""Benchmark helpers.""" + +import socket + +from zeroconf import DNSAddress, DNSOutgoing, DNSService, DNSText, const + + +def generate_packets() -> DNSOutgoing: + out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) + address = socket.inet_pton(socket.AF_INET, "192.168.208.5") + + additionals = [ + { + "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", + "address": address, + "port": 51832, + "text": b"\x13md=HASS Bridge" + b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=L0m/aQ==", + }, + { + "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", + "address": address, + "port": 51834, + "text": b"\x13md=HASS Bridge" + b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b2CnzQ==", + }, + { + "name": "Master Bed TV CEDB27._hap._tcp.local.", + "address": address, + "port": 51830, + "text": b"\x10md=Master Bed" + b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=CVj1kw==", + }, + { + "name": "Living Room TV 921B77._hap._tcp.local.", + "address": address, + "port": 51833, + "text": b"\x11md=Living Room" + b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=qU77SQ==", + }, + { + "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", + "address": address, + "port": 51829, + "text": b"\x13md=HASS Bridge" + b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=b0QZlg==", + }, + { + "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", + "address": address, + "port": 51837, + "text": b"\x13md=HASS Bridge" + b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=ahAISA==", + }, + { + "name": "FrontdoorCamera 8941D1._hap._tcp.local.", + "address": address, + "port": 54898, + "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" + b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", + }, + { + "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + "address": address, + "port": 51836, + "text": b"\x13md=HASS Bridge" + b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=6fLM5A==", + }, + { + "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", + "address": address, + "port": 51838, + "text": b"\x13md=HASS Bridge" + b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" + b"ci=2\x04sf=0\x0bsh=u3bdfw==", + }, + { + "name": "Snooze Room TV 6B89B0._hap._tcp.local.", + "address": address, + "port": 51835, + "text": b"\x11md=Snooze Room" + b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" + b"ci=31\x04sf=0\x0bsh=xNTqsg==", + }, + { + "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", + "address": address, + "port": 54811, + "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" + b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", + }, + { + "name": "HASS Bridge OS95 39C053._hap._tcp.local.", + "address": address, + "port": 51831, + "text": b"\x13md=HASS Bridge" + b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" + b"\x04sf=0\x0bsh=Xfe5LQ==", + }, + ] + + out.add_answer_at_time( + DNSText( + "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" + b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", + ), + 0, + ) + + for record in additionals: + out.add_additional_answer( + DNSService( + record["name"], # type: ignore + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + 0, + 0, + record["port"], # type: ignore + record["name"], # type: ignore + ) + ) + out.add_additional_answer( + DNSText( + record["name"], # type: ignore + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + record["text"], # type: ignore + ) + ) + out.add_additional_answer( + DNSAddress( + record["name"], # type: ignore + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_HOST_TTL, + record["address"], # type: ignore + ) + ) + + return out diff --git a/tests/benchmarks/test_outgoing.py b/tests/benchmarks/test_outgoing.py index 5b7ee164a..69de540ea 100644 --- a/tests/benchmarks/test_outgoing.py +++ b/tests/benchmarks/test_outgoing.py @@ -1,165 +1,15 @@ """Benchmark for DNSOutgoing.""" -import socket - from pytest_codspeed import BenchmarkFixture -from zeroconf import DNSAddress, DNSOutgoing, DNSService, DNSText, const from zeroconf._protocol.outgoing import State - -def generate_packets() -> DNSOutgoing: - out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) - address = socket.inet_pton(socket.AF_INET, "192.168.208.5") - - additionals = [ - { - "name": "HASS Bridge ZJWH FF5137._hap._tcp.local.", - "address": address, - "port": 51832, - "text": b"\x13md=HASS Bridge" - b" ZJWH\x06pv=1.0\x14id=01:6B:30:FF:51:37\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=L0m/aQ==", - }, - { - "name": "HASS Bridge 3K9A C2582A._hap._tcp.local.", - "address": address, - "port": 51834, - "text": b"\x13md=HASS Bridge" - b" 3K9A\x06pv=1.0\x14id=E2:AA:5B:C2:58:2A\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b2CnzQ==", - }, - { - "name": "Master Bed TV CEDB27._hap._tcp.local.", - "address": address, - "port": 51830, - "text": b"\x10md=Master Bed" - b" TV\x06pv=1.0\x14id=9E:B7:44:CE:DB:27\x05c#=18\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=CVj1kw==", - }, - { - "name": "Living Room TV 921B77._hap._tcp.local.", - "address": address, - "port": 51833, - "text": b"\x11md=Living Room" - b" TV\x06pv=1.0\x14id=11:61:E7:92:1B:77\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=qU77SQ==", - }, - { - "name": "HASS Bridge ZC8X FF413D._hap._tcp.local.", - "address": address, - "port": 51829, - "text": b"\x13md=HASS Bridge" - b" ZC8X\x06pv=1.0\x14id=96:14:45:FF:41:3D\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=b0QZlg==", - }, - { - "name": "HASS Bridge WLTF 4BE61F._hap._tcp.local.", - "address": address, - "port": 51837, - "text": b"\x13md=HASS Bridge" - b" WLTF\x06pv=1.0\x14id=E0:E7:98:4B:E6:1F\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=ahAISA==", - }, - { - "name": "FrontdoorCamera 8941D1._hap._tcp.local.", - "address": address, - "port": 54898, - "text": b"\x12md=FrontdoorCamera\x06pv=1.0\x14id=9F:B7:DC:89:41:D1\x04c#=2\x04" - b"s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=0+MXmA==", - }, - { - "name": "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - "address": address, - "port": 51836, - "text": b"\x13md=HASS Bridge" - b" W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=6fLM5A==", - }, - { - "name": "HASS Bridge Y9OO EFF0A7._hap._tcp.local.", - "address": address, - "port": 51838, - "text": b"\x13md=HASS Bridge" - b" Y9OO\x06pv=1.0\x14id=D3:FE:98:EF:F0:A7\x04c#=2\x04s#=1\x04ff=0\x04" - b"ci=2\x04sf=0\x0bsh=u3bdfw==", - }, - { - "name": "Snooze Room TV 6B89B0._hap._tcp.local.", - "address": address, - "port": 51835, - "text": b"\x11md=Snooze Room" - b" TV\x06pv=1.0\x14id=5F:D5:70:6B:89:B0\x05c#=17\x04s#=1\x04ff=0\x05" - b"ci=31\x04sf=0\x0bsh=xNTqsg==", - }, - { - "name": "AlexanderHomeAssistant 74651D._hap._tcp.local.", - "address": address, - "port": 54811, - "text": b"\x19md=AlexanderHomeAssistant\x06pv=1.0\x14id=59:8A:0B:74:65:1D\x05" - b"c#=14\x04s#=1\x04ff=0\x04ci=2\x04sf=0\x0bsh=ccZLPA==", - }, - { - "name": "HASS Bridge OS95 39C053._hap._tcp.local.", - "address": address, - "port": 51831, - "text": b"\x13md=HASS Bridge" - b" OS95\x06pv=1.0\x14id=7E:8C:E6:39:C0:53\x05c#=12\x04s#=1\x04ff=0\x04ci=2" - b"\x04sf=0\x0bsh=Xfe5LQ==", - }, - ] - - out.add_answer_at_time( - DNSText( - "HASS Bridge W9DN 5B5CC5._hap._tcp.local.", - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - b"\x13md=HASS Bridge W9DN\x06pv=1.0\x14id=11:8E:DB:5B:5C:C5\x05c#=12\x04s#=1" - b"\x04ff=0\x04ci=2\x04sf=0\x0bsh=6fLM5A==", - ), - 0, - ) - - for record in additionals: - out.add_additional_answer( - DNSService( - record["name"], # type: ignore - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - 0, - 0, - record["port"], # type: ignore - record["name"], # type: ignore - ) - ) - out.add_additional_answer( - DNSText( - record["name"], # type: ignore - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_OTHER_TTL, - record["text"], # type: ignore - ) - ) - out.add_additional_answer( - DNSAddress( - record["name"], # type: ignore - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL, - record["address"], # type: ignore - ) - ) - - return out - - -out = generate_packets() +from .helpers import generate_packets def test_parse_outgoing_message(benchmark: BenchmarkFixture) -> None: + out = generate_packets() + @benchmark def make_outgoing_message() -> None: out.packets() diff --git a/tests/benchmarks/test_send.py b/tests/benchmarks/test_send.py new file mode 100644 index 000000000..7a6d664b7 --- /dev/null +++ b/tests/benchmarks/test_send.py @@ -0,0 +1,22 @@ +"""Benchmark for sending packets.""" + +import pytest +from pytest_codspeed import BenchmarkFixture + +from zeroconf.asyncio import AsyncZeroconf + +from .helpers import generate_packets + + +@pytest.mark.asyncio +async def test_sending_packets(benchmark: BenchmarkFixture) -> None: + """Benchmark sending packets.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + out = generate_packets() + + @benchmark + def _send_packets() -> None: + aiozc.zeroconf.async_send(out) + + await aiozc.async_close() From 2197b9672bb9490a55b5f58b5acf0a5e0ce25837 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:40:08 -1000 Subject: [PATCH 1159/1433] chore(deps-dev): bump setuptools from 75.7.0 to 75.8.0 (#1473) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index e4a5fae5d..5ce877e41 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "async-timeout" @@ -522,13 +522,13 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "75.7.0" +version = "75.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-75.7.0-py3-none-any.whl", hash = "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183"}, - {file = "setuptools-75.7.0.tar.gz", hash = "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f"}, + {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, + {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, ] [package.extras] From ed02799ea1209cddac7e2d2d6428b72b95f5906a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:40:40 -1000 Subject: [PATCH 1160/1433] chore(pre-commit.ci): pre-commit autoupdate (#1480) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- src/zeroconf/_utils/net.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c4c252fc..8551ee8b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.9.1 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 4cd50926a..0eba9288f 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -254,7 +254,7 @@ def new_socket( except OSError as ex: if ex.errno == errno.EADDRNOTAVAIL: log.warning( - "Address not available when binding to %s, " "it is expected to happen on some systems", + "Address not available when binding to %s, it is expected to happen on some systems", bind_tup, ) return None @@ -295,8 +295,7 @@ def add_multicast_member( _errno = get_errno(e) if _errno == errno.EADDRINUSE: log.info( - "Address in use when adding %s to multicast group, " - "it is expected to happen on some systems", + "Address in use when adding %s to multicast group, it is expected to happen on some systems", interface, ) return False @@ -309,7 +308,7 @@ def add_multicast_member( return False if _errno in err_einval: log.info( - "Interface of %s does not support multicast, " "it is expected in WSL", + "Interface of %s does not support multicast, it is expected in WSL", interface, ) return False From 430491db91952ad03a058f5932436969fb4b06cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:40:57 -1000 Subject: [PATCH 1161/1433] chore(deps-dev): bump pytest-codspeed from 3.1.0 to 3.1.2 (#1475) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5ce877e41..939ecd226 100644 --- a/poetry.lock +++ b/poetry.lock @@ -440,22 +440,23 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "3.1.0" +version = "3.1.2" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" files = [ - {file = "pytest_codspeed-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb7c16e5a64cb30bad30f5204c7690f3cbc9ae5b9839ce187ef1727aa5d2d9c"}, - {file = "pytest_codspeed-3.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d23910893c22ceef6efbdf85d80e803b7fb4a231c9e7676ab08f5ddfc228438"}, - {file = "pytest_codspeed-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb1495a633a33e15268a1f97d91a4809c868de06319db50cf97b4e9fa426372c"}, - {file = "pytest_codspeed-3.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd8a54b99207bd25a4c3f64d9a83ac0f3def91cdd87204ca70a49f822ba919c"}, - {file = "pytest_codspeed-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4d1ac896ebaea5b365e69b41319b4d09b57dab85ec6234f6ff26116b3795f03"}, - {file = "pytest_codspeed-3.1.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f0c1857a0a6cce6a23c49f98c588c2eef66db353c76ecbb2fb65c1a2b33a8d5"}, - {file = "pytest_codspeed-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4731a7cf1d8d38f58140d51faa69b7c1401234c59d9759a2507df570c805b11"}, - {file = "pytest_codspeed-3.1.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f2e4b63260f65493b8d42c8167f831b8ed90788f81eb4eb95a103ee6aa4294"}, - {file = "pytest_codspeed-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db44099b3f1ec1c9c41f0267c4d57d94e31667f4cb3fb4b71901561e8ab8bc98"}, - {file = "pytest_codspeed-3.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a533c1ad3cc60f07be432864c83d1769ce2877753ac778e1bfc5a9821f5c6ddf"}, - {file = "pytest_codspeed-3.1.0.tar.gz", hash = "sha256:f29641d27b4ded133b1058a4c859e510a2612ad4217ef9a839ba61750abd2f8a"}, + {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aed496f873670ce0ea8f980a7c1a2c6a08f415e0ebdf207bf651b2d922103374"}, + {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee45b0b763f6b5fa5d74c7b91d694a9615561c428b320383660672f4471756e3"}, + {file = "pytest_codspeed-3.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c84e591a7a0f67d45e2dc9fd05b276971a3aabcab7478fe43363ebefec1358f4"}, + {file = "pytest_codspeed-3.1.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6ae6d094247156407770e6b517af70b98862dd59a3c31034aede11d5f71c32c"}, + {file = "pytest_codspeed-3.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0f264991de5b5cdc118b96fc671386cca3f0f34e411482939bf2459dc599097"}, + {file = "pytest_codspeed-3.1.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0695a4bcd5ff04e8379124dba5d9795ea5e0cadf38be7a0406432fc1467b555"}, + {file = "pytest_codspeed-3.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc356c8dcaaa883af83310f397ac06c96fac9b8a1146e303d4b374b2cb46a18"}, + {file = "pytest_codspeed-3.1.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc8a5d0366322a75cf562f7d8d672d28c1cf6948695c4dddca50331e08f6b3d5"}, + {file = "pytest_codspeed-3.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c5fe7a19b72f54f217480b3b527102579547b1de9fe3acd9e66cb4629ff46c8"}, + {file = "pytest_codspeed-3.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67205755a665593f6521a98317d02a9d07d6fdc593f6634de2c94dea47a3055"}, + {file = "pytest_codspeed-3.1.2-py3-none-any.whl", hash = "sha256:5e7ed0315e33496c5c07dba262b50303b8d0bc4c3d10bf1d422a41e70783f1cb"}, + {file = "pytest_codspeed-3.1.2.tar.gz", hash = "sha256:09c1733af3aab35e94a621aa510f2d2114f65591e6f644c42ca3f67547edad4b"}, ] [package.dependencies] From 6f3430f334761092f0ced7d5e5065a3710eb4ad5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:41:12 -1000 Subject: [PATCH 1162/1433] chore(deps-dev): bump pytest-asyncio from 0.24.0 to 0.25.2 (#1476) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 939ecd226..56a2b910a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -422,20 +422,20 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "0.25.2" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, - {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, + {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, + {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, ] [package.dependencies] pytest = ">=8.2,<9" [package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] @@ -615,4 +615,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "b2255f56e331fb25e626030bf4ad11e7424d28cb1b7dd0310b9c704ee39bb0e1" +content-hash = "f5c250deb75c032aed220cdb67ee2a16316143cec5458a8bb99fd9bafbdbf1ad" diff --git a/pyproject.toml b/pyproject.toml index 8343c5e01..ad4618901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = ">=4,<6" -pytest-asyncio = ">=0.20.3,<0.25.0" +pytest-asyncio = ">=0.20.3,<0.26.0" cython = "^3.0.5" setuptools = ">=65.6.3,<76.0.0" pytest-timeout = "^2.1.0" From b170d903868be4b13c1cef7bf5fb4b9e9bffba72 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 16 Jan 2025 23:41:50 +0100 Subject: [PATCH 1163/1433] chore(tests): replace `lru_cache` with `cache` (#1477) --- tests/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 82c09be7e..dc4524fb1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,7 +23,7 @@ import asyncio import socket import time -from functools import lru_cache +from functools import cache from typing import List, Optional, Set from unittest import mock @@ -62,7 +62,7 @@ def _wait_for_start(zc: Zeroconf) -> None: asyncio.run_coroutine_threadsafe(zc.async_wait_for_start(), zc.loop).result() -@lru_cache(maxsize=None) +@cache def has_working_ipv6(): """Return True if if the system can bind an IPv6 address.""" if not socket.has_ipv6: From ba2ee5a2b48e9e2a378889f1e46c9ed00349b457 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 16 Jan 2025 23:42:09 +0100 Subject: [PATCH 1164/1433] chore: remove outdated requirements-dev.txt (#1478) --- requirements-dev.txt | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 1054014ed..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,16 +0,0 @@ -async_timeout>=4.0.1 -autopep8 -black;implementation_name=="cpython" -bump2version -coverage -flake8 -flake8-import-order -ifaddr -mypy;implementation_name=="cpython" -pep8-naming>=0.12.0 -pylint -pytest -pytest-asyncio -pytest-cov -pytest-timeout -readme_renderer From d20d8c1b4db2dd2ff3818091a1bbd973caa6acd6 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Thu, 16 Jan 2025 23:42:53 +0100 Subject: [PATCH 1165/1433] chore: migrate to f-string (#1481) --- examples/async_apple_scanner.py | 12 ++++++------ examples/async_browser.py | 12 ++++++------ examples/async_service_info_request.py | 8 ++++---- examples/browser.py | 10 +++++----- examples/self_test.py | 2 +- pyproject.toml | 1 - src/zeroconf/_dns.py | 2 +- src/zeroconf/_protocol/incoming.py | 26 ++++++++++++++------------ src/zeroconf/_protocol/outgoing.py | 20 +++++++++++--------- src/zeroconf/_utils/net.py | 8 ++++---- tests/services/test_browser.py | 20 ++++++++++---------- tests/services/test_info.py | 26 +++++++++++++------------- tests/test_asyncio.py | 6 +++--- tests/test_core.py | 2 +- tests/test_init.py | 10 +++++----- tests/test_protocol.py | 6 +++--- 16 files changed, 87 insertions(+), 84 deletions(-) diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index 1d2c53067..e126e8f90 100755 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -55,12 +55,12 @@ def async_on_service_state_change( async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000, question_type=DNSQuestionType.QU) - print("Info from zeroconf.get_service_info: %r" % (info)) + print(f"Info from zeroconf.get_service_info: {info!r}") if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] - print(" Name: %s" % name) - print(" Addresses: %s" % ", ".join(addresses)) - print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + addresses = [f"{addr}:{cast(int, info.port)}" for addr in info.parsed_addresses()] + print(f" Name: {name}") + print(f" Addresses: {', '.join(addresses)}") + print(f" Weight: {info.weight}, priority: {info.priority}") print(f" Server: {info.server}") if info.properties: print(" Properties are:") @@ -82,7 +82,7 @@ def __init__(self, args: Any) -> None: async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=ip_version) await self.aiozc.zeroconf.async_wait_for_start() - print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % ALL_SERVICES) + print(f"\nBrowsing {ALL_SERVICES} service(s), press Ctrl-C to exit...\n") kwargs = { "handlers": [async_on_service_state_change], "question_type": DNSQuestionType.QU, diff --git a/examples/async_browser.py b/examples/async_browser.py index 78be3a4c5..31b55e4a8 100755 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -35,12 +35,12 @@ def async_on_service_state_change( async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name: str) -> None: info = AsyncServiceInfo(service_type, name) await info.async_request(zeroconf, 3000) - print("Info from zeroconf.get_service_info: %r" % (info)) + print(f"Info from zeroconf.get_service_info: {info!r}") if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] - print(" Name: %s" % name) - print(" Addresses: %s" % ", ".join(addresses)) - print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + addresses = [f"{addr}:{cast(int, info.port)}" for addr in info.parsed_scoped_addresses()] + print(f" Name: {name}") + print(f" Addresses: {', '.join(addresses)}") + print(f" Weight: {info.weight}, priority: {info.priority}") print(f" Server: {info.server}") if info.properties: print(" Properties are:") @@ -68,7 +68,7 @@ async def async_run(self) -> None: await AsyncZeroconfServiceTypes.async_find(aiozc=self.aiozc, ip_version=ip_version) ) - print("\nBrowsing %s service(s), press Ctrl-C to exit...\n" % services) + print(f"\nBrowsing {services} service(s), press Ctrl-C to exit...\n") self.aiobrowser = AsyncServiceBrowser( self.aiozc.zeroconf, services, handlers=[async_on_service_state_change] ) diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index b904fd897..42df809d6 100755 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -30,11 +30,11 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: tasks = [info.async_request(aiozc.zeroconf, 3000) for info in infos] await asyncio.gather(*tasks) for info in infos: - print("Info for %s" % (info.name)) + print(f"Info for {info.name}") if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()] - print(" Addresses: %s" % ", ".join(addresses)) - print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + addresses = [f"{addr}:{cast(int, info.port)}" for addr in info.parsed_addresses()] + print(f" Addresses: {', '.join(addresses)}") + print(f" Weight: {info.weight}, priority: {info.priority}") print(f" Server: {info.server}") if info.properties: print(" Properties are:") diff --git a/examples/browser.py b/examples/browser.py index 4e7b76103..107be452f 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -26,12 +26,12 @@ def on_service_state_change( if state_change is ServiceStateChange.Added: info = zeroconf.get_service_info(service_type, name) - print("Info from zeroconf.get_service_info: %r" % (info)) + print(f"Info from zeroconf.get_service_info: {info!r}") if info: - addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()] - print(" Addresses: %s" % ", ".join(addresses)) - print(" Weight: %d, priority: %d" % (info.weight, info.priority)) + addresses = [f"{addr}:{cast(int, info.port)}" for addr in info.parsed_scoped_addresses()] + print(f" Addresses: {', '.join(addresses)}") + print(f" Weight: {info.weight}, priority: {info.priority}") print(f" Server: {info.server}") if info.properties: print(" Properties are:") @@ -75,7 +75,7 @@ def on_service_state_change( if args.find: services = list(ZeroconfServiceTypes.find(zc=zeroconf)) - print("\nBrowsing %d service(s), press Ctrl-C to exit...\n" % len(services)) + print(f"\nBrowsing {len(services)} service(s), press Ctrl-C to exit...\n") browser = ServiceBrowser(zeroconf, services, handlers=[on_service_state_change]) try: diff --git a/examples/self_test.py b/examples/self_test.py index 1fec3921a..b12a8518a 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -34,7 +34,7 @@ r.register_service(info) print(" Registration done.") print("2. Testing query of service information...") - print(" Getting ZOE service: %s" % (r.get_service_info("_http._tcp.local.", "ZOE._http._tcp.local."))) + print(f" Getting ZOE service: {r.get_service_info('_http._tcp.local.', 'ZOE._http._tcp.local.')}") print(" Query done.") print("3. Testing query of own service...") queried_info = r.get_service_info("_http._tcp.local.", "My Service Name._http._tcp.local.") diff --git a/pyproject.toml b/pyproject.toml index ad4618901..9da8d87d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,6 @@ line-length = 110 ignore = [ "S101", # use of assert "S104", # S104 Possible binding to all interfaces - "UP031", # UP031 use f-strings -- too many to fix right now ] select = [ "B", # flake8-bugbear diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 4fc8d2d65..c22f8b170 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -102,7 +102,7 @@ def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: self.get_class_(self.class_), "-unique" if self.unique else "", self.name, - "=%s" % cast(Any, other) if other is not None else "", + f"={cast(Any, other)}" if other is not None else "", ) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 5347f50d3..6e009b293 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -208,18 +208,20 @@ def is_probe(self) -> bool: return self._num_authorities > 0 def __repr__(self) -> str: - return "" % ", ".join( - [ - "id=%s" % self.id, - "flags=%s" % self.flags, - "truncated=%s" % self.truncated, - "n_q=%s" % self._num_questions, - "n_ans=%s" % self._num_answers, - "n_auth=%s" % self._num_authorities, - "n_add=%s" % self._num_additionals, - "questions=%s" % self._questions, - "answers=%s" % self.answers(), - ] + return "".format( + ", ".join( + [ + f"id={self.id}", + f"flags={self.flags}", + f"truncated={self.truncated}", + f"n_q={self._num_questions}", + f"n_ans={self._num_answers}", + f"n_auth={self._num_authorities}", + f"n_add={self._num_additionals}", + f"questions={self._questions}", + f"answers={self.answers()}", + ] + ) ) def _read_header(self) -> None: diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index b2eb9230d..c937350ed 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -128,15 +128,17 @@ def _reset_for_next_packet(self) -> None: self.allow_long = True def __repr__(self) -> str: - return "" % ", ".join( - [ - "multicast=%s" % self.multicast, - "flags=%s" % self.flags, - "questions=%s" % self.questions, - "answers=%s" % self.answers, - "authorities=%s" % self.authorities, - "additionals=%s" % self.additionals, - ] + return "".format( + ", ".join( + [ + f"multicast={self.multicast}", + f"flags={self.flags}", + f"questions={self.questions}", + f"answers={self.answers}", + f"authorities={self.authorities}", + f"additionals={self.additionals}", + ] + ) ) def add_question(self, record: DNSQuestion) -> None: diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 0eba9288f..7298bec4d 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -95,7 +95,7 @@ def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, i cast(int, adapter.index), ) - raise RuntimeError("No adapter found for IP address %s" % ip) + raise RuntimeError(f"No adapter found for IP address {ip}") def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: @@ -106,7 +106,7 @@ def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str if isinstance(adapter_ip.ip, tuple): return cast(Tuple[str, int, int], adapter_ip.ip) - raise RuntimeError("No adapter found for index %s" % index) + raise RuntimeError(f"No adapter found for index {index}") def ip6_addresses_to_indexes( @@ -154,7 +154,7 @@ def normalize_interface_choice( result.extend(get_all_addresses()) if not result: raise RuntimeError( - "No interfaces to listen on, check that any interfaces have IP version %s" % ip_version + f"No interfaces to listen on, check that any interfaces have IP version {ip_version}" ) elif isinstance(choice, list): # First, take IPv4 addresses. @@ -162,7 +162,7 @@ def normalize_interface_choice( # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. result += ip6_addresses_to_indexes(choice) else: - raise TypeError("choice must be a list or InterfaceChoice, got %r" % choice) + raise TypeError(f"choice must be a list or InterfaceChoice, got {choice!r}") return result diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index ba5ae52e5..f4d750e04 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -562,7 +562,7 @@ async def test_asking_default_is_asking_qm_questions_after_the_first_qu(): got_query = asyncio.Event() type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): if name == registration_name: @@ -664,7 +664,7 @@ async def test_ttl_refresh_cancelled_rescue_query(): got_query = asyncio.Event() type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): if name == registration_name: @@ -913,7 +913,7 @@ def test_service_browser_is_aware_of_port_changes(): zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" callbacks = [] @@ -977,7 +977,7 @@ def test_service_browser_listeners_update_service(): zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" callbacks = [] class MyServiceListener(r.ServiceListener): @@ -1042,7 +1042,7 @@ def test_service_browser_listeners_no_update_service(): zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" callbacks = [] class MyServiceListener(r.ServiceListener): @@ -1364,9 +1364,9 @@ def test_service_browser_matching(): zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" not_match_type_ = "_asustor-looksgood_http._tcp.local." - not_match_registration_name = "xxxyyy.%s" % not_match_type_ + not_match_registration_name = f"xxxyyy.{not_match_type_}" callbacks = [] class MyServiceListener(r.ServiceListener): @@ -1457,7 +1457,7 @@ def test_service_browser_expire_callbacks(): zc = Zeroconf(interfaces=["127.0.0.1"]) # start a browser type_ = "_old._tcp.local." - registration_name = "uniquezip323.%s" % type_ + registration_name = f"uniquezip323.{type_}" callbacks = [] class MyServiceListener(r.ServiceListener): @@ -1582,7 +1582,7 @@ async def test_close_zeroconf_without_browser_before_start_up_queries(): """Test that we stop sending startup queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): if name == registration_name: @@ -1651,7 +1651,7 @@ async def test_close_zeroconf_without_browser_after_start_up_queries(): service_added = asyncio.Event() type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): if name == registration_name: diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 1b16fef83..7051e6fe2 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -666,7 +666,7 @@ def test_service_info_duplicate_properties_txt_records(self): def test_multiple_addresses(): type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" desc = {"path": "/~paulsm/"} address_parsed = "10.0.1.2" address = socket.inet_aton(address_parsed) @@ -830,7 +830,7 @@ def test_scoped_addresses_from_cache(): async def test_multiple_a_addresses_newest_address_first(): """Test that info.addresses returns the newest seen address first.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) cache = aiozc.zeroconf.cache @@ -849,7 +849,7 @@ async def test_multiple_a_addresses_newest_address_first(): @pytest.mark.asyncio async def test_invalid_a_addresses(caplog): type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) cache = aiozc.zeroconf.cache @@ -1082,7 +1082,7 @@ def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): async def test_release_wait_when_new_recorded_added(): """Test that async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1147,7 +1147,7 @@ async def test_release_wait_when_new_recorded_added(): async def test_port_changes_are_seen(): """Test that port changes are seen by async_request.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1230,7 +1230,7 @@ async def test_port_changes_are_seen(): async def test_port_changes_are_seen_with_directed_request(): """Test that port changes are seen by async_request with a directed request.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1313,7 +1313,7 @@ async def test_port_changes_are_seen_with_directed_request(): async def test_ipv4_changes_are_seen(): """Test that ipv4 changes are seen by async_request.""" type_ = "_http._tcp.local." - registration_name = "multiaipv4rec.%s" % type_ + registration_name = f"multiaipv4rec.{type_}" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1401,7 +1401,7 @@ async def test_ipv4_changes_are_seen(): async def test_ipv6_changes_are_seen(): """Test that ipv6 changes are seen by async_request.""" type_ = "_http._tcp.local." - registration_name = "multiaipv6rec.%s" % type_ + registration_name = f"multiaipv6rec.{type_}" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1496,7 +1496,7 @@ async def test_ipv6_changes_are_seen(): async def test_bad_ip_addresses_ignored_in_cache(): """Test that bad ip address in the cache are ignored async_request.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1550,7 +1550,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): async def test_service_name_change_as_seen_has_ip_in_cache(): """Test that service name changes are seen by async_request when the ip is in the cache.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1632,7 +1632,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): async def test_service_name_change_as_seen_ip_not_in_cache(): """Test that service name changes are seen by async_request when the ip is not in the cache.""" type_ = "_http._tcp.local." - registration_name = "multiarec.%s" % type_ + registration_name = f"multiarec.{type_}" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahost.local." @@ -1715,7 +1715,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): async def test_release_wait_when_new_recorded_added_concurrency(): """Test that concurrent async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." - registration_name = "multiareccon.%s" % type_ + registration_name = f"multiareccon.{type_}" desc = {"path": "/~paulsm/"} aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) host = "multahostcon.local." @@ -1786,7 +1786,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): async def test_service_info_nsec_records(): """Test we can generate nsec records from ServiceInfo.""" type_ = "_http._tcp.local." - registration_name = "multiareccon.%s" % type_ + registration_name = f"multiareccon.{type_}" desc = {"path": "/~paulsm/"} host = "multahostcon.local." info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, host) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 2471733b6..86e9e8c7b 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -933,7 +933,7 @@ async def test_service_browser_instantiation_generates_add_events_from_cache(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_hap._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" callbacks = [] class MyServiceListener(ServiceListener): @@ -982,7 +982,7 @@ async def test_integration(): got_query = asyncio.Event() type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): if name == registration_name: @@ -1184,7 +1184,7 @@ async def test_service_browser_ignores_unrelated_updates(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf type_ = "_veryuniqueone._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" callbacks = [] class MyServiceListener(ServiceListener): diff --git a/tests/test_core.py b/tests/test_core.py index 820559689..9ccdcc78c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -318,7 +318,7 @@ def test_goodbye_all_services(): out = zc.generate_unregister_all_services() assert out is None type_ = "_http._tcp.local." - registration_name = "xxxyyy.%s" % type_ + registration_name = f"xxxyyy.{type_}" desc = {"path": "/~paulsm/"} info = r.ServiceInfo( type_, diff --git a/tests/test_init.py b/tests/test_init.py index 3ae695c55..080d485e7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -40,20 +40,20 @@ def test_long_name(self): def test_exceedingly_long_name(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - name = "%slocal." % ("part." * 1000) + name = f"{'part.' * 1000}local." question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) r.DNSIncoming(generated.packets()[0]) def test_extra_exceedingly_long_name(self): generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - name = "%slocal." % ("part." * 4000) + name = f"{'part.' * 4000}local." question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) r.DNSIncoming(generated.packets()[0]) def test_exceedingly_long_name_part(self): - name = "%s.local." % ("a" * 1000) + name = f"{'a' * 1000}.local." generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) question = r.DNSQuestion(name, const._TYPE_SRV, const._CLASS_IN) generated.add_question(question) @@ -154,14 +154,14 @@ def verify_name_change(self, zc, type_, name, number_hosts): addresses=[socket.inet_aton("10.0.1.2")], ) zc.register_service(info_service2, allow_name_change=True) - assert info_service2.name.split(".")[0] == "%s-%d" % (name, number_hosts + 1) + assert info_service2.name.split(".")[0] == f"{name}-{number_hosts + 1}" def generate_many_hosts(self, zc, type_, name, number_hosts): block_size = 25 number_hosts = int((number_hosts - 1) / block_size + 1) * block_size out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) for i in range(1, number_hosts + 1): - next_name = name if i == 1 else "%s-%d" % (name, i) + next_name = name if i == 1 else f"{name}-{i}" self.generate_host(out, next_name, type_) _inject_responses(zc, [r.DNSIncoming(packet) for packet in out.packets()]) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 1feb64c58..e46dbf03b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -707,7 +707,7 @@ def test_tc_bit_in_query_packet(): for i in range(30): out.add_answer_at_time( DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), + f"HASS Bridge W9DN {i}._hap._tcp.local.", const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, @@ -739,7 +739,7 @@ def test_tc_bit_not_set_in_answer_packet(): for i in range(30): out.add_answer_at_time( DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), + f"HASS Bridge W9DN {i}._hap._tcp.local.", const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, @@ -813,7 +813,7 @@ def test_records_same_packet_share_fate(): for i in range(30): out.add_answer_at_time( DNSText( - ("HASS Bridge W9DN %s._hap._tcp.local." % i), + f"HASS Bridge W9DN {i}._hap._tcp.local.", const._TYPE_TXT, const._CLASS_IN | const._CLASS_UNIQUE, const._DNS_OTHER_TTL, From 46d3f551e561908749afbf296d30537371d88cd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:33:11 -1000 Subject: [PATCH 1166/1433] chore(deps-dev): bump pytest-cov from 5.0.0 to 6.0.0 (#1474) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 56a2b910a..bf39f7925 100644 --- a/poetry.lock +++ b/poetry.lock @@ -472,17 +472,17 @@ test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" -version = "5.0.0" +version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, - {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, ] [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} +coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] @@ -615,4 +615,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "f5c250deb75c032aed220cdb67ee2a16316143cec5458a8bb99fd9bafbdbf1ad" +content-hash = "748c1d5a24ec0b6c1561daace768193ce87acc53d4cabf06c82551a45c079c94" diff --git a/pyproject.toml b/pyproject.toml index 9da8d87d2..450c8aa7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" -pytest-cov = ">=4,<6" +pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.26.0" cython = "^3.0.5" setuptools = ">=65.6.3,<76.0.0" From a97d228213350b5fc34ed5c82a635dc1d0f9438d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 13:35:47 -1000 Subject: [PATCH 1167/1433] chore: use native arm runners for arm wheels (#1484) --- .github/workflows/ci.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 520bf35ea..0ec373454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,14 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-2019, macos-13, macos-latest] + os: + [ + ubuntu-24.04-arm, + ubuntu-latest, + windows-2019, + macos-13, + macos-latest, + ] musl: ["", "musllinux"] exclude: - os: windows-2019 @@ -201,12 +208,6 @@ jobs: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 - - name: Set up QEMU - if: runner.os == 'Linux' - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - name: Build wheels (non-musl) uses: pypa/cibuildwheel@v2.22.0 if: matrix.musl == '' @@ -214,7 +215,7 @@ jobs: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux* CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: auto aarch64 + CIBW_ARCHS_LINUX: ${matrix.os == ubuntu-24.04-arm && 'aarch64' || 'auto'} CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 @@ -225,7 +226,7 @@ jobs: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *manylinux* CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: auto aarch64 + CIBW_ARCHS_LINUX: ${matrix.os == ubuntu-24.04-arm && 'aarch64' || 'auto'} CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 From dde26c655a49811c11071b0531e408a188687009 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 17 Jan 2025 00:35:54 +0100 Subject: [PATCH 1168/1433] fix(docs): remove repetition of words (#1479) Co-authored-by: J. Nick Koston --- tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index dc4524fb1..5b1789a1b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -64,7 +64,7 @@ def _wait_for_start(zc: Zeroconf) -> None: @cache def has_working_ipv6(): - """Return True if if the system can bind an IPv6 address.""" + """Return True if the system can bind an IPv6 address.""" if not socket.has_ipv6: return False From 22a0fb487db27bc2c6448a9167742f3040e910ba Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Fri, 17 Jan 2025 00:36:03 +0100 Subject: [PATCH 1169/1433] feat: migrate to native types (#1472) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- bench/incoming.py | 3 +-- docs/conf.py | 4 ++-- examples/async_apple_scanner.py | 8 +++++--- examples/async_browser.py | 8 +++++--- examples/async_registration.py | 9 +++++---- examples/async_service_info_request.py | 10 ++++++---- tests/__init__.py | 9 +++++---- tests/benchmarks/test_incoming.py | 3 +-- tests/services/test_browser.py | 11 ++++++----- tests/services/test_info.py | 12 +++++++----- tests/test_cache.py | 1 - tests/test_core.py | 10 ++++++---- tests/test_dns.py | 1 - tests/test_engine.py | 3 +-- tests/test_exceptions.py | 1 - tests/test_handlers.py | 10 +++++----- tests/test_history.py | 8 +++----- tests/test_init.py | 1 - tests/test_listener.py | 5 +++-- tests/test_protocol.py | 1 - tests/test_services.py | 4 ++-- tests/utils/test_asyncio.py | 5 +++-- 22 files changed, 66 insertions(+), 61 deletions(-) diff --git a/bench/incoming.py b/bench/incoming.py index 3edcfec21..eb35f8a92 100644 --- a/bench/incoming.py +++ b/bench/incoming.py @@ -2,7 +2,6 @@ import socket import timeit -from typing import List from zeroconf import ( DNSAddress, @@ -15,7 +14,7 @@ ) -def generate_packets() -> List[bytes]: +def generate_packets() -> list[bytes]: out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) address = socket.inet_pton(socket.AF_INET, "192.168.208.5") diff --git a/docs/conf.py b/docs/conf.py index b3ad57eaa..647742e65 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,7 +7,7 @@ # All configuration values have a default; values that are commented out # serve to show the default. -from typing import Any, Dict +from typing import Any import zeroconf @@ -173,7 +173,7 @@ # -- Options for LaTeX output -------------------------------------------------- -latex_elements: Dict[str, Any] = {} +latex_elements: dict[str, Any] = {} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index e126e8f90..19691662d 100755 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -2,10 +2,12 @@ """Scan for apple devices.""" +from __future__ import annotations + import argparse import asyncio import logging -from typing import Any, Optional, cast +from typing import Any, cast from zeroconf import DNSQuestionType, IPVersion, ServiceStateChange, Zeroconf from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf @@ -76,8 +78,8 @@ async def _async_show_service_info(zeroconf: Zeroconf, service_type: str, name: class AsyncAppleScanner: def __init__(self, args: Any) -> None: self.args = args - self.aiobrowser: Optional[AsyncServiceBrowser] = None - self.aiozc: Optional[AsyncZeroconf] = None + self.aiobrowser: AsyncServiceBrowser | None = None + self.aiozc: AsyncZeroconf | None = None async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=ip_version) diff --git a/examples/async_browser.py b/examples/async_browser.py index 31b55e4a8..d86cfc5e7 100755 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -5,10 +5,12 @@ The default is HTTP and HAP; use --find to search for all available services in the network """ +from __future__ import annotations + import argparse import asyncio import logging -from typing import Any, Optional, cast +from typing import Any, cast from zeroconf import IPVersion, ServiceStateChange, Zeroconf from zeroconf.asyncio import ( @@ -56,8 +58,8 @@ async def async_display_service_info(zeroconf: Zeroconf, service_type: str, name class AsyncRunner: def __init__(self, args: Any) -> None: self.args = args - self.aiobrowser: Optional[AsyncServiceBrowser] = None - self.aiozc: Optional[AsyncZeroconf] = None + self.aiobrowser: AsyncServiceBrowser | None = None + self.aiozc: AsyncZeroconf | None = None async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=ip_version) diff --git a/examples/async_registration.py b/examples/async_registration.py index 56cb91f21..d01b15e16 100755 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -2,11 +2,12 @@ """Example of announcing 250 services (in this case, a fake HTTP server).""" +from __future__ import annotations + import argparse import asyncio import logging import socket -from typing import List, Optional from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -15,9 +16,9 @@ class AsyncRunner: def __init__(self, ip_version: IPVersion) -> None: self.ip_version = ip_version - self.aiozc: Optional[AsyncZeroconf] = None + self.aiozc: AsyncZeroconf | None = None - async def register_services(self, infos: List[AsyncServiceInfo]) -> None: + async def register_services(self, infos: list[AsyncServiceInfo]) -> None: self.aiozc = AsyncZeroconf(ip_version=self.ip_version) tasks = [self.aiozc.async_register_service(info) for info in infos] background_tasks = await asyncio.gather(*tasks) @@ -26,7 +27,7 @@ async def register_services(self, infos: List[AsyncServiceInfo]) -> None: while True: await asyncio.sleep(1) - async def unregister_services(self, infos: List[AsyncServiceInfo]) -> None: + async def unregister_services(self, infos: list[AsyncServiceInfo]) -> None: assert self.aiozc is not None tasks = [self.aiozc.async_unregister_service(info) for info in infos] background_tasks = await asyncio.gather(*tasks) diff --git a/examples/async_service_info_request.py b/examples/async_service_info_request.py index 42df809d6..ca75fc522 100755 --- a/examples/async_service_info_request.py +++ b/examples/async_service_info_request.py @@ -7,10 +7,12 @@ """ +from __future__ import annotations + import argparse import asyncio import logging -from typing import Any, List, Optional, cast +from typing import Any, cast from zeroconf import IPVersion, ServiceBrowser, ServiceStateChange, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -22,7 +24,7 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: zeroconf = aiozc.zeroconf while True: await asyncio.sleep(5) - infos: List[AsyncServiceInfo] = [] + infos: list[AsyncServiceInfo] = [] for name in zeroconf.cache.names(): if not name.endswith(HAP_TYPE): continue @@ -50,8 +52,8 @@ async def async_watch_services(aiozc: AsyncZeroconf) -> None: class AsyncRunner: def __init__(self, args: Any) -> None: self.args = args - self.threaded_browser: Optional[ServiceBrowser] = None - self.aiozc: Optional[AsyncZeroconf] = None + self.threaded_browser: ServiceBrowser | None = None + self.aiozc: AsyncZeroconf | None = None async def async_run(self) -> None: self.aiozc = AsyncZeroconf(ip_version=ip_version) diff --git a/tests/__init__.py b/tests/__init__.py index 5b1789a1b..a70cca600 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,11 +20,12 @@ USA """ +from __future__ import annotations + import asyncio import socket import time from functools import cache -from typing import List, Optional, Set from unittest import mock import ifaddr @@ -36,11 +37,11 @@ class QuestionHistoryWithoutSuppression(QuestionHistory): - def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool: return False -def _inject_responses(zc: Zeroconf, msgs: List[DNSIncoming]) -> None: +def _inject_responses(zc: Zeroconf, msgs: list[DNSIncoming]) -> None: """Inject a DNSIncoming response.""" assert zc.loop is not None @@ -90,7 +91,7 @@ def _clear_cache(zc: Zeroconf) -> None: zc.question_history.clear() -def time_changed_millis(millis: Optional[float] = None) -> None: +def time_changed_millis(millis: float | None = None) -> None: """Call all scheduled events for a time.""" loop = asyncio.get_running_loop() loop_time = loop.time() diff --git a/tests/benchmarks/test_incoming.py b/tests/benchmarks/test_incoming.py index 6285c19f1..e0552f3a1 100644 --- a/tests/benchmarks/test_incoming.py +++ b/tests/benchmarks/test_incoming.py @@ -1,7 +1,6 @@ """Benchmark for DNSIncoming.""" import socket -from typing import List from pytest_codspeed import BenchmarkFixture @@ -16,7 +15,7 @@ ) -def generate_packets() -> List[bytes]: +def generate_packets() -> list[bytes]: out = DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) address = socket.inet_pton(socket.AF_INET, "192.168.208.5") diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index f4d750e04..5268c3414 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -6,8 +6,9 @@ import socket import time import unittest +from collections.abc import Iterable from threading import Event -from typing import Iterable, List, Set, cast +from typing import cast from unittest.mock import patch import pytest @@ -580,7 +581,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): old_send = zeroconf_browser.async_send expected_ttl = const._DNS_OTHER_TTL - questions: List[List[DNSQuestion]] = [] + questions: list[list[DNSQuestion]] = [] def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -1151,7 +1152,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): 10000, f"known-to-other.{name}", ) - other_known_answers: Set[r.DNSRecord] = {answer} + other_known_answers: set[r.DNSRecord] = {answer} zc.question_history.add_question_at_time(question, now, other_known_answers) assert zc.question_history.suppresses(question, now, other_known_answers) @@ -1196,7 +1197,7 @@ async def test_query_scheduler(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() zc = aiozc.zeroconf - sends: List[r.DNSIncoming] = [] + sends: list[r.DNSIncoming] = [] def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -1289,7 +1290,7 @@ async def test_query_scheduler_rescue_records(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() zc = aiozc.zeroconf - sends: List[r.DNSIncoming] = [] + sends: list[r.DNSIncoming] = [] def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 7051e6fe2..1f8924a34 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1,14 +1,16 @@ """Unit tests for zeroconf._services.info.""" +from __future__ import annotations + import asyncio import logging import os import socket import threading import unittest +from collections.abc import Iterable from ipaddress import ip_address from threading import Event -from typing import Iterable, List, Optional from unittest.mock import patch import pytest @@ -264,7 +266,7 @@ def test_get_info_partial(self): send_event = Event() service_info_event = Event() - last_sent: Optional[r.DNSOutgoing] = None + last_sent: r.DNSOutgoing | None = None def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -407,7 +409,7 @@ def test_get_info_suppressed_by_question_history(self): send_event = Event() service_info_event = Event() - last_sent: Optional[r.DNSOutgoing] = None + last_sent: r.DNSOutgoing | None = None def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -534,7 +536,7 @@ def test_get_info_single(self): send_event = Event() service_info_event = Event() - last_sent = None # type: Optional[r.DNSOutgoing] + last_sent: r.DNSOutgoing | None = None def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): """Sends an outgoing packet.""" @@ -879,7 +881,7 @@ def test_filter_address_by_type_from_service_info(): ipv6 = socket.inet_pton(socket.AF_INET6, "2001:db8::1") info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[ipv4, ipv6]) - def dns_addresses_to_addresses(dns_address: List[DNSAddress]) -> List[bytes]: + def dns_addresses_to_addresses(dns_address: list[DNSAddress]) -> list[bytes]: return [address.address for address in dns_address] assert dns_addresses_to_addresses(info.dns_addresses()) == [ipv4, ipv6] diff --git a/tests/test_cache.py b/tests/test_cache.py index 99de9827f..f5304cef2 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,7 +1,6 @@ """Unit tests for zeroconf._cache.""" import logging -import unittest import unittest.mock from heapq import heapify, heappop diff --git a/tests/test_core.py b/tests/test_core.py index 9ccdcc78c..fcfdf4249 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._core""" +from __future__ import annotations + import asyncio import logging import os @@ -9,7 +11,7 @@ import time import unittest import unittest.mock -from typing import Tuple, Union, cast +from typing import cast from unittest.mock import AsyncMock, Mock, patch import pytest @@ -38,13 +40,13 @@ def teardown_module(): def threadsafe_query( - zc: "Zeroconf", - protocol: "AsyncListener", + zc: Zeroconf, + protocol: AsyncListener, msg: DNSIncoming, addr: str, port: int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]], + v6_flow_scope: tuple[()] | tuple[int, int], ) -> None: async def make_query(): protocol.handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) diff --git a/tests/test_dns.py b/tests/test_dns.py index e9c4dc09f..491e2ca7f 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -3,7 +3,6 @@ import logging import os import socket -import unittest import unittest.mock import pytest diff --git a/tests/test_engine.py b/tests/test_engine.py index 79560d9ce..23a039497 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,7 +3,6 @@ import asyncio import itertools import logging -from typing import Set from unittest.mock import patch import pytest @@ -41,7 +40,7 @@ async def test_reaper(): zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers: Set[r.DNSRecord] = { + other_known_answers: set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 1f5bd7387..cf004d2c0 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,6 @@ """Unit tests for zeroconf._exceptions""" import logging -import unittest import unittest.mock import zeroconf as r diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 8cf5cc9a8..80ee7f407 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -7,7 +7,7 @@ import time import unittest import unittest.mock -from typing import List, cast +from typing import cast from unittest.mock import patch import pytest @@ -1371,7 +1371,7 @@ async def test_record_update_manager_add_listener_callsback_existing_records(): class MyListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -1973,7 +1973,7 @@ async def test_add_listener_warns_when_not_using_record_update_listener(caplog): class MyListener: """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -2005,7 +2005,7 @@ async def test_async_updates_iteration_safe(): class OtherListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -2014,7 +2014,7 @@ def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.Recor class ListenerThatAddsListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: List[r.RecordUpdate]) -> None: + def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.async_add_listener(other, None) diff --git a/tests/test_history.py b/tests/test_history.py index c604d3832..606362d1d 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,7 +1,5 @@ """Unit tests for _history.py.""" -from typing import Set - import zeroconf as r import zeroconf.const as const from zeroconf._history import QuestionHistory @@ -12,7 +10,7 @@ def test_question_suppression(): question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() - other_known_answers: Set[r.DNSRecord] = { + other_known_answers: set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, @@ -21,7 +19,7 @@ def test_question_suppression(): "known-to-other._hap._tcp.local.", ) } - our_known_answers: Set[r.DNSRecord] = { + our_known_answers: set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, @@ -54,7 +52,7 @@ def test_question_expire(): now = r.current_time_millis() question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) - other_known_answers: Set[r.DNSRecord] = { + other_known_answers: set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, diff --git a/tests/test_init.py b/tests/test_init.py index 080d485e7..78fb1e370 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,7 +3,6 @@ import logging import socket import time -import unittest import unittest.mock from unittest.mock import patch diff --git a/tests/test_listener.py b/tests/test_listener.py index f5af91f82..a55fc1435 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,9 +1,10 @@ """Unit tests for zeroconf._listener""" +from __future__ import annotations + import logging import unittest import unittest.mock -from typing import Tuple, Union from unittest.mock import MagicMock, patch import zeroconf as r @@ -146,7 +147,7 @@ def handle_query_or_defer( addr: str, port: int, transport: _engine._WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + v6_flow_scope: tuple[()] | tuple[int, int] = (), ) -> None: """Handle a query or defer it for later processing.""" super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index e46dbf03b..1397c60cd 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -5,7 +5,6 @@ import os import socket import struct -import unittest import unittest.mock from typing import cast diff --git a/tests/test_services.py b/tests/test_services.py index 908782c7b..992070e23 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,7 +6,7 @@ import time import unittest from threading import Event -from typing import Any, Dict +from typing import Any import pytest @@ -91,7 +91,7 @@ def update_service(self, zeroconf, type, name): } zeroconf_registrar = Zeroconf(interfaces=["127.0.0.1"]) - desc: Dict[str, Any] = {"path": "/~paulsm/"} + desc: dict[str, Any] = {"path": "/~paulsm/"} desc.update(properties) addresses = [socket.inet_aton("10.0.1.2")] if has_working_ipv6() and not os.environ.get("SKIP_IPV6"): diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index f22d85ede..09137a719 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -1,11 +1,12 @@ """Unit tests for zeroconf._utils.asyncio.""" +from __future__ import annotations + import asyncio import concurrent.futures import contextlib import threading import time -from typing import Optional from unittest.mock import patch import pytest @@ -120,7 +121,7 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() - task: Optional[asyncio.Task] = None + task: asyncio.Task | None = None async def _saved_sleep_task(): nonlocal task From d9be7155a0ef1ac521e5bbedd3884ddeb9f0b99d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 13:36:11 -1000 Subject: [PATCH 1170/1433] feat: small performance improvement to writing outgoing packets (#1482) --- src/zeroconf/_dns.pxd | 2 +- src/zeroconf/_protocol/outgoing.pxd | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index e41ac4c34..5ff98a8d4 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -134,7 +134,7 @@ cdef class DNSService(DNSRecord): cdef class DNSNsec(DNSRecord): cdef public cython.int _hash - cdef public object next_name + cdef public str next_name cdef public cython.list rdtypes cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str next_name, cython.list rdtypes, double created) diff --git a/src/zeroconf/_protocol/outgoing.pxd b/src/zeroconf/_protocol/outgoing.pxd index fa1aeebcd..bb9730b89 100644 --- a/src/zeroconf/_protocol/outgoing.pxd +++ b/src/zeroconf/_protocol/outgoing.pxd @@ -108,6 +108,8 @@ cdef class DNSOutgoing: cpdef void write_string(self, cython.bytes value) + cpdef void write_character_string(self, cython.bytes value) + @cython.locals(utfstr=bytes) cdef void _write_utf(self, cython.str value) From aaec7c2f612fe7182fba07a8e5f97ac2f2086793 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 13:47:18 -1000 Subject: [PATCH 1171/1433] chore: add cython linter (#1416) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 5 +++ pyproject.toml | 4 +++ src/zeroconf/_cache.pxd | 50 +++++++----------------------- src/zeroconf/_handlers/answers.pxd | 3 +- src/zeroconf/_listener.pxd | 1 - src/zeroconf/_services/browser.pxd | 6 +++- src/zeroconf/_utils/ipaddress.pxd | 7 +++-- 7 files changed, 32 insertions(+), 44 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8551ee8b9..c7603360b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,3 +57,8 @@ repos: hooks: - id: mypy additional_dependencies: [] + - repo: https://github.com/MarcoGorelli/cython-lint + rev: v0.16.2 + hooks: + - id: cython-lint + - id: double-quote-cython-strings diff --git a/pyproject.toml b/pyproject.toml index 450c8aa7f..c77333eeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,3 +193,7 @@ build-backend = "poetry.core.masonry.api" [tool.codespell] ignore-words-list = ["additionals", "HASS"] + +[tool.cython-lint] +max-line-length = 88 +ignore = ['E501'] # too many to fix right now diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 7f78a736e..a39ed7562 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -20,9 +20,8 @@ cdef unsigned int _TYPE_PTR cdef cython.uint _ONE_SECOND cdef unsigned int _MIN_SCHEDULED_RECORD_EXPIRATION -@cython.locals( - record_cache=dict, -) + +@cython.locals(record_cache=dict) cdef _remove_key(cython.dict cache, object key, DNSRecord record) @@ -37,36 +36,23 @@ cdef class DNSCache: cpdef void async_remove_records(self, object entries) - @cython.locals( - store=cython.dict, - ) + @cython.locals(store=cython.dict) cpdef DNSRecord async_get_unique(self, DNSRecord entry) - @cython.locals( - record=DNSRecord, - ) + @cython.locals(record=DNSRecord) cpdef list async_expire(self, double now) - @cython.locals( - records=cython.dict, - record=DNSRecord, - ) + @cython.locals(records=cython.dict, record=DNSRecord) cpdef list async_all_by_details(self, str name, unsigned int type_, unsigned int class_) cpdef list async_entries_with_name(self, str name) cpdef list async_entries_with_server(self, str name) - @cython.locals( - cached_entry=DNSRecord, - records=dict - ) + @cython.locals(cached_entry=DNSRecord, records=dict) cpdef DNSRecord get_by_details(self, str name, unsigned int type_, unsigned int class_) - @cython.locals( - records=cython.dict, - entry=DNSRecord, - ) + @cython.locals(records=cython.dict, entry=DNSRecord) cpdef cython.list get_all_by_details(self, str name, unsigned int type_, unsigned int class_) @cython.locals( @@ -76,31 +62,19 @@ cdef class DNSCache: ) cdef bint _async_add(self, DNSRecord record) - @cython.locals( - service_record=DNSService - ) + @cython.locals(service_record=DNSService) cdef void _async_remove(self, DNSRecord record) - @cython.locals( - record=DNSRecord, - created_double=double, - ) + @cython.locals(record=DNSRecord, created_double=double) cpdef void async_mark_unique_records_older_than_1s_to_expire(self, cython.set unique_types, object answers, double now) - @cython.locals( - entries=dict - ) + @cython.locals(entries=dict) cpdef list entries_with_name(self, str name) - @cython.locals( - entries=dict - ) + @cython.locals(entries=dict) cpdef list entries_with_server(self, str server) - @cython.locals( - record=DNSRecord, - now=double - ) + @cython.locals(record=DNSRecord, now=double) cpdef current_entry_with_name_and_alias(self, str name, str alias) cpdef void _async_set_created_ttl( diff --git a/src/zeroconf/_handlers/answers.pxd b/src/zeroconf/_handlers/answers.pxd index 25b3c1a1e..759905f27 100644 --- a/src/zeroconf/_handlers/answers.pxd +++ b/src/zeroconf/_handlers/answers.pxd @@ -20,8 +20,6 @@ cdef class AnswerGroup: cdef public cython.dict answers - - cdef object _FLAGS_QR_RESPONSE_AA cdef object NAME_GETTER @@ -31,5 +29,6 @@ cpdef DNSOutgoing construct_outgoing_unicast_answers( cython.dict answers, bint ucast_source, cython.list questions, object id_ ) + @cython.locals(answer=DNSRecord, additionals=cython.set, additional=DNSRecord) cdef void _add_answers_additionals(DNSOutgoing out, cython.dict answers) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 96f52be02..20084b47c 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -16,7 +16,6 @@ cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL - cdef class AsyncListener: cdef public object zc diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 4649291c7..1ea99c82d 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -44,6 +44,7 @@ cdef class _DNSPointerOutgoingBucket: cpdef add(self, cython.uint max_compressed_size, DNSQuestion question, cython.set answers) + @cython.locals(cache=DNSCache, question_history=QuestionHistory, record=DNSRecord, qu_question=bint) cpdef list generate_service_query( object zc, @@ -53,9 +54,11 @@ cpdef list generate_service_query( object question_type ) + @cython.locals(answer=DNSPointer, query_buckets=list, question=DNSQuestion, max_compressed_size=cython.uint, max_bucket_size=cython.uint, query_bucket=_DNSPointerOutgoingBucket) cdef list _group_ptr_queries_with_known_answers(double now_millis, bint multicast, cython.dict question_with_known_answers) + cdef class QueryScheduler: cdef object _zc @@ -83,7 +86,7 @@ cdef class QueryScheduler: @cython.locals(current=_ScheduledPTRQuery, expire_time=double) cpdef void reschedule_ptr_first_refresh(self, DNSPointer pointer) - @cython.locals(ttl_millis='unsigned int', additional_wait=double, next_query_time=double) + @cython.locals(ttl_millis="unsigned int", additional_wait=double, next_query_time=double) cpdef void schedule_rescue_query(self, _ScheduledPTRQuery query, double now_millis, float additional_percentage) cpdef void _process_startup_queries(self) @@ -93,6 +96,7 @@ cdef class QueryScheduler: cpdef void async_send_ready_queries(self, bint first_request, double now_millis, set ready_types) + cdef class _ServiceBrowserBase(RecordUpdateListener): cdef public cython.set types diff --git a/src/zeroconf/_utils/ipaddress.pxd b/src/zeroconf/_utils/ipaddress.pxd index 01d381640..78bbdfbdd 100644 --- a/src/zeroconf/_utils/ipaddress.pxd +++ b/src/zeroconf/_utils/ipaddress.pxd @@ -1,13 +1,16 @@ -cdef bint TYPE_CHECKING - from .._dns cimport DNSAddress +cdef bint TYPE_CHECKING + cpdef get_ip_address_object_from_record(DNSAddress record) + @cython.locals(address_str=str) cpdef str_without_scope_id(object addr) + cpdef ip_bytes_and_scope_to_address(object addr, object scope_id) + cdef object cached_ip_addresses_wrapper From 127338d2ae69fe1fe9b25f0614db7448c427c751 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 16 Jan 2025 23:59:38 +0000 Subject: [PATCH 1172/1433] 0.140.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e28c16c..91ea999d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ # CHANGELOG +## v0.140.0 (2025-01-16) + +### Bug Fixes + +* fix(docs): remove repetition of words (#1479) + +Co-authored-by: J. Nick Koston ([`dde26c6`](https://github.com/python-zeroconf/python-zeroconf/commit/dde26c655a49811c11071b0531e408a188687009)) + +### Features + +* feat: small performance improvement to writing outgoing packets (#1482) ([`d9be715`](https://github.com/python-zeroconf/python-zeroconf/commit/d9be7155a0ef1ac521e5bbedd3884ddeb9f0b99d)) + +* feat: migrate to native types (#1472) + +Co-authored-by: J. Nick Koston +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ([`22a0fb4`](https://github.com/python-zeroconf/python-zeroconf/commit/22a0fb487db27bc2c6448a9167742f3040e910ba)) + + ## v0.139.0 (2025-01-09) ### Features diff --git a/pyproject.toml b/pyproject.toml index c77333eeb..934838fc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.139.0" +version = "0.140.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2c4004ab6..d13d7e3ff 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.139.0" +__version__ = "0.140.0" __license__ = "LGPL" From 9d228e28eead1561deda696e8837d59896cbc98d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jan 2025 14:20:35 -1000 Subject: [PATCH 1173/1433] fix: wheel builds for aarch64 (#1485) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ec373454..b5d21d404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux* CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: ${matrix.os == ubuntu-24.04-arm && 'aarch64' || 'auto'} + CIBW_ARCHS_LINUX: ${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'auto' }} CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 @@ -226,7 +226,7 @@ jobs: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *manylinux* CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: ${matrix.os == ubuntu-24.04-arm && 'aarch64' || 'auto'} + CIBW_ARCHS_LINUX: ${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'auto' }} CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 From 9af848b42f002bded7a2aef343bdd423ed4745d4 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 17 Jan 2025 00:30:33 +0000 Subject: [PATCH 1174/1433] 0.140.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 ++++++++- pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ea999d0..ef8f5deb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,14 @@ # CHANGELOG -## v0.140.0 (2025-01-16) +## v0.140.1 (2025-01-17) + +### Bug Fixes + +* fix: wheel builds for aarch64 (#1485) ([`9d228e2`](https://github.com/python-zeroconf/python-zeroconf/commit/9d228e28eead1561deda696e8837d59896cbc98d)) + + +## v0.140.0 (2025-01-17) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 934838fc4..9ea7f9cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.140.0" +version = "0.140.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d13d7e3ff..22434e47e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.140.0" +__version__ = "0.140.1" __license__ = "LGPL" From dbc5d11c9f46d4d705d0d6557e7876d8fcff0a11 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:52:44 -1000 Subject: [PATCH 1175/1433] chore(pre-commit.ci): pre-commit autoupdate (#1486) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7603360b..87c380830 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.9.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -58,7 +58,7 @@ repos: - id: mypy additional_dependencies: [] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.16.2 + rev: v0.16.6 hooks: - id: cython-lint - id: double-quote-cython-strings From 7db643687199e7383ef18419824e5fbba69d6b51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jan 2025 17:52:58 -1000 Subject: [PATCH 1176/1433] chore: bump upload/download artifact to v4 (#1487) --- .github/workflows/ci.yml | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5d21d404..e16ed4daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: needs: [release] if: needs.release.outputs.released == 'true' - name: Build wheels on ${{ matrix.os }} + name: Build wheels on ${{ matrix.os }} (${{ matrix.musl }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -208,31 +208,20 @@ jobs: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 - - name: Build wheels (non-musl) + - name: Build wheels ${{ matrix.musl }} uses: pypa/cibuildwheel@v2.22.0 - if: matrix.musl == '' # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *musllinux* + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc CIBW_ARCHS_LINUX: ${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'auto' }} CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 - - name: Build wheels (musl) - uses: pypa/cibuildwheel@v2.22.0 - if: matrix.musl == 'musllinux' - # to supply options, put them in 'env', like: - env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 *manylinux* - CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: ${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'auto' }} - CIBW_BUILD_VERBOSITY: 3 - REQUIRE_CYTHON: 1 - - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: path: ./wheelhouse/*.whl + name: wheels-${{ matrix.os }}-${{ matrix.musl }} upload_pypi: needs: [build_wheels] @@ -240,12 +229,13 @@ jobs: environment: release steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir - name: artifact + pattern: wheels-* path: dist + merge-multiple: true - uses: pypa/gh-action-pypi-publish@v1.5.0 with: From 8f86b35deca40cbc05451e758010d946884a917a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 18:13:12 -1000 Subject: [PATCH 1177/1433] chore(ci): bump the github-actions group across 1 directory with 7 updates (#1488) --- .github/workflows/ci.yml | 22 +++++++++++----------- commitlint.config.mjs | 8 ++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 commitlint.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e16ed4daa..e43c63b84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,10 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@v5 + - uses: wagoid/commitlint-github-action@v6 test: strategy: @@ -65,7 +65,7 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install poetry run: pipx install poetry - name: Set up Python @@ -87,7 +87,7 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -96,10 +96,10 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python 3.12 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.12 - - uses: snok/install-poetry@v1.3.4 + - uses: snok/install-poetry@v1.4.1 - name: Install Dependencies run: | REQUIRE_CYTHON=1 poetry install --only=main,dev @@ -134,14 +134,14 @@ jobs: # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@v9.12.0 + uses: python-semantic-release/python-semantic-release@v9.16.1 if: github.ref_name != 'master' with: root_options: --noop # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@v9.12.0 + uses: python-semantic-release/python-semantic-release@v9.16.1 id: release if: github.ref_name == 'master' with: @@ -183,7 +183,7 @@ jobs: musl: "musllinux" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: "master" @@ -203,7 +203,7 @@ jobs: run: | echo "::set-output name=newest_release_tag::$(semantic-release print-version --current)" - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 @@ -237,7 +237,7 @@ jobs: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@v1.5.0 + - uses: pypa/gh-action-pypi-publish@v1.12.3 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 000000000..deb029abf --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,8 @@ +export default { + extends: ["@commitlint/config-conventional"], + rules: { + "header-max-length": [0, "always", Infinity], + "body-max-line-length": [0, "always", Infinity], + "footer-max-line-length": [0, "always", Infinity], + }, +}; From d6f1fda45ec1de6d1a34c7ccc62dbf87bbd5164e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 11:29:15 -1000 Subject: [PATCH 1178/1433] chore: add benchmarks for adding and expiring records (#1489) --- tests/benchmarks/test_cache.py | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/benchmarks/test_cache.py diff --git a/tests/benchmarks/test_cache.py b/tests/benchmarks/test_cache.py new file mode 100644 index 000000000..051c1e473 --- /dev/null +++ b/tests/benchmarks/test_cache.py @@ -0,0 +1,48 @@ +from pytest_codspeed import BenchmarkFixture + +from zeroconf import DNSCache, DNSPointer, current_time_millis +from zeroconf.const import _CLASS_IN, _TYPE_PTR + + +def test_add_expire_1000_records(benchmark: BenchmarkFixture) -> None: + """Benchmark for DNSCache to expire 10000 records.""" + cache = DNSCache() + now = current_time_millis() + records = [ + DNSPointer( + name=f"test{id}.local.", + type_=_TYPE_PTR, + class_=_CLASS_IN, + ttl=60, + alias=f"test{id}.local.", + created=now, + ) + for id in range(1000) + ] + + @benchmark + def _expire_records() -> None: + cache.async_add_records(records) + cache.async_expire(now + 61_000) + + +def test_expire_no_records_to_expire(benchmark: BenchmarkFixture) -> None: + """Benchmark for DNSCache with 1000 records none to expire.""" + cache = DNSCache() + now = current_time_millis() + cache.async_add_records( + DNSPointer( + name=f"test{id}.local.", + type_=_TYPE_PTR, + class_=_CLASS_IN, + ttl=60, + alias=f"test{id}.local.", + created=now, + ) + for id in range(1000) + ) + cache.async_expire(now) + + @benchmark + def _expire_records() -> None: + cache.async_expire(now) From 854fef637c370dd1cd55300402417193e29777ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 11:52:29 -1000 Subject: [PATCH 1179/1433] chore: adjust cache benchmark to better reflect real cache data (#1491) --- tests/benchmarks/test_cache.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/benchmarks/test_cache.py b/tests/benchmarks/test_cache.py index 051c1e473..6fde9438f 100644 --- a/tests/benchmarks/test_cache.py +++ b/tests/benchmarks/test_cache.py @@ -15,7 +15,7 @@ def test_add_expire_1000_records(benchmark: BenchmarkFixture) -> None: class_=_CLASS_IN, ttl=60, alias=f"test{id}.local.", - created=now, + created=now + id, ) for id in range(1000) ] @@ -23,7 +23,7 @@ def test_add_expire_1000_records(benchmark: BenchmarkFixture) -> None: @benchmark def _expire_records() -> None: cache.async_add_records(records) - cache.async_expire(now + 61_000) + cache.async_expire(now + 100_000) def test_expire_no_records_to_expire(benchmark: BenchmarkFixture) -> None: @@ -37,7 +37,7 @@ def test_expire_no_records_to_expire(benchmark: BenchmarkFixture) -> None: class_=_CLASS_IN, ttl=60, alias=f"test{id}.local.", - created=now, + created=now + id, ) for id in range(1000) ) From 628b13670d04327dd8d4908842f31b476598c7e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jan 2025 12:23:54 -1000 Subject: [PATCH 1180/1433] feat: speed up adding and expiring records in the DNSCache (#1490) --- src/zeroconf/_cache.pxd | 6 ++++-- src/zeroconf/_cache.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index a39ed7562..273d46c37 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -39,7 +39,7 @@ cdef class DNSCache: @cython.locals(store=cython.dict) cpdef DNSRecord async_get_unique(self, DNSRecord entry) - @cython.locals(record=DNSRecord) + @cython.locals(record=DNSRecord, when_record=tuple, when=double) cpdef list async_expire(self, double now) @cython.locals(records=cython.dict, record=DNSRecord) @@ -57,8 +57,10 @@ cdef class DNSCache: @cython.locals( store=cython.dict, + service_store=cython.dict, service_record=DNSService, - when=object + when=object, + new=bint ) cdef bint _async_add(self, DNSRecord record) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index a43bdc5c5..1b7aae38f 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -86,7 +86,8 @@ def _async_add(self, record: _DNSRecord) -> bool: # replaces any existing records that are __eq__ to each other which # removes the risk that accessing the cache from the wrong # direction would return the old incorrect entry. - store = self.cache.setdefault(record.key, {}) + if (store := self.cache.get(record.key)) is None: + store = self.cache[record.key] = {} new = record not in store and not isinstance(record, DNSNsec) store[record] = record when = record.created + (record.ttl * 1000) @@ -97,7 +98,9 @@ def _async_add(self, record: _DNSRecord) -> bool: if isinstance(record, DNSService): service_record = record - self.service_cache.setdefault(record.server_key, {})[service_record] = service_record + if (service_store := self.service_cache.get(service_record.server_key)) is None: + service_store = self.service_cache[service_record.server_key] = {} + service_store[service_record] = service_record return new def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: @@ -145,7 +148,8 @@ def async_expire(self, now: _float) -> List[DNSRecord]: expired: List[DNSRecord] = [] # Find any expired records and add them to the to-delete list while self._expire_heap: - when, record = self._expire_heap[0] + when_record = self._expire_heap[0] + when = when_record[0] if when > now: break heappop(self._expire_heap) @@ -153,6 +157,7 @@ def async_expire(self, now: _float) -> List[DNSRecord]: # with a different expiration time as it will be removed # later when it reaches the top of the heap and its # expiration time is met. + record = when_record[1] if self._expirations.get(record) == when: expired.append(record) From eae89d8ed000311b5950cab1a1bb0f67f30b0b94 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 22 Jan 2025 22:33:08 +0000 Subject: [PATCH 1181/1433] 0.141.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 5072 +++++--------------------------------- pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 650 insertions(+), 4426 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8f5deb4..e5874ebbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5566 +1,1790 @@ # CHANGELOG +## v0.141.0 (2025-01-22) + +### Features + +- Speed up adding and expiring records in the DNSCache + ([#1490](https://github.com/python-zeroconf/python-zeroconf/pull/1490), + [`628b136`](https://github.com/python-zeroconf/python-zeroconf/commit/628b13670d04327dd8d4908842f31b476598c7e8)) + + ## v0.140.1 (2025-01-17) ### Bug Fixes -* fix: wheel builds for aarch64 (#1485) ([`9d228e2`](https://github.com/python-zeroconf/python-zeroconf/commit/9d228e28eead1561deda696e8837d59896cbc98d)) +- Wheel builds for aarch64 ([#1485](https://github.com/python-zeroconf/python-zeroconf/pull/1485), + [`9d228e2`](https://github.com/python-zeroconf/python-zeroconf/commit/9d228e28eead1561deda696e8837d59896cbc98d)) ## v0.140.0 (2025-01-17) ### Bug Fixes -* fix(docs): remove repetition of words (#1479) +- **docs**: Remove repetition of words + ([#1479](https://github.com/python-zeroconf/python-zeroconf/pull/1479), + [`dde26c6`](https://github.com/python-zeroconf/python-zeroconf/commit/dde26c655a49811c11071b0531e408a188687009)) -Co-authored-by: J. Nick Koston ([`dde26c6`](https://github.com/python-zeroconf/python-zeroconf/commit/dde26c655a49811c11071b0531e408a188687009)) +Co-authored-by: J. Nick Koston ### Features -* feat: small performance improvement to writing outgoing packets (#1482) ([`d9be715`](https://github.com/python-zeroconf/python-zeroconf/commit/d9be7155a0ef1ac521e5bbedd3884ddeb9f0b99d)) - -* feat: migrate to native types (#1472) +- Migrate to native types ([#1472](https://github.com/python-zeroconf/python-zeroconf/pull/1472), + [`22a0fb4`](https://github.com/python-zeroconf/python-zeroconf/commit/22a0fb487db27bc2c6448a9167742f3040e910ba)) Co-authored-by: J. Nick Koston -Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ([`22a0fb4`](https://github.com/python-zeroconf/python-zeroconf/commit/22a0fb487db27bc2c6448a9167742f3040e910ba)) + +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> + +- Small performance improvement to writing outgoing packets + ([#1482](https://github.com/python-zeroconf/python-zeroconf/pull/1482), + [`d9be715`](https://github.com/python-zeroconf/python-zeroconf/commit/d9be7155a0ef1ac521e5bbedd3884ddeb9f0b99d)) ## v0.139.0 (2025-01-09) ### Features -* feat: implement heapq for tracking cache expire times (#1465) ([`09db184`](https://github.com/python-zeroconf/python-zeroconf/commit/09db1848957b34415f364b7338e4adce99b57abc)) +- Implement heapq for tracking cache expire times + ([#1465](https://github.com/python-zeroconf/python-zeroconf/pull/1465), + [`09db184`](https://github.com/python-zeroconf/python-zeroconf/commit/09db1848957b34415f364b7338e4adce99b57abc)) ## v0.138.1 (2025-01-08) ### Bug Fixes -* fix: ensure cache does not return stale created and ttl values (#1469) ([`e05055c`](https://github.com/python-zeroconf/python-zeroconf/commit/e05055c584ca46080990437b2b385a187bc48458)) +- Ensure cache does not return stale created and ttl values + ([#1469](https://github.com/python-zeroconf/python-zeroconf/pull/1469), + [`e05055c`](https://github.com/python-zeroconf/python-zeroconf/commit/e05055c584ca46080990437b2b385a187bc48458)) ## v0.138.0 (2025-01-08) ### Features -* feat: improve performance of processing incoming records (#1467) +- Improve performance of processing incoming records + ([#1467](https://github.com/python-zeroconf/python-zeroconf/pull/1467), + [`ebbb2af`](https://github.com/python-zeroconf/python-zeroconf/commit/ebbb2afccabd3841a3cb0a39824b49773cc6258a)) -Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ([`ebbb2af`](https://github.com/python-zeroconf/python-zeroconf/commit/ebbb2afccabd3841a3cb0a39824b49773cc6258a)) +Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ## v0.137.2 (2025-01-06) ### Bug Fixes -* fix: split wheel builds to avoid timeout (#1461) ([`be05f0d`](https://github.com/python-zeroconf/python-zeroconf/commit/be05f0dc4f6b2431606031a7bb24585728d15f01)) +- Split wheel builds to avoid timeout + ([#1461](https://github.com/python-zeroconf/python-zeroconf/pull/1461), + [`be05f0d`](https://github.com/python-zeroconf/python-zeroconf/commit/be05f0dc4f6b2431606031a7bb24585728d15f01)) ## v0.137.1 (2025-01-06) ### Bug Fixes -* fix: move wheel builds to macos-13 (#1459) ([`4ff48a0`](https://github.com/python-zeroconf/python-zeroconf/commit/4ff48a01bc76c82e5710aafaf6cf6e79c069cd85)) +- Move wheel builds to macos-13 + ([#1459](https://github.com/python-zeroconf/python-zeroconf/pull/1459), + [`4ff48a0`](https://github.com/python-zeroconf/python-zeroconf/commit/4ff48a01bc76c82e5710aafaf6cf6e79c069cd85)) ## v0.137.0 (2025-01-06) ### Features -* feat: speed up parsing incoming records (#1458) ([`783c1b3`](https://github.com/python-zeroconf/python-zeroconf/commit/783c1b37d1372c90dfce658c66d03aa753afbf49)) +- Speed up parsing incoming records + ([#1458](https://github.com/python-zeroconf/python-zeroconf/pull/1458), + [`783c1b3`](https://github.com/python-zeroconf/python-zeroconf/commit/783c1b37d1372c90dfce658c66d03aa753afbf49)) ## v0.136.2 (2024-11-21) ### Bug Fixes -* fix: retrigger release from failed github workflow (#1443) ([`2ea705d`](https://github.com/python-zeroconf/python-zeroconf/commit/2ea705d850c1cb096c87372d5ec855f684603d01)) +- Retrigger release from failed github workflow + ([#1443](https://github.com/python-zeroconf/python-zeroconf/pull/1443), + [`2ea705d`](https://github.com/python-zeroconf/python-zeroconf/commit/2ea705d850c1cb096c87372d5ec855f684603d01)) ## v0.136.1 (2024-11-21) ### Bug Fixes -* fix(ci): run release workflow only on main repository (#1441) ([`f637c75`](https://github.com/python-zeroconf/python-zeroconf/commit/f637c75f638ba20c193e58ff63c073a4003430b9)) +- **ci**: Run release workflow only on main repository + ([#1441](https://github.com/python-zeroconf/python-zeroconf/pull/1441), + [`f637c75`](https://github.com/python-zeroconf/python-zeroconf/commit/f637c75f638ba20c193e58ff63c073a4003430b9)) -* fix(docs): update python to 3.8 (#1430) ([`483d067`](https://github.com/python-zeroconf/python-zeroconf/commit/483d0673d4ae3eec37840452723fc1839a6cc95c)) +- **docs**: Update python to 3.8 + ([#1430](https://github.com/python-zeroconf/python-zeroconf/pull/1430), + [`483d067`](https://github.com/python-zeroconf/python-zeroconf/commit/483d0673d4ae3eec37840452723fc1839a6cc95c)) ## v0.136.0 (2024-10-26) ### Bug Fixes -* fix: update python-semantic-release to fix release process (#1426) ([`2f20155`](https://github.com/python-zeroconf/python-zeroconf/commit/2f201558d0ab089cdfebb18d2d7bb5785b2cce16)) +- Add ignore for .c file for wheels + ([#1424](https://github.com/python-zeroconf/python-zeroconf/pull/1424), + [`6535963`](https://github.com/python-zeroconf/python-zeroconf/commit/6535963b5b789ce445e77bb728a5b7ee4263e582)) -* fix: add ignore for .c file for wheels (#1424) ([`6535963`](https://github.com/python-zeroconf/python-zeroconf/commit/6535963b5b789ce445e77bb728a5b7ee4263e582)) +- Correct typos ([#1422](https://github.com/python-zeroconf/python-zeroconf/pull/1422), + [`3991b42`](https://github.com/python-zeroconf/python-zeroconf/commit/3991b4256b8de5b37db7a6144e5112f711b2efef)) -* fix: correct typos (#1422) ([`3991b42`](https://github.com/python-zeroconf/python-zeroconf/commit/3991b4256b8de5b37db7a6144e5112f711b2efef)) +- Update python-semantic-release to fix release process + ([#1426](https://github.com/python-zeroconf/python-zeroconf/pull/1426), + [`2f20155`](https://github.com/python-zeroconf/python-zeroconf/commit/2f201558d0ab089cdfebb18d2d7bb5785b2cce16)) ### Features -* feat: use SPDX license identifier (#1425) ([`1596145`](https://github.com/python-zeroconf/python-zeroconf/commit/1596145452721e0de4e2a724b055e8e290792d3e)) +- Use SPDX license identifier + ([#1425](https://github.com/python-zeroconf/python-zeroconf/pull/1425), + [`1596145`](https://github.com/python-zeroconf/python-zeroconf/commit/1596145452721e0de4e2a724b055e8e290792d3e)) ## v0.135.0 (2024-09-24) ### Features -* feat: improve performance of DNSCache backend (#1415) ([`1df2e69`](https://github.com/python-zeroconf/python-zeroconf/commit/1df2e691ff11c9592e1cdad5599fb6601eb1aa3f)) +- Improve performance of DNSCache backend + ([#1415](https://github.com/python-zeroconf/python-zeroconf/pull/1415), + [`1df2e69`](https://github.com/python-zeroconf/python-zeroconf/commit/1df2e691ff11c9592e1cdad5599fb6601eb1aa3f)) ## v0.134.0 (2024-09-08) ### Bug Fixes -* fix: improve helpfulness of ServiceInfo.request assertions (#1408) ([`9262626`](https://github.com/python-zeroconf/python-zeroconf/commit/9262626895d354ed7376aa567043b793c37a985e)) +- Improve helpfulness of ServiceInfo.request assertions + ([#1408](https://github.com/python-zeroconf/python-zeroconf/pull/1408), + [`9262626`](https://github.com/python-zeroconf/python-zeroconf/commit/9262626895d354ed7376aa567043b793c37a985e)) ### Features -* feat: improve performance when IP addresses change frequently (#1407) ([`111c91a`](https://github.com/python-zeroconf/python-zeroconf/commit/111c91ab395a7520e477eb0e75d5924fba3c64c7)) +- Improve performance when IP addresses change frequently + ([#1407](https://github.com/python-zeroconf/python-zeroconf/pull/1407), + [`111c91a`](https://github.com/python-zeroconf/python-zeroconf/commit/111c91ab395a7520e477eb0e75d5924fba3c64c7)) ## v0.133.0 (2024-08-27) ### Features -* feat: improve performance of ip address caching (#1392) ([`f7c7708`](https://github.com/python-zeroconf/python-zeroconf/commit/f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2)) +- Add classifier for python 3.13 + ([#1393](https://github.com/python-zeroconf/python-zeroconf/pull/1393), + [`7fb2bb2`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb2bb21421c70db0eb288fa7e73d955f58b0f5d)) -* feat: enable building of arm64 macOS builds (#1384) +- Enable building of arm64 macOS builds + ([#1384](https://github.com/python-zeroconf/python-zeroconf/pull/1384), + [`0df2ce0`](https://github.com/python-zeroconf/python-zeroconf/commit/0df2ce0e6f7313831da6a63d477019982d5df55c)) Co-authored-by: Alex Ciobanu -Co-authored-by: J. Nick Koston ([`0df2ce0`](https://github.com/python-zeroconf/python-zeroconf/commit/0df2ce0e6f7313831da6a63d477019982d5df55c)) -* feat: add classifier for python 3.13 (#1393) ([`7fb2bb2`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb2bb21421c70db0eb288fa7e73d955f58b0f5d)) +Co-authored-by: J. Nick Koston + +- Improve performance of ip address caching + ([#1392](https://github.com/python-zeroconf/python-zeroconf/pull/1392), + [`f7c7708`](https://github.com/python-zeroconf/python-zeroconf/commit/f7c77081b2f8c70b1ed6a9b9751a86cf91f9aae2)) -* feat: python 3.13 support (#1390) ([`98cfa83`](https://github.com/python-zeroconf/python-zeroconf/commit/98cfa83710e43880698353821bae61108b08cb2f)) +- Python 3.13 support ([#1390](https://github.com/python-zeroconf/python-zeroconf/pull/1390), + [`98cfa83`](https://github.com/python-zeroconf/python-zeroconf/commit/98cfa83710e43880698353821bae61108b08cb2f)) ## v0.132.2 (2024-04-13) ### Bug Fixes -* fix: update references to minimum-supported python version of 3.8 (#1369) ([`599524a`](https://github.com/python-zeroconf/python-zeroconf/commit/599524a5ce1e4c1731519dd89377c2a852e59935)) +- Bump cibuildwheel to fix wheel builds + ([#1371](https://github.com/python-zeroconf/python-zeroconf/pull/1371), + [`83e4ce3`](https://github.com/python-zeroconf/python-zeroconf/commit/83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210)) -* fix: bump cibuildwheel to fix wheel builds (#1371) ([`83e4ce3`](https://github.com/python-zeroconf/python-zeroconf/commit/83e4ce3e31ddd4ae9aec2f8c9d84d7a93f8be210)) +- Update references to minimum-supported python version of 3.8 + ([#1369](https://github.com/python-zeroconf/python-zeroconf/pull/1369), + [`599524a`](https://github.com/python-zeroconf/python-zeroconf/commit/599524a5ce1e4c1731519dd89377c2a852e59935)) ## v0.132.1 (2024-04-12) ### Bug Fixes -* fix: set change during iteration when dispatching listeners (#1370) ([`e9f8aa5`](https://github.com/python-zeroconf/python-zeroconf/commit/e9f8aa5741ae2d490c33a562b459f0af1014dbb0)) +- Set change during iteration when dispatching listeners + ([#1370](https://github.com/python-zeroconf/python-zeroconf/pull/1370), + [`e9f8aa5`](https://github.com/python-zeroconf/python-zeroconf/commit/e9f8aa5741ae2d490c33a562b459f0af1014dbb0)) ## v0.132.0 (2024-04-01) ### Bug Fixes -* fix: avoid including scope_id in IPv6Address object if its zero (#1367) ([`edc4a55`](https://github.com/python-zeroconf/python-zeroconf/commit/edc4a556819956c238a11332052000dcbcb07e3d)) +- Avoid including scope_id in IPv6Address object if its zero + ([#1367](https://github.com/python-zeroconf/python-zeroconf/pull/1367), + [`edc4a55`](https://github.com/python-zeroconf/python-zeroconf/commit/edc4a556819956c238a11332052000dcbcb07e3d)) ### Features -* feat: make async_get_service_info available on the Zeroconf object (#1366) ([`c4c2dee`](https://github.com/python-zeroconf/python-zeroconf/commit/c4c2deeb05279ddbb0eba1330c7ae58795fea001)) +- Drop python 3.7 support ([#1359](https://github.com/python-zeroconf/python-zeroconf/pull/1359), + [`4877829`](https://github.com/python-zeroconf/python-zeroconf/commit/4877829e6442de5426db152d11827b1ba85dbf59)) -* feat: drop python 3.7 support (#1359) ([`4877829`](https://github.com/python-zeroconf/python-zeroconf/commit/4877829e6442de5426db152d11827b1ba85dbf59)) +- Make async_get_service_info available on the Zeroconf object + ([#1366](https://github.com/python-zeroconf/python-zeroconf/pull/1366), + [`c4c2dee`](https://github.com/python-zeroconf/python-zeroconf/commit/c4c2deeb05279ddbb0eba1330c7ae58795fea001)) ## v0.131.0 (2023-12-19) ### Features -* feat: small speed up to constructing outgoing packets (#1354) ([`517d7d0`](https://github.com/python-zeroconf/python-zeroconf/commit/517d7d00ca7738c770077738125aec0e4824c000)) +- Small speed up to constructing outgoing packets + ([#1354](https://github.com/python-zeroconf/python-zeroconf/pull/1354), + [`517d7d0`](https://github.com/python-zeroconf/python-zeroconf/commit/517d7d00ca7738c770077738125aec0e4824c000)) -* feat: speed up processing incoming packets (#1352) ([`6c15325`](https://github.com/python-zeroconf/python-zeroconf/commit/6c153258a995cf9459a6f23267b7e379b5e2550f)) +- Speed up processing incoming packets + ([#1352](https://github.com/python-zeroconf/python-zeroconf/pull/1352), + [`6c15325`](https://github.com/python-zeroconf/python-zeroconf/commit/6c153258a995cf9459a6f23267b7e379b5e2550f)) -* feat: speed up the query handler (#1350) ([`9eac0a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb)) +- Speed up the query handler ([#1350](https://github.com/python-zeroconf/python-zeroconf/pull/1350), + [`9eac0a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9eac0a122f28a7a4fa76cbfdda21d9a3571d7abb)) ## v0.130.0 (2023-12-16) ### Bug Fixes -* fix: scheduling race with the QueryScheduler (#1347) ([`cf40470`](https://github.com/python-zeroconf/python-zeroconf/commit/cf40470b89f918d3c24d7889d3536f3ffa44846c)) +- Ensure IPv6 scoped address construction uses the string cache + ([#1336](https://github.com/python-zeroconf/python-zeroconf/pull/1336), + [`f78a196`](https://github.com/python-zeroconf/python-zeroconf/commit/f78a196db632c4fe017a34f1af8a58903c15a575)) -* fix: ensure question history suppresses duplicates (#1338) ([`6f23656`](https://github.com/python-zeroconf/python-zeroconf/commit/6f23656576daa04e3de44e100f3ddd60ee4c560d)) +- Ensure question history suppresses duplicates + ([#1338](https://github.com/python-zeroconf/python-zeroconf/pull/1338), + [`6f23656`](https://github.com/python-zeroconf/python-zeroconf/commit/6f23656576daa04e3de44e100f3ddd60ee4c560d)) -* fix: microsecond precision loss in the query handler (#1339) ([`6560fad`](https://github.com/python-zeroconf/python-zeroconf/commit/6560fad584e0d392962c9a9248759f17c416620e)) +- Microsecond precision loss in the query handler + ([#1339](https://github.com/python-zeroconf/python-zeroconf/pull/1339), + [`6560fad`](https://github.com/python-zeroconf/python-zeroconf/commit/6560fad584e0d392962c9a9248759f17c416620e)) -* fix: ensure IPv6 scoped address construction uses the string cache (#1336) ([`f78a196`](https://github.com/python-zeroconf/python-zeroconf/commit/f78a196db632c4fe017a34f1af8a58903c15a575)) +- Scheduling race with the QueryScheduler + ([#1347](https://github.com/python-zeroconf/python-zeroconf/pull/1347), + [`cf40470`](https://github.com/python-zeroconf/python-zeroconf/commit/cf40470b89f918d3c24d7889d3536f3ffa44846c)) ### Features -* feat: make ServiceInfo aware of question history (#1348) ([`b9aae1d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9aae1de07bf1491e873bc314f8a1d7996127ad3)) +- Make ServiceInfo aware of question history + ([#1348](https://github.com/python-zeroconf/python-zeroconf/pull/1348), + [`b9aae1d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9aae1de07bf1491e873bc314f8a1d7996127ad3)) -* feat: small speed up to ServiceInfo construction (#1346) ([`b329d99`](https://github.com/python-zeroconf/python-zeroconf/commit/b329d99917bb731b4c70bf20c7c010eeb85ad9fd)) +- Significantly improve efficiency of the ServiceBrowser scheduler + ([#1335](https://github.com/python-zeroconf/python-zeroconf/pull/1335), + [`c65d869`](https://github.com/python-zeroconf/python-zeroconf/commit/c65d869aec731b803484871e9d242a984f9f5848)) -* feat: significantly improve efficiency of the ServiceBrowser scheduler (#1335) ([`c65d869`](https://github.com/python-zeroconf/python-zeroconf/commit/c65d869aec731b803484871e9d242a984f9f5848)) +- Small performance improvement constructing outgoing questions + ([#1340](https://github.com/python-zeroconf/python-zeroconf/pull/1340), + [`157185f`](https://github.com/python-zeroconf/python-zeroconf/commit/157185f28bf1e83e6811e2a5cd1fa9b38966f780)) -* feat: small speed up to processing incoming records (#1345) ([`7de655b`](https://github.com/python-zeroconf/python-zeroconf/commit/7de655b6f05012f20a3671e0bcdd44a1913d7b52)) +- Small performance improvement for converting time + ([#1342](https://github.com/python-zeroconf/python-zeroconf/pull/1342), + [`73d3ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/73d3ab90dd3b59caab771235dd6dbedf05bfe0b3)) -* feat: small performance improvement for converting time (#1342) ([`73d3ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/73d3ab90dd3b59caab771235dd6dbedf05bfe0b3)) +- Small performance improvement for ServiceInfo asking questions + ([#1341](https://github.com/python-zeroconf/python-zeroconf/pull/1341), + [`810a309`](https://github.com/python-zeroconf/python-zeroconf/commit/810a3093c5a9411ee97740b468bd706bdf4a95de)) -* feat: small performance improvement for ServiceInfo asking questions (#1341) ([`810a309`](https://github.com/python-zeroconf/python-zeroconf/commit/810a3093c5a9411ee97740b468bd706bdf4a95de)) +- Small speed up to processing incoming records + ([#1345](https://github.com/python-zeroconf/python-zeroconf/pull/1345), + [`7de655b`](https://github.com/python-zeroconf/python-zeroconf/commit/7de655b6f05012f20a3671e0bcdd44a1913d7b52)) -* feat: small performance improvement constructing outgoing questions (#1340) ([`157185f`](https://github.com/python-zeroconf/python-zeroconf/commit/157185f28bf1e83e6811e2a5cd1fa9b38966f780)) +- Small speed up to ServiceInfo construction + ([#1346](https://github.com/python-zeroconf/python-zeroconf/pull/1346), + [`b329d99`](https://github.com/python-zeroconf/python-zeroconf/commit/b329d99917bb731b4c70bf20c7c010eeb85ad9fd)) ## v0.129.0 (2023-12-13) ### Features -* feat: add decoded_properties method to ServiceInfo (#1332) ([`9b595a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9b595a1dcacf109c699953219d70fe36296c7318)) +- Add decoded_properties method to ServiceInfo + ([#1332](https://github.com/python-zeroconf/python-zeroconf/pull/1332), + [`9b595a1`](https://github.com/python-zeroconf/python-zeroconf/commit/9b595a1dcacf109c699953219d70fe36296c7318)) -* feat: ensure ServiceInfo.properties always returns bytes (#1333) ([`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) +- Cache is_unspecified for zeroconf ip address objects + ([#1331](https://github.com/python-zeroconf/python-zeroconf/pull/1331), + [`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) -* feat: cache is_unspecified for zeroconf ip address objects (#1331) ([`a1c84dc`](https://github.com/python-zeroconf/python-zeroconf/commit/a1c84dc6adeebd155faec1a647c0f70d70de2945)) +- Ensure ServiceInfo.properties always returns bytes + ([#1333](https://github.com/python-zeroconf/python-zeroconf/pull/1333), + [`d29553a`](https://github.com/python-zeroconf/python-zeroconf/commit/d29553ab7de6b7af70769ddb804fe2aaf492f320)) ## v0.128.5 (2023-12-13) ### Bug Fixes -* fix: performance regression with ServiceInfo IPv6Addresses (#1330) ([`e2f9f81`](https://github.com/python-zeroconf/python-zeroconf/commit/e2f9f81dbc54c3dd527eeb3298897d63f99d33f4)) +- Performance regression with ServiceInfo IPv6Addresses + ([#1330](https://github.com/python-zeroconf/python-zeroconf/pull/1330), + [`e2f9f81`](https://github.com/python-zeroconf/python-zeroconf/commit/e2f9f81dbc54c3dd527eeb3298897d63f99d33f4)) ## v0.128.4 (2023-12-10) ### Bug Fixes -* fix: re-expose ServiceInfo._set_properties for backwards compat (#1327) ([`39c4005`](https://github.com/python-zeroconf/python-zeroconf/commit/39c40051d7a63bdc63a3e2dfa20bd944fee4e761)) +- Re-expose ServiceInfo._set_properties for backwards compat + ([#1327](https://github.com/python-zeroconf/python-zeroconf/pull/1327), + [`39c4005`](https://github.com/python-zeroconf/python-zeroconf/commit/39c40051d7a63bdc63a3e2dfa20bd944fee4e761)) ## v0.128.3 (2023-12-10) ### Bug Fixes -* fix: correct nsec record writing (#1326) ([`cd7a16a`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7a16a32c37b2f7a2e90d3c749525a5393bad57)) +- Correct nsec record writing + ([#1326](https://github.com/python-zeroconf/python-zeroconf/pull/1326), + [`cd7a16a`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7a16a32c37b2f7a2e90d3c749525a5393bad57)) ## v0.128.2 (2023-12-10) ### Bug Fixes -* fix: timestamps missing double precision (#1324) ([`ecea4e4`](https://github.com/python-zeroconf/python-zeroconf/commit/ecea4e4217892ca8cf763074ac3e5d1b898acd21)) +- Match cython version for dev deps to build deps + ([#1325](https://github.com/python-zeroconf/python-zeroconf/pull/1325), + [`a0dac46`](https://github.com/python-zeroconf/python-zeroconf/commit/a0dac46c01202b3d5a0823ac1928fc1d75332522)) -* fix: match cython version for dev deps to build deps (#1325) ([`a0dac46`](https://github.com/python-zeroconf/python-zeroconf/commit/a0dac46c01202b3d5a0823ac1928fc1d75332522)) +- Timestamps missing double precision + ([#1324](https://github.com/python-zeroconf/python-zeroconf/pull/1324), + [`ecea4e4`](https://github.com/python-zeroconf/python-zeroconf/commit/ecea4e4217892ca8cf763074ac3e5d1b898acd21)) ## v0.128.1 (2023-12-10) ### Bug Fixes -* fix: correct handling of IPv6 addresses with scope_id in ServiceInfo (#1322) ([`1682991`](https://github.com/python-zeroconf/python-zeroconf/commit/1682991b985b1f7b2bf0cff1a7eb7793070e7cb1)) +- Correct handling of IPv6 addresses with scope_id in ServiceInfo + ([#1322](https://github.com/python-zeroconf/python-zeroconf/pull/1322), + [`1682991`](https://github.com/python-zeroconf/python-zeroconf/commit/1682991b985b1f7b2bf0cff1a7eb7793070e7cb1)) ## v0.128.0 (2023-12-02) ### Features -* feat: speed up unpacking TXT record data in ServiceInfo (#1318) ([`a200842`](https://github.com/python-zeroconf/python-zeroconf/commit/a20084281e66bdb9c37183a5eb992435f5b866ac)) +- Speed up unpacking TXT record data in ServiceInfo + ([#1318](https://github.com/python-zeroconf/python-zeroconf/pull/1318), + [`a200842`](https://github.com/python-zeroconf/python-zeroconf/commit/a20084281e66bdb9c37183a5eb992435f5b866ac)) ## v0.127.0 (2023-11-15) ### Features -* feat: small speed up to writing outgoing packets (#1316) ([`cd28476`](https://github.com/python-zeroconf/python-zeroconf/commit/cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65)) +- Small speed up to processing incoming dns records + ([#1315](https://github.com/python-zeroconf/python-zeroconf/pull/1315), + [`bfe4c24`](https://github.com/python-zeroconf/python-zeroconf/commit/bfe4c24881a7259713425df5ab00ffe487518841)) -* feat: speed up incoming packet reader (#1314) ([`0d60b61`](https://github.com/python-zeroconf/python-zeroconf/commit/0d60b61538a5d4b6f44b2369333b6e916a0a55b4)) +- Small speed up to writing outgoing packets + ([#1316](https://github.com/python-zeroconf/python-zeroconf/pull/1316), + [`cd28476`](https://github.com/python-zeroconf/python-zeroconf/commit/cd28476f6b0a6c2c733273fb24ddaac6c7bbdf65)) -* feat: small speed up to processing incoming dns records (#1315) ([`bfe4c24`](https://github.com/python-zeroconf/python-zeroconf/commit/bfe4c24881a7259713425df5ab00ffe487518841)) +- Speed up incoming packet reader + ([#1314](https://github.com/python-zeroconf/python-zeroconf/pull/1314), + [`0d60b61`](https://github.com/python-zeroconf/python-zeroconf/commit/0d60b61538a5d4b6f44b2369333b6e916a0a55b4)) ## v0.126.0 (2023-11-13) ### Features -* feat: speed up outgoing packet writer (#1313) ([`55cf4cc`](https://github.com/python-zeroconf/python-zeroconf/commit/55cf4ccdff886a136db4e2133d3e6cdd001a8bd6)) +- Speed up outgoing packet writer + ([#1313](https://github.com/python-zeroconf/python-zeroconf/pull/1313), + [`55cf4cc`](https://github.com/python-zeroconf/python-zeroconf/commit/55cf4ccdff886a136db4e2133d3e6cdd001a8bd6)) -* feat: speed up writing name compression for outgoing packets (#1312) ([`9caeabb`](https://github.com/python-zeroconf/python-zeroconf/commit/9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b)) +- Speed up writing name compression for outgoing packets + ([#1312](https://github.com/python-zeroconf/python-zeroconf/pull/1312), + [`9caeabb`](https://github.com/python-zeroconf/python-zeroconf/commit/9caeabb6d4659a25ea1251c1ee7bb824e05f3d8b)) ## v0.125.0 (2023-11-12) ### Features -* feat: speed up service browser queries when browsing many types (#1311) ([`d192d33`](https://github.com/python-zeroconf/python-zeroconf/commit/d192d33b1f05aa95a89965e86210aec086673a17)) +- Speed up service browser queries when browsing many types + ([#1311](https://github.com/python-zeroconf/python-zeroconf/pull/1311), + [`d192d33`](https://github.com/python-zeroconf/python-zeroconf/commit/d192d33b1f05aa95a89965e86210aec086673a17)) ## v0.124.0 (2023-11-12) ### Features -* feat: avoid decoding known answers if we have no answers to give (#1308) ([`605dc9c`](https://github.com/python-zeroconf/python-zeroconf/commit/605dc9ccd843a535802031f051b3d93310186ad1)) +- Avoid decoding known answers if we have no answers to give + ([#1308](https://github.com/python-zeroconf/python-zeroconf/pull/1308), + [`605dc9c`](https://github.com/python-zeroconf/python-zeroconf/commit/605dc9ccd843a535802031f051b3d93310186ad1)) -* feat: small speed up to process incoming packets (#1309) ([`56ef908`](https://github.com/python-zeroconf/python-zeroconf/commit/56ef90865189c01d2207abcc5e2efe3a7a022fa1)) +- Small speed up to process incoming packets + ([#1309](https://github.com/python-zeroconf/python-zeroconf/pull/1309), + [`56ef908`](https://github.com/python-zeroconf/python-zeroconf/commit/56ef90865189c01d2207abcc5e2efe3a7a022fa1)) ## v0.123.0 (2023-11-12) ### Features -* feat: speed up instances only used to lookup answers (#1307) ([`0701b8a`](https://github.com/python-zeroconf/python-zeroconf/commit/0701b8ab6009891cbaddaa1d17116d31fd1b2f78)) +- Speed up instances only used to lookup answers + ([#1307](https://github.com/python-zeroconf/python-zeroconf/pull/1307), + [`0701b8a`](https://github.com/python-zeroconf/python-zeroconf/commit/0701b8ab6009891cbaddaa1d17116d31fd1b2f78)) ## v0.122.3 (2023-11-09) ### Bug Fixes -* fix: do not build musllinux aarch64 wheels to reduce release time (#1306) ([`79aafb0`](https://github.com/python-zeroconf/python-zeroconf/commit/79aafb0acf7ca6b17976be7ede748008deada27b)) +- Do not build musllinux aarch64 wheels to reduce release time + ([#1306](https://github.com/python-zeroconf/python-zeroconf/pull/1306), + [`79aafb0`](https://github.com/python-zeroconf/python-zeroconf/commit/79aafb0acf7ca6b17976be7ede748008deada27b)) ## v0.122.2 (2023-11-09) ### Bug Fixes -* fix: do not build aarch64 wheels for PyPy (#1305) ([`7e884db`](https://github.com/python-zeroconf/python-zeroconf/commit/7e884db4d958459e64257aba860dba2450db0687)) +- Do not build aarch64 wheels for PyPy + ([#1305](https://github.com/python-zeroconf/python-zeroconf/pull/1305), + [`7e884db`](https://github.com/python-zeroconf/python-zeroconf/commit/7e884db4d958459e64257aba860dba2450db0687)) ## v0.122.1 (2023-11-09) ### Bug Fixes -* fix: skip wheel builds for eol python and older python with aarch64 (#1304) ([`6c8f5a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6c8f5a5dec2072aa6a8f889c5d8a4623ab392234)) +- Skip wheel builds for eol python and older python with aarch64 + ([#1304](https://github.com/python-zeroconf/python-zeroconf/pull/1304), + [`6c8f5a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6c8f5a5dec2072aa6a8f889c5d8a4623ab392234)) ## v0.122.0 (2023-11-08) ### Features -* feat: build aarch64 wheels (#1302) ([`4fe58e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83)) +- Build aarch64 wheels ([#1302](https://github.com/python-zeroconf/python-zeroconf/pull/1302), + [`4fe58e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4fe58e2edc6da64a8ece0e2b16ec9ebfc5b3cd83)) ## v0.121.0 (2023-11-08) ### Features -* feat: speed up record updates (#1301) ([`d2af6a0`](https://github.com/python-zeroconf/python-zeroconf/commit/d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4)) +- Speed up record updates ([#1301](https://github.com/python-zeroconf/python-zeroconf/pull/1301), + [`d2af6a0`](https://github.com/python-zeroconf/python-zeroconf/commit/d2af6a0978f5abe4f8bb70d3e29d9836d0fd77c4)) ## v0.120.0 (2023-11-05) ### Features -* feat: speed up incoming packet processing with a memory view (#1290) ([`f1f0a25`](https://github.com/python-zeroconf/python-zeroconf/commit/f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7)) +- Speed up decoding labels from incoming data + ([#1291](https://github.com/python-zeroconf/python-zeroconf/pull/1291), + [`c37ead4`](https://github.com/python-zeroconf/python-zeroconf/commit/c37ead4d7000607e81706a97b4cdffd80cf8cf99)) -* feat: speed up decoding labels from incoming data (#1291) ([`c37ead4`](https://github.com/python-zeroconf/python-zeroconf/commit/c37ead4d7000607e81706a97b4cdffd80cf8cf99)) +- Speed up incoming packet processing with a memory view + ([#1290](https://github.com/python-zeroconf/python-zeroconf/pull/1290), + [`f1f0a25`](https://github.com/python-zeroconf/python-zeroconf/commit/f1f0a2504afd4d29bc6b7cf715cd3cb81b9049f7)) -* feat: speed up ServiceBrowsers with a pxd for the signal interface (#1289) ([`8a17f20`](https://github.com/python-zeroconf/python-zeroconf/commit/8a17f2053a89db4beca9e8c1de4640faf27726b4)) +- Speed up ServiceBrowsers with a pxd for the signal interface + ([#1289](https://github.com/python-zeroconf/python-zeroconf/pull/1289), + [`8a17f20`](https://github.com/python-zeroconf/python-zeroconf/commit/8a17f2053a89db4beca9e8c1de4640faf27726b4)) ## v0.119.0 (2023-10-18) ### Features -* feat: update cibuildwheel to build wheels on latest cython final release (#1285) ([`e8c9083`](https://github.com/python-zeroconf/python-zeroconf/commit/e8c9083bb118764a85b12fac9055152a2f62a212)) +- Update cibuildwheel to build wheels on latest cython final release + ([#1285](https://github.com/python-zeroconf/python-zeroconf/pull/1285), + [`e8c9083`](https://github.com/python-zeroconf/python-zeroconf/commit/e8c9083bb118764a85b12fac9055152a2f62a212)) ## v0.118.1 (2023-10-18) ### Bug Fixes -* fix: reduce size of wheels by excluding generated .c files (#1284) ([`b6afa4b`](https://github.com/python-zeroconf/python-zeroconf/commit/b6afa4b2775a1fdb090145eccdc5711c98e7147a)) +- Reduce size of wheels by excluding generated .c files + ([#1284](https://github.com/python-zeroconf/python-zeroconf/pull/1284), + [`b6afa4b`](https://github.com/python-zeroconf/python-zeroconf/commit/b6afa4b2775a1fdb090145eccdc5711c98e7147a)) ## v0.118.0 (2023-10-14) ### Features -* feat: small improvements to ServiceBrowser performance (#1283) ([`0fc031b`](https://github.com/python-zeroconf/python-zeroconf/commit/0fc031b1e7bf1766d5a1d39d70d300b86e36715e)) +- Small improvements to ServiceBrowser performance + ([#1283](https://github.com/python-zeroconf/python-zeroconf/pull/1283), + [`0fc031b`](https://github.com/python-zeroconf/python-zeroconf/commit/0fc031b1e7bf1766d5a1d39d70d300b86e36715e)) ## v0.117.0 (2023-10-14) ### Features -* feat: small cleanups to incoming data handlers (#1282) ([`4f4bd9f`](https://github.com/python-zeroconf/python-zeroconf/commit/4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9)) +- Small cleanups to incoming data handlers + ([#1282](https://github.com/python-zeroconf/python-zeroconf/pull/1282), + [`4f4bd9f`](https://github.com/python-zeroconf/python-zeroconf/commit/4f4bd9ff7c1e575046e5ea213d9b8c91ac7a24a9)) ## v0.116.0 (2023-10-13) ### Features -* feat: reduce type checking overhead at run time (#1281) ([`8f30099`](https://github.com/python-zeroconf/python-zeroconf/commit/8f300996e5bd4316b2237f0502791dd0d6a855fe)) +- Reduce type checking overhead at run time + ([#1281](https://github.com/python-zeroconf/python-zeroconf/pull/1281), + [`8f30099`](https://github.com/python-zeroconf/python-zeroconf/commit/8f300996e5bd4316b2237f0502791dd0d6a855fe)) ## v0.115.2 (2023-10-05) ### Bug Fixes -* fix: ensure ServiceInfo cache is cleared when adding to the registry (#1279) +- Ensure ServiceInfo cache is cleared when adding to the registry + ([#1279](https://github.com/python-zeroconf/python-zeroconf/pull/1279), + [`2060eb2`](https://github.com/python-zeroconf/python-zeroconf/commit/2060eb2cc43489c34bea08924c3f40b875d5a498)) -* There were production use cases that mutated the service info and re-registered it that need to be accounted for ([`2060eb2`](https://github.com/python-zeroconf/python-zeroconf/commit/2060eb2cc43489c34bea08924c3f40b875d5a498)) +* There were production use cases that mutated the service info and re-registered it that need to be + accounted for ## v0.115.1 (2023-10-01) ### Bug Fixes -* fix: add missing python definition for addresses_by_version (#1278) ([`52ee02b`](https://github.com/python-zeroconf/python-zeroconf/commit/52ee02b16860e344c402124f4b2e2869536ec839)) +- Add missing python definition for addresses_by_version + ([#1278](https://github.com/python-zeroconf/python-zeroconf/pull/1278), + [`52ee02b`](https://github.com/python-zeroconf/python-zeroconf/commit/52ee02b16860e344c402124f4b2e2869536ec839)) ## v0.115.0 (2023-09-26) ### Features -* feat: speed up outgoing multicast queue (#1277) ([`a13fd49`](https://github.com/python-zeroconf/python-zeroconf/commit/a13fd49d77474fd5858de809e48cbab1ccf89173)) +- Speed up outgoing multicast queue + ([#1277](https://github.com/python-zeroconf/python-zeroconf/pull/1277), + [`a13fd49`](https://github.com/python-zeroconf/python-zeroconf/commit/a13fd49d77474fd5858de809e48cbab1ccf89173)) ## v0.114.0 (2023-09-25) ### Features -* feat: speed up responding to queries (#1275) ([`3c6b18c`](https://github.com/python-zeroconf/python-zeroconf/commit/3c6b18cdf4c94773ad6f4497df98feb337939ee9)) +- Speed up responding to queries + ([#1275](https://github.com/python-zeroconf/python-zeroconf/pull/1275), + [`3c6b18c`](https://github.com/python-zeroconf/python-zeroconf/commit/3c6b18cdf4c94773ad6f4497df98feb337939ee9)) ## v0.113.0 (2023-09-24) ### Features -* feat: improve performance of loading records from cache in ServiceInfo (#1274) ([`6257d49`](https://github.com/python-zeroconf/python-zeroconf/commit/6257d49952e02107f800f4ad4894716508edfcda)) +- Improve performance of loading records from cache in ServiceInfo + ([#1274](https://github.com/python-zeroconf/python-zeroconf/pull/1274), + [`6257d49`](https://github.com/python-zeroconf/python-zeroconf/commit/6257d49952e02107f800f4ad4894716508edfcda)) ## v0.112.0 (2023-09-14) ### Features -* feat: improve AsyncServiceBrowser performance (#1273) ([`0c88ecf`](https://github.com/python-zeroconf/python-zeroconf/commit/0c88ecf5ef6b9b256f991e7a630048de640999a6)) +- Improve AsyncServiceBrowser performance + ([#1273](https://github.com/python-zeroconf/python-zeroconf/pull/1273), + [`0c88ecf`](https://github.com/python-zeroconf/python-zeroconf/commit/0c88ecf5ef6b9b256f991e7a630048de640999a6)) ## v0.111.0 (2023-09-14) ### Features -* feat: speed up question and answer internals (#1272) ([`d24722b`](https://github.com/python-zeroconf/python-zeroconf/commit/d24722bfa4201d48ab482d35b0ef004f070ada80)) +- Speed up question and answer internals + ([#1272](https://github.com/python-zeroconf/python-zeroconf/pull/1272), + [`d24722b`](https://github.com/python-zeroconf/python-zeroconf/commit/d24722bfa4201d48ab482d35b0ef004f070ada80)) ## v0.110.0 (2023-09-14) ### Features -* feat: small speed ups to ServiceBrowser (#1271) ([`22c433d`](https://github.com/python-zeroconf/python-zeroconf/commit/22c433ddaea3049ac49933325ba938fd87a529c0)) +- Small speed ups to ServiceBrowser + ([#1271](https://github.com/python-zeroconf/python-zeroconf/pull/1271), + [`22c433d`](https://github.com/python-zeroconf/python-zeroconf/commit/22c433ddaea3049ac49933325ba938fd87a529c0)) ## v0.109.0 (2023-09-14) ### Features -* feat: speed up ServiceBrowsers with a cython pxd (#1270) ([`4837876`](https://github.com/python-zeroconf/python-zeroconf/commit/48378769c3887b5746ca00de30067a4c0851765c)) +- Speed up ServiceBrowsers with a cython pxd + ([#1270](https://github.com/python-zeroconf/python-zeroconf/pull/1270), + [`4837876`](https://github.com/python-zeroconf/python-zeroconf/commit/48378769c3887b5746ca00de30067a4c0851765c)) ## v0.108.0 (2023-09-11) ### Features -* feat: improve performance of constructing outgoing queries (#1267) ([`00c439a`](https://github.com/python-zeroconf/python-zeroconf/commit/00c439a6400b7850ef9fdd75bc8d82d4e64b1da0)) +- Improve performance of constructing outgoing queries + ([#1267](https://github.com/python-zeroconf/python-zeroconf/pull/1267), + [`00c439a`](https://github.com/python-zeroconf/python-zeroconf/commit/00c439a6400b7850ef9fdd75bc8d82d4e64b1da0)) ## v0.107.0 (2023-09-11) ### Features -* feat: speed up responding to queries (#1266) ([`24a0a00`](https://github.com/python-zeroconf/python-zeroconf/commit/24a0a00b3e457979e279a2eeadc8fad2ab09e125)) +- Speed up responding to queries + ([#1266](https://github.com/python-zeroconf/python-zeroconf/pull/1266), + [`24a0a00`](https://github.com/python-zeroconf/python-zeroconf/commit/24a0a00b3e457979e279a2eeadc8fad2ab09e125)) ## v0.106.0 (2023-09-11) ### Features -* feat: speed up answering questions (#1265) ([`37bfaf2`](https://github.com/python-zeroconf/python-zeroconf/commit/37bfaf2f630358e8c68652f3b3120931a6f94910)) +- Speed up answering questions + ([#1265](https://github.com/python-zeroconf/python-zeroconf/pull/1265), + [`37bfaf2`](https://github.com/python-zeroconf/python-zeroconf/commit/37bfaf2f630358e8c68652f3b3120931a6f94910)) ## v0.105.0 (2023-09-10) ### Features -* feat: speed up ServiceInfo with a cython pxd (#1264) ([`7ca690a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ca690ac3fa75e7474d3412944bbd5056cb313dd)) +- Speed up ServiceInfo with a cython pxd + ([#1264](https://github.com/python-zeroconf/python-zeroconf/pull/1264), + [`7ca690a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ca690ac3fa75e7474d3412944bbd5056cb313dd)) ## v0.104.0 (2023-09-10) ### Features -* feat: speed up generating answers (#1262) ([`50a8f06`](https://github.com/python-zeroconf/python-zeroconf/commit/50a8f066b6ab90bc9e3300f81cf9332550b720df)) +- Speed up generating answers + ([#1262](https://github.com/python-zeroconf/python-zeroconf/pull/1262), + [`50a8f06`](https://github.com/python-zeroconf/python-zeroconf/commit/50a8f066b6ab90bc9e3300f81cf9332550b720df)) ## v0.103.0 (2023-09-09) ### Features -* feat: avoid calling get_running_loop when resolving ServiceInfo (#1261) ([`33a2714`](https://github.com/python-zeroconf/python-zeroconf/commit/33a2714cadff96edf016b869cc63b0661d16ef2c)) +- Avoid calling get_running_loop when resolving ServiceInfo + ([#1261](https://github.com/python-zeroconf/python-zeroconf/pull/1261), + [`33a2714`](https://github.com/python-zeroconf/python-zeroconf/commit/33a2714cadff96edf016b869cc63b0661d16ef2c)) ## v0.102.0 (2023-09-07) ### Features -* feat: significantly speed up writing outgoing dns records (#1260) ([`bf2f366`](https://github.com/python-zeroconf/python-zeroconf/commit/bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077)) +- Significantly speed up writing outgoing dns records + ([#1260](https://github.com/python-zeroconf/python-zeroconf/pull/1260), + [`bf2f366`](https://github.com/python-zeroconf/python-zeroconf/commit/bf2f3660a1f341e50ab0ae586dfbacbc5ddcc077)) ## v0.101.0 (2023-09-07) ### Features -* feat: speed up writing outgoing dns records (#1259) ([`248655f`](https://github.com/python-zeroconf/python-zeroconf/commit/248655f0276223b089373c70ec13a0385dfaa4d6)) +- Speed up writing outgoing dns records + ([#1259](https://github.com/python-zeroconf/python-zeroconf/pull/1259), + [`248655f`](https://github.com/python-zeroconf/python-zeroconf/commit/248655f0276223b089373c70ec13a0385dfaa4d6)) ## v0.100.0 (2023-09-07) ### Features -* feat: small speed up to writing outgoing dns records (#1258) ([`1ed6bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/1ed6bd2ec4db0612b71384f923ffff1efd3ce878)) +- Small speed up to writing outgoing dns records + ([#1258](https://github.com/python-zeroconf/python-zeroconf/pull/1258), + [`1ed6bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/1ed6bd2ec4db0612b71384f923ffff1efd3ce878)) ## v0.99.0 (2023-09-06) ### Features -* feat: reduce IP Address parsing overhead in ServiceInfo (#1257) ([`83d0b7f`](https://github.com/python-zeroconf/python-zeroconf/commit/83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb)) +- Reduce IP Address parsing overhead in ServiceInfo + ([#1257](https://github.com/python-zeroconf/python-zeroconf/pull/1257), + [`83d0b7f`](https://github.com/python-zeroconf/python-zeroconf/commit/83d0b7fda2eb09c9c6e18b85f329d1ddc701e3fb)) ## v0.98.0 (2023-09-06) ### Features -* feat: speed up decoding incoming packets (#1256) ([`ac081cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac081cf00addde1ceea2c076f73905fdb293de3a)) +- Speed up decoding incoming packets + ([#1256](https://github.com/python-zeroconf/python-zeroconf/pull/1256), + [`ac081cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac081cf00addde1ceea2c076f73905fdb293de3a)) ## v0.97.0 (2023-09-03) ### Features -* feat: speed up answering queries (#1255) ([`2d3aed3`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3aed36e24c73013fcf4acc90803fc1737d0917)) +- Speed up answering queries ([#1255](https://github.com/python-zeroconf/python-zeroconf/pull/1255), + [`2d3aed3`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3aed36e24c73013fcf4acc90803fc1737d0917)) ## v0.96.0 (2023-09-03) ### Features -* feat: optimize DNSCache.get_by_details (#1254) +- Optimize DNSCache.get_by_details + ([#1254](https://github.com/python-zeroconf/python-zeroconf/pull/1254), + [`ce59787`](https://github.com/python-zeroconf/python-zeroconf/commit/ce59787a170781ffdaa22425018d288b395ac081)) * feat: optimize DNSCache.get_by_details -This is one of the most called functions since ServiceInfo.load_from_cache calls -it +This is one of the most called functions since ServiceInfo.load_from_cache calls it * fix: make get_all_by_details thread-safe -* fix: remove unneeded key checks ([`ce59787`](https://github.com/python-zeroconf/python-zeroconf/commit/ce59787a170781ffdaa22425018d288b395ac081)) +* fix: remove unneeded key checks ## v0.95.0 (2023-09-03) ### Features -* feat: speed up adding and removing RecordUpdateListeners (#1253) ([`22e4a29`](https://github.com/python-zeroconf/python-zeroconf/commit/22e4a296d440b3038c0ff5ed6fc8878304ec4937)) +- Speed up adding and removing RecordUpdateListeners + ([#1253](https://github.com/python-zeroconf/python-zeroconf/pull/1253), + [`22e4a29`](https://github.com/python-zeroconf/python-zeroconf/commit/22e4a296d440b3038c0ff5ed6fc8878304ec4937)) ## v0.94.0 (2023-09-03) ### Features -* feat: optimize cache implementation (#1252) ([`8d3ec79`](https://github.com/python-zeroconf/python-zeroconf/commit/8d3ec792277aaf7ef790318b5b35ab00839ca3b3)) +- Optimize cache implementation + ([#1252](https://github.com/python-zeroconf/python-zeroconf/pull/1252), + [`8d3ec79`](https://github.com/python-zeroconf/python-zeroconf/commit/8d3ec792277aaf7ef790318b5b35ab00839ca3b3)) ## v0.93.1 (2023-09-03) ### Bug Fixes -* fix: no change re-release due to unrecoverable failed CI run (#1251) ([`730921b`](https://github.com/python-zeroconf/python-zeroconf/commit/730921b155dfb9c62251c8c643b1302e807aff3b)) +- No change re-release due to unrecoverable failed CI run + ([#1251](https://github.com/python-zeroconf/python-zeroconf/pull/1251), + [`730921b`](https://github.com/python-zeroconf/python-zeroconf/commit/730921b155dfb9c62251c8c643b1302e807aff3b)) ## v0.93.0 (2023-09-02) ### Features -* feat: reduce overhead to answer questions (#1250) ([`7cb8da0`](https://github.com/python-zeroconf/python-zeroconf/commit/7cb8da0c6c5c944588009fe36012c1197c422668)) +- Reduce overhead to answer questions + ([#1250](https://github.com/python-zeroconf/python-zeroconf/pull/1250), + [`7cb8da0`](https://github.com/python-zeroconf/python-zeroconf/commit/7cb8da0c6c5c944588009fe36012c1197c422668)) ## v0.92.0 (2023-09-02) ### Features -* feat: cache construction of records used to answer queries from the service registry (#1243) ([`0890f62`](https://github.com/python-zeroconf/python-zeroconf/commit/0890f628dbbd577fb77d3e6f2e267052b2b2b515)) +- Cache construction of records used to answer queries from the service registry + ([#1243](https://github.com/python-zeroconf/python-zeroconf/pull/1243), + [`0890f62`](https://github.com/python-zeroconf/python-zeroconf/commit/0890f628dbbd577fb77d3e6f2e267052b2b2b515)) ## v0.91.1 (2023-09-02) ### Bug Fixes -* fix: remove useless calls in ServiceInfo (#1248) ([`4e40fae`](https://github.com/python-zeroconf/python-zeroconf/commit/4e40fae20bf50b4608e28fad4a360c4ed48ac86b)) +- Remove useless calls in ServiceInfo + ([#1248](https://github.com/python-zeroconf/python-zeroconf/pull/1248), + [`4e40fae`](https://github.com/python-zeroconf/python-zeroconf/commit/4e40fae20bf50b4608e28fad4a360c4ed48ac86b)) ## v0.91.0 (2023-09-02) ### Features -* feat: reduce overhead to process incoming updates by avoiding the handle_response shim (#1247) ([`5e31f0a`](https://github.com/python-zeroconf/python-zeroconf/commit/5e31f0afe4c341fbdbbbe50348a829ea553cbda0)) +- Reduce overhead to process incoming updates by avoiding the handle_response shim + ([#1247](https://github.com/python-zeroconf/python-zeroconf/pull/1247), + [`5e31f0a`](https://github.com/python-zeroconf/python-zeroconf/commit/5e31f0afe4c341fbdbbbe50348a829ea553cbda0)) ## v0.90.0 (2023-09-02) ### Features -* feat: avoid python float conversion in listener hot path (#1245) ([`816ad4d`](https://github.com/python-zeroconf/python-zeroconf/commit/816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a)) +- Avoid python float conversion in listener hot path + ([#1245](https://github.com/python-zeroconf/python-zeroconf/pull/1245), + [`816ad4d`](https://github.com/python-zeroconf/python-zeroconf/commit/816ad4dceb3859bad4bb136bdb1d1ee2daa0bf5a)) ### Refactoring -* refactor: reduce duplicate code in engine.py (#1246) ([`36ae505`](https://github.com/python-zeroconf/python-zeroconf/commit/36ae505dc9f95b59fdfb632960845a45ba8575b8)) +- Reduce duplicate code in engine.py + ([#1246](https://github.com/python-zeroconf/python-zeroconf/pull/1246), + [`36ae505`](https://github.com/python-zeroconf/python-zeroconf/commit/36ae505dc9f95b59fdfb632960845a45ba8575b8)) ## v0.89.0 (2023-09-02) ### Features -* feat: reduce overhead to process incoming questions (#1244) ([`18b65d1`](https://github.com/python-zeroconf/python-zeroconf/commit/18b65d1c75622869b0c29258215d3db3ae520d6c)) +- Reduce overhead to process incoming questions + ([#1244](https://github.com/python-zeroconf/python-zeroconf/pull/1244), + [`18b65d1`](https://github.com/python-zeroconf/python-zeroconf/commit/18b65d1c75622869b0c29258215d3db3ae520d6c)) ## v0.88.0 (2023-08-29) ### Features -* feat: speed up RecordManager with additional cython defs (#1242) ([`5a76fc5`](https://github.com/python-zeroconf/python-zeroconf/commit/5a76fc5ff74f2941ffbf7570e45390f35e0b7e01)) +- Speed up RecordManager with additional cython defs + ([#1242](https://github.com/python-zeroconf/python-zeroconf/pull/1242), + [`5a76fc5`](https://github.com/python-zeroconf/python-zeroconf/commit/5a76fc5ff74f2941ffbf7570e45390f35e0b7e01)) ## v0.87.0 (2023-08-29) ### Features -* feat: improve performance by adding cython pxd for RecordManager (#1241) ([`a7dad3d`](https://github.com/python-zeroconf/python-zeroconf/commit/a7dad3d9743586f352e21eea1e129c6875f9a713)) +- Improve performance by adding cython pxd for RecordManager + ([#1241](https://github.com/python-zeroconf/python-zeroconf/pull/1241), + [`a7dad3d`](https://github.com/python-zeroconf/python-zeroconf/commit/a7dad3d9743586f352e21eea1e129c6875f9a713)) ## v0.86.0 (2023-08-28) ### Features -* feat: build wheels for cpython 3.12 (#1239) ([`58bc154`](https://github.com/python-zeroconf/python-zeroconf/commit/58bc154f55b06b4ddfc4a141592488abe76f062a)) +- Build wheels for cpython 3.12 + ([#1239](https://github.com/python-zeroconf/python-zeroconf/pull/1239), + [`58bc154`](https://github.com/python-zeroconf/python-zeroconf/commit/58bc154f55b06b4ddfc4a141592488abe76f062a)) -* feat: use server_key when processing DNSService records (#1238) ([`cc8feb1`](https://github.com/python-zeroconf/python-zeroconf/commit/cc8feb110fefc3fb714fd482a52f16e2b620e8c4)) +- Use server_key when processing DNSService records + ([#1238](https://github.com/python-zeroconf/python-zeroconf/pull/1238), + [`cc8feb1`](https://github.com/python-zeroconf/python-zeroconf/commit/cc8feb110fefc3fb714fd482a52f16e2b620e8c4)) ## v0.85.0 (2023-08-27) ### Features -* feat: simplify code to unpack properties (#1237) ([`68d9998`](https://github.com/python-zeroconf/python-zeroconf/commit/68d99985a0e9d2c72ff670b2e2af92271a6fe934)) +- Simplify code to unpack properties + ([#1237](https://github.com/python-zeroconf/python-zeroconf/pull/1237), + [`68d9998`](https://github.com/python-zeroconf/python-zeroconf/commit/68d99985a0e9d2c72ff670b2e2af92271a6fe934)) ## v0.84.0 (2023-08-27) ### Features -* feat: context managers in ServiceBrowser and AsyncServiceBrowser (#1233) +- Context managers in ServiceBrowser and AsyncServiceBrowser + ([#1233](https://github.com/python-zeroconf/python-zeroconf/pull/1233), + [`bd8d846`](https://github.com/python-zeroconf/python-zeroconf/commit/bd8d8467dec2a39a0b525043ea1051259100fded)) -Co-authored-by: J. Nick Koston ([`bd8d846`](https://github.com/python-zeroconf/python-zeroconf/commit/bd8d8467dec2a39a0b525043ea1051259100fded)) +Co-authored-by: J. Nick Koston ## v0.83.1 (2023-08-27) ### Bug Fixes -* fix: rebuild wheels with cython 3.0.2 (#1236) ([`dd637fb`](https://github.com/python-zeroconf/python-zeroconf/commit/dd637fb2e5a87ba283750e69d116e124bef54e7c)) +- Rebuild wheels with cython 3.0.2 + ([#1236](https://github.com/python-zeroconf/python-zeroconf/pull/1236), + [`dd637fb`](https://github.com/python-zeroconf/python-zeroconf/commit/dd637fb2e5a87ba283750e69d116e124bef54e7c)) ## v0.83.0 (2023-08-26) ### Features -* feat: speed up question and answer history with a cython pxd (#1234) ([`703ecb2`](https://github.com/python-zeroconf/python-zeroconf/commit/703ecb2901b2150fb72fac3deed61d7302561298)) +- Speed up question and answer history with a cython pxd + ([#1234](https://github.com/python-zeroconf/python-zeroconf/pull/1234), + [`703ecb2`](https://github.com/python-zeroconf/python-zeroconf/commit/703ecb2901b2150fb72fac3deed61d7302561298)) ## v0.82.1 (2023-08-22) ### Bug Fixes -* fix: build failures with older cython 0.29 series (#1232) ([`30c3ad9`](https://github.com/python-zeroconf/python-zeroconf/commit/30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced)) +- Build failures with older cython 0.29 series + ([#1232](https://github.com/python-zeroconf/python-zeroconf/pull/1232), + [`30c3ad9`](https://github.com/python-zeroconf/python-zeroconf/commit/30c3ad9d1bc6b589e1ca6675fea21907ebcd1ced)) ## v0.82.0 (2023-08-22) ### Features -* feat: optimize processing of records in RecordUpdateListener subclasses (#1231) ([`3e89294`](https://github.com/python-zeroconf/python-zeroconf/commit/3e89294ea0ecee1122e1c1ffdc78925add8ca40e)) +- Optimize processing of records in RecordUpdateListener subclasses + ([#1231](https://github.com/python-zeroconf/python-zeroconf/pull/1231), + [`3e89294`](https://github.com/python-zeroconf/python-zeroconf/commit/3e89294ea0ecee1122e1c1ffdc78925add8ca40e)) ## v0.81.0 (2023-08-22) ### Features -* feat: speed up the service registry with a cython pxd (#1226) ([`47d3c7a`](https://github.com/python-zeroconf/python-zeroconf/commit/47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e)) +- Optimizing sending answers to questions + ([#1227](https://github.com/python-zeroconf/python-zeroconf/pull/1227), + [`cd7b56b`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7b56b2aa0c8ee429da430e9a36abd515512011)) -* feat: optimizing sending answers to questions (#1227) ([`cd7b56b`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7b56b2aa0c8ee429da430e9a36abd515512011)) +- Speed up the service registry with a cython pxd + ([#1226](https://github.com/python-zeroconf/python-zeroconf/pull/1226), + [`47d3c7a`](https://github.com/python-zeroconf/python-zeroconf/commit/47d3c7ad4bc5f2247631c3ad5e6b6156d45a0a4e)) ## v0.80.0 (2023-08-15) ### Features -* feat: optimize unpacking properties in ServiceInfo (#1225) ([`1492e41`](https://github.com/python-zeroconf/python-zeroconf/commit/1492e41b3d5cba5598cc9dd6bd2bc7d238f13555)) +- Optimize unpacking properties in ServiceInfo + ([#1225](https://github.com/python-zeroconf/python-zeroconf/pull/1225), + [`1492e41`](https://github.com/python-zeroconf/python-zeroconf/commit/1492e41b3d5cba5598cc9dd6bd2bc7d238f13555)) ## v0.79.0 (2023-08-14) ### Features -* feat: refactor notify implementation to reduce overhead of adding and removing listeners (#1224) ([`ceb92cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb92cfe42d885dbb38cee7aaeebf685d97627a9)) +- Refactor notify implementation to reduce overhead of adding and removing listeners + ([#1224](https://github.com/python-zeroconf/python-zeroconf/pull/1224), + [`ceb92cf`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb92cfe42d885dbb38cee7aaeebf685d97627a9)) ## v0.78.0 (2023-08-14) ### Features -* feat: add cython pxd file for _listener.py to improve incoming message processing performance (#1221) ([`f459856`](https://github.com/python-zeroconf/python-zeroconf/commit/f459856a0a61b8afa8a541926d7e15d51f8e4aea)) +- Add cython pxd file for _listener.py to improve incoming message processing performance + ([#1221](https://github.com/python-zeroconf/python-zeroconf/pull/1221), + [`f459856`](https://github.com/python-zeroconf/python-zeroconf/commit/f459856a0a61b8afa8a541926d7e15d51f8e4aea)) ## v0.77.0 (2023-08-14) ### Features -* feat: cythonize _listener.py to improve incoming message processing performance (#1220) ([`9efde8c`](https://github.com/python-zeroconf/python-zeroconf/commit/9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9)) +- Cythonize _listener.py to improve incoming message processing performance + ([#1220](https://github.com/python-zeroconf/python-zeroconf/pull/1220), + [`9efde8c`](https://github.com/python-zeroconf/python-zeroconf/commit/9efde8c8c1ed14c5d3c162f185b49212fcfcb5c9)) ## v0.76.0 (2023-08-14) ### Features -* feat: improve performance responding to queries (#1217) ([`69b33be`](https://github.com/python-zeroconf/python-zeroconf/commit/69b33be3b2f9d4a27ef5154cae94afca048efffa)) +- Improve performance responding to queries + ([#1217](https://github.com/python-zeroconf/python-zeroconf/pull/1217), + [`69b33be`](https://github.com/python-zeroconf/python-zeroconf/commit/69b33be3b2f9d4a27ef5154cae94afca048efffa)) ## v0.75.0 (2023-08-13) ### Features -* feat: expose flag to disable strict name checking in service registration (#1215) ([`5df8a57`](https://github.com/python-zeroconf/python-zeroconf/commit/5df8a57a14d59687a3c22ea8ee063e265031e278)) +- Expose flag to disable strict name checking in service registration + ([#1215](https://github.com/python-zeroconf/python-zeroconf/pull/1215), + [`5df8a57`](https://github.com/python-zeroconf/python-zeroconf/commit/5df8a57a14d59687a3c22ea8ee063e265031e278)) -* feat: speed up processing incoming records (#1216) ([`aff625d`](https://github.com/python-zeroconf/python-zeroconf/commit/aff625dc6a5e816dad519644c4adac4f96980c04)) +- Speed up processing incoming records + ([#1216](https://github.com/python-zeroconf/python-zeroconf/pull/1216), + [`aff625d`](https://github.com/python-zeroconf/python-zeroconf/commit/aff625dc6a5e816dad519644c4adac4f96980c04)) ## v0.74.0 (2023-08-04) ### Bug Fixes -* fix: remove typing on reset_ttl for cython compat (#1213) ([`0094e26`](https://github.com/python-zeroconf/python-zeroconf/commit/0094e2684344c6b7edd7948924f093f1b4c19901)) +- Remove typing on reset_ttl for cython compat + ([#1213](https://github.com/python-zeroconf/python-zeroconf/pull/1213), + [`0094e26`](https://github.com/python-zeroconf/python-zeroconf/commit/0094e2684344c6b7edd7948924f093f1b4c19901)) ### Features -* feat: speed up unpacking text records in ServiceInfo (#1212) ([`99a6f98`](https://github.com/python-zeroconf/python-zeroconf/commit/99a6f98e44a1287ba537eabb852b1b69923402f0)) +- Speed up unpacking text records in ServiceInfo + ([#1212](https://github.com/python-zeroconf/python-zeroconf/pull/1212), + [`99a6f98`](https://github.com/python-zeroconf/python-zeroconf/commit/99a6f98e44a1287ba537eabb852b1b69923402f0)) ## v0.73.0 (2023-08-03) ### Features -* feat: add a cache to service_type_name (#1211) ([`53a694f`](https://github.com/python-zeroconf/python-zeroconf/commit/53a694f60e675ae0560e727be6b721b401c2b68f)) +- Add a cache to service_type_name + ([#1211](https://github.com/python-zeroconf/python-zeroconf/pull/1211), + [`53a694f`](https://github.com/python-zeroconf/python-zeroconf/commit/53a694f60e675ae0560e727be6b721b401c2b68f)) ## v0.72.3 (2023-08-03) ### Bug Fixes -* fix: revert adding typing to DNSRecord.suppressed_by (#1210) ([`3dba5ae`](https://github.com/python-zeroconf/python-zeroconf/commit/3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a)) +- Revert adding typing to DNSRecord.suppressed_by + ([#1210](https://github.com/python-zeroconf/python-zeroconf/pull/1210), + [`3dba5ae`](https://github.com/python-zeroconf/python-zeroconf/commit/3dba5ae0c0e9473b7b20fd6fc79fa1a3b298dc5a)) ## v0.72.2 (2023-08-03) ### Bug Fixes -* fix: revert DNSIncoming cimport in _dns.pxd (#1209) ([`5f14b6d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1)) +- Revert DNSIncoming cimport in _dns.pxd + ([#1209](https://github.com/python-zeroconf/python-zeroconf/pull/1209), + [`5f14b6d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f14b6dc687b3a0716d0ca7f61ccf1e93dfe5fa1)) ## v0.72.1 (2023-08-03) ### Bug Fixes -* fix: race with InvalidStateError when async_request times out (#1208) ([`2233b6b`](https://github.com/python-zeroconf/python-zeroconf/commit/2233b6bc4ceeee5524d2ee88ecae8234173feb5f)) +- Race with InvalidStateError when async_request times out + ([#1208](https://github.com/python-zeroconf/python-zeroconf/pull/1208), + [`2233b6b`](https://github.com/python-zeroconf/python-zeroconf/commit/2233b6bc4ceeee5524d2ee88ecae8234173feb5f)) ## v0.72.0 (2023-08-02) ### Features -* feat: speed up processing incoming records (#1206) ([`126849c`](https://github.com/python-zeroconf/python-zeroconf/commit/126849c92be8cec9253fba9faa591029d992fcc3)) +- Speed up processing incoming records + ([#1206](https://github.com/python-zeroconf/python-zeroconf/pull/1206), + [`126849c`](https://github.com/python-zeroconf/python-zeroconf/commit/126849c92be8cec9253fba9faa591029d992fcc3)) ## v0.71.5 (2023-08-02) ### Bug Fixes -* fix: improve performance of ServiceInfo.async_request (#1205) ([`8019a73`](https://github.com/python-zeroconf/python-zeroconf/commit/8019a73c952f2fc4c88d849aab970fafedb316d8)) +- Improve performance of ServiceInfo.async_request + ([#1205](https://github.com/python-zeroconf/python-zeroconf/pull/1205), + [`8019a73`](https://github.com/python-zeroconf/python-zeroconf/commit/8019a73c952f2fc4c88d849aab970fafedb316d8)) ## v0.71.4 (2023-07-24) ### Bug Fixes -* fix: cleanup naming from previous refactoring in ServiceInfo (#1202) ([`b272d75`](https://github.com/python-zeroconf/python-zeroconf/commit/b272d75abd982f3be1f4b20f683cac38011cc6f4)) +- Cleanup naming from previous refactoring in ServiceInfo + ([#1202](https://github.com/python-zeroconf/python-zeroconf/pull/1202), + [`b272d75`](https://github.com/python-zeroconf/python-zeroconf/commit/b272d75abd982f3be1f4b20f683cac38011cc6f4)) ## v0.71.3 (2023-07-23) ### Bug Fixes -* fix: pin python-semantic-release to fix release process (#1200) ([`c145a23`](https://github.com/python-zeroconf/python-zeroconf/commit/c145a238d768aa17c3aebe120c20a46bfbec6b99)) +- Pin python-semantic-release to fix release process + ([#1200](https://github.com/python-zeroconf/python-zeroconf/pull/1200), + [`c145a23`](https://github.com/python-zeroconf/python-zeroconf/commit/c145a238d768aa17c3aebe120c20a46bfbec6b99)) ## v0.71.2 (2023-07-23) ### Bug Fixes -* fix: no change re-release to fix wheel builds (#1199) ([`8c3a4c8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c3a4c80c221bea7401c12e1c6a525e75b7ffea2)) +- No change re-release to fix wheel builds + ([#1199](https://github.com/python-zeroconf/python-zeroconf/pull/1199), + [`8c3a4c8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c3a4c80c221bea7401c12e1c6a525e75b7ffea2)) ## v0.71.1 (2023-07-23) ### Bug Fixes -* fix: add missing if TYPE_CHECKING guard to generate_service_query (#1198) ([`ac53adf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac53adf7e71db14c1a0f9adbfd1d74033df36898)) +- Add missing if TYPE_CHECKING guard to generate_service_query + ([#1198](https://github.com/python-zeroconf/python-zeroconf/pull/1198), + [`ac53adf`](https://github.com/python-zeroconf/python-zeroconf/commit/ac53adf7e71db14c1a0f9adbfd1d74033df36898)) ## v0.71.0 (2023-07-08) ### Features -* feat: improve incoming data processing performance (#1194) ([`a56c776`](https://github.com/python-zeroconf/python-zeroconf/commit/a56c776008ef86f99db78f5997e45a57551be725)) +- Improve incoming data processing performance + ([#1194](https://github.com/python-zeroconf/python-zeroconf/pull/1194), + [`a56c776`](https://github.com/python-zeroconf/python-zeroconf/commit/a56c776008ef86f99db78f5997e45a57551be725)) ## v0.70.0 (2023-07-02) ### Features -* feat: add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and `ServiceInfo.request` (#1192) ([`405f547`](https://github.com/python-zeroconf/python-zeroconf/commit/405f54762d3f61e97de9c1787e837e953de31412)) +- Add support for sending to a specific `addr` and `port` with `ServiceInfo.async_request` and + `ServiceInfo.request` ([#1192](https://github.com/python-zeroconf/python-zeroconf/pull/1192), + [`405f547`](https://github.com/python-zeroconf/python-zeroconf/commit/405f54762d3f61e97de9c1787e837e953de31412)) ## v0.69.0 (2023-06-18) ### Features -* feat: cython3 support (#1190) ([`8ae8ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d)) +- Cython3 support ([#1190](https://github.com/python-zeroconf/python-zeroconf/pull/1190), + [`8ae8ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/8ae8ba1af324b0c8c2da3bd12c264a5c0f3dcc3d)) -* feat: reorder incoming data handler to reduce overhead (#1189) ([`32756ff`](https://github.com/python-zeroconf/python-zeroconf/commit/32756ff113f675b7a9cf16d3c0ab840ba733e5e4)) +- Reorder incoming data handler to reduce overhead + ([#1189](https://github.com/python-zeroconf/python-zeroconf/pull/1189), + [`32756ff`](https://github.com/python-zeroconf/python-zeroconf/commit/32756ff113f675b7a9cf16d3c0ab840ba733e5e4)) ## v0.68.1 (2023-06-18) ### Bug Fixes -* fix: reduce debug logging overhead by adding missing checks to datagram_received (#1188) ([`ac5c50a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3)) +- Reduce debug logging overhead by adding missing checks to datagram_received + ([#1188](https://github.com/python-zeroconf/python-zeroconf/pull/1188), + [`ac5c50a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5c50afc70aaa33fcd20bf02222ff4f0c596fa3)) ## v0.68.0 (2023-06-17) ### Features -* feat: reduce overhead to handle queries and responses (#1184) +- Reduce overhead to handle queries and responses + ([#1184](https://github.com/python-zeroconf/python-zeroconf/pull/1184), + [`81126b7`](https://github.com/python-zeroconf/python-zeroconf/commit/81126b7600f94848ef8c58b70bac0c6ab993c6ae)) - adds slots to handler classes -- avoid any expression overhead and inline instead ([`81126b7`](https://github.com/python-zeroconf/python-zeroconf/commit/81126b7600f94848ef8c58b70bac0c6ab993c6ae)) +- avoid any expression overhead and inline instead ## v0.67.0 (2023-06-17) ### Features -* feat: speed up answering incoming questions (#1186) ([`8f37665`](https://github.com/python-zeroconf/python-zeroconf/commit/8f376658d2a3bef0353646e6fddfda15626b73a9)) +- Speed up answering incoming questions + ([#1186](https://github.com/python-zeroconf/python-zeroconf/pull/1186), + [`8f37665`](https://github.com/python-zeroconf/python-zeroconf/commit/8f376658d2a3bef0353646e6fddfda15626b73a9)) ## v0.66.0 (2023-06-13) ### Features -* feat: optimize construction of outgoing dns records (#1182) ([`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) +- Optimize construction of outgoing dns records + ([#1182](https://github.com/python-zeroconf/python-zeroconf/pull/1182), + [`fc0341f`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0341f281cdb71428c0f1cf90c12d34cbb4acae)) ## v0.65.0 (2023-06-13) ### Features -* feat: reduce overhead to enumerate ip addresses in ServiceInfo (#1181) ([`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) +- Reduce overhead to enumerate ip addresses in ServiceInfo + ([#1181](https://github.com/python-zeroconf/python-zeroconf/pull/1181), + [`6a85cbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6a85cbf2b872cb0abd184c2dd728d9ae3eb8115c)) ## v0.64.1 (2023-06-05) ### Bug Fixes -* fix: small internal typing cleanups (#1180) ([`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) +- Small internal typing cleanups + ([#1180](https://github.com/python-zeroconf/python-zeroconf/pull/1180), + [`f03e511`](https://github.com/python-zeroconf/python-zeroconf/commit/f03e511f7aae72c5ccd4f7514d89e168847bd7a2)) ## v0.64.0 (2023-06-05) ### Bug Fixes -* fix: always answer QU questions when the exact same packet is received from different sources in sequence (#1178) +- Always answer QU questions when the exact same packet is received from different sources in + sequence ([#1178](https://github.com/python-zeroconf/python-zeroconf/pull/1178), + [`74d7ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/74d7ba1aeeae56be087ee8142ee6ca1219744baa)) -If the exact same packet with a QU question is asked from two different sources in a 1s window we end up ignoring the second one as a duplicate. We should still respond in this case because the client wants a unicast response and the question may not be answered by the previous packet since the response may not be multicast. +If the exact same packet with a QU question is asked from two different sources in a 1s window we + end up ignoring the second one as a duplicate. We should still respond in this case because the + client wants a unicast response and the question may not be answered by the previous packet since + the response may not be multicast. fix: include NSEC records in initial broadcast when registering a new service -This also revealed that we do not send NSEC records in the initial broadcast. This needed to be fixed in this PR as well for everything to work as expected since all the tests would fail with 2 updates otherwise. ([`74d7ba1`](https://github.com/python-zeroconf/python-zeroconf/commit/74d7ba1aeeae56be087ee8142ee6ca1219744baa)) +This also revealed that we do not send NSEC records in the initial broadcast. This needed to be + fixed in this PR as well for everything to work as expected since all the tests would fail with 2 + updates otherwise. ### Features -* feat: speed up processing incoming records (#1179) ([`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) +- Speed up processing incoming records + ([#1179](https://github.com/python-zeroconf/python-zeroconf/pull/1179), + [`d919316`](https://github.com/python-zeroconf/python-zeroconf/commit/d9193160b05beeca3755e19fd377ba13fe37b071)) ## v0.63.0 (2023-05-25) ### Features -* feat: small speed up to fetch dns addresses from ServiceInfo (#1176) ([`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) +- Improve dns cache performance + ([#1172](https://github.com/python-zeroconf/python-zeroconf/pull/1172), + [`bb496a1`](https://github.com/python-zeroconf/python-zeroconf/commit/bb496a1dd5fa3562c0412cb064d14639a542592e)) -* feat: speed up the service registry (#1174) ([`360ceb2`](https://github.com/python-zeroconf/python-zeroconf/commit/360ceb2548c4c4974ff798aac43a6fff9803ea0e)) +- Small speed up to fetch dns addresses from ServiceInfo + ([#1176](https://github.com/python-zeroconf/python-zeroconf/pull/1176), + [`4deaa6e`](https://github.com/python-zeroconf/python-zeroconf/commit/4deaa6ed7c9161db55bf16ec068ab7260bbd4976)) -* feat: improve dns cache performance (#1172) ([`bb496a1`](https://github.com/python-zeroconf/python-zeroconf/commit/bb496a1dd5fa3562c0412cb064d14639a542592e)) +- Speed up the service registry + ([#1174](https://github.com/python-zeroconf/python-zeroconf/pull/1174), + [`360ceb2`](https://github.com/python-zeroconf/python-zeroconf/commit/360ceb2548c4c4974ff798aac43a6fff9803ea0e)) ## v0.62.0 (2023-05-04) ### Features -* feat: improve performance of ServiceBrowser outgoing query scheduler (#1170) ([`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) +- Improve performance of ServiceBrowser outgoing query scheduler + ([#1170](https://github.com/python-zeroconf/python-zeroconf/pull/1170), + [`963d022`](https://github.com/python-zeroconf/python-zeroconf/commit/963d022ef82b615540fa7521d164a98a6c6f5209)) ## v0.61.0 (2023-05-03) ### Features -* feat: speed up parsing NSEC records (#1169) ([`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) +- Speed up parsing NSEC records + ([#1169](https://github.com/python-zeroconf/python-zeroconf/pull/1169), + [`06fa94d`](https://github.com/python-zeroconf/python-zeroconf/commit/06fa94d87b4f0451cb475a921ce1d8e9562e0f26)) ## v0.60.0 (2023-05-01) ### Features -* feat: speed up processing incoming data (#1167) ([`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) +- Speed up processing incoming data + ([#1167](https://github.com/python-zeroconf/python-zeroconf/pull/1167), + [`fbaaf7b`](https://github.com/python-zeroconf/python-zeroconf/commit/fbaaf7bb6ff985bdabb85feb6cba144f12d4f1d6)) ## v0.59.0 (2023-05-01) ### Features -* feat: speed up decoding dns questions when processing incoming data (#1168) ([`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) +- Speed up decoding dns questions when processing incoming data + ([#1168](https://github.com/python-zeroconf/python-zeroconf/pull/1168), + [`f927190`](https://github.com/python-zeroconf/python-zeroconf/commit/f927190cb24f70fd7c825c6e12151fcc0daf3973)) ## v0.58.2 (2023-04-26) ### Bug Fixes -* fix: re-release to rebuild failed wheels (#1165) ([`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) +- Re-release to rebuild failed wheels + ([#1165](https://github.com/python-zeroconf/python-zeroconf/pull/1165), + [`4986271`](https://github.com/python-zeroconf/python-zeroconf/commit/498627166a4976f1d9d8cd1f3654b0d50272d266)) ## v0.58.1 (2023-04-26) ### Bug Fixes -* fix: reduce cast calls in service browser (#1164) ([`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) +- Reduce cast calls in service browser + ([#1164](https://github.com/python-zeroconf/python-zeroconf/pull/1164), + [`c0d65ae`](https://github.com/python-zeroconf/python-zeroconf/commit/c0d65aeae7037a18ed1149336f5e7bdb8b2dd8cf)) ## v0.58.0 (2023-04-23) ### Features -* feat: speed up incoming parser (#1163) ([`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) +- Speed up incoming parser ([#1163](https://github.com/python-zeroconf/python-zeroconf/pull/1163), + [`4626399`](https://github.com/python-zeroconf/python-zeroconf/commit/46263999c0c7ea5176885f1eadd2c8498834b70e)) ## v0.57.0 (2023-04-23) ### Features -* feat: speed up incoming data parser (#1161) ([`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) +- Speed up incoming data parser + ([#1161](https://github.com/python-zeroconf/python-zeroconf/pull/1161), + [`cb4c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/cb4c3b2b80ca3b88b8de6e87062a45e03e8805a6)) ## v0.56.0 (2023-04-07) ### Features -* feat: reduce denial of service protection overhead (#1157) ([`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) +- Reduce denial of service protection overhead + ([#1157](https://github.com/python-zeroconf/python-zeroconf/pull/1157), + [`2c2f26a`](https://github.com/python-zeroconf/python-zeroconf/commit/2c2f26a87d0aac81a77205b06bc9ba499caa2321)) ## v0.55.0 (2023-04-07) ### Features -* feat: improve performance of processing incoming records (#1155) ([`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) +- Improve performance of processing incoming records + ([#1155](https://github.com/python-zeroconf/python-zeroconf/pull/1155), + [`b65e279`](https://github.com/python-zeroconf/python-zeroconf/commit/b65e2792751c44e0fafe9ad3a55dadc5d8ee9d46)) ## v0.54.0 (2023-04-03) ### Features -* feat: avoid waking async_request when record updates are not relevant (#1153) ([`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) +- Avoid waking async_request when record updates are not relevant + ([#1153](https://github.com/python-zeroconf/python-zeroconf/pull/1153), + [`a3f970c`](https://github.com/python-zeroconf/python-zeroconf/commit/a3f970c7f66067cf2c302c49ed6ad8286f19b679)) ## v0.53.1 (2023-04-03) ### Bug Fixes -* fix: addresses incorrect after server name change (#1154) ([`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) +- Addresses incorrect after server name change + ([#1154](https://github.com/python-zeroconf/python-zeroconf/pull/1154), + [`41ea06a`](https://github.com/python-zeroconf/python-zeroconf/commit/41ea06a0192c0d186e678009285759eb37d880d5)) ## v0.53.0 (2023-04-02) ### Bug Fixes -* fix: make parsed_scoped_addresses return addresses in the same order as all other methods (#1150) ([`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) +- Make parsed_scoped_addresses return addresses in the same order as all other methods + ([#1150](https://github.com/python-zeroconf/python-zeroconf/pull/1150), + [`9b6adcf`](https://github.com/python-zeroconf/python-zeroconf/commit/9b6adcf5c04a469632ee866c32f5898c5cbf810a)) ### Features -* feat: improve ServiceBrowser performance by removing OrderedDict (#1148) ([`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) +- Improve ServiceBrowser performance by removing OrderedDict + ([#1148](https://github.com/python-zeroconf/python-zeroconf/pull/1148), + [`9a16be5`](https://github.com/python-zeroconf/python-zeroconf/commit/9a16be56a9f69a5d0f7cde13dc1337b6d93c1433)) ## v0.52.0 (2023-04-02) ### Features -* feat: small cleanups to cache cleanup interval (#1146) ([`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) +- Add ip_addresses_by_version to ServiceInfo + ([#1145](https://github.com/python-zeroconf/python-zeroconf/pull/1145), + [`524494e`](https://github.com/python-zeroconf/python-zeroconf/commit/524494edd49bd049726b19ae8ac8f6eea69a3943)) -* feat: add ip_addresses_by_version to ServiceInfo (#1145) ([`524494e`](https://github.com/python-zeroconf/python-zeroconf/commit/524494edd49bd049726b19ae8ac8f6eea69a3943)) +- Include tests and docs in sdist archives + ([#1142](https://github.com/python-zeroconf/python-zeroconf/pull/1142), + [`da10a3b`](https://github.com/python-zeroconf/python-zeroconf/commit/da10a3b2827cee0719d3bb9152ae897f061c6e2e)) -* feat: speed up processing records in the ServiceBrowser (#1143) ([`6a327d0`](https://github.com/python-zeroconf/python-zeroconf/commit/6a327d00ffb81de55b7c5b599893c789996680c1)) +feat: Include tests and docs in sdist archives -* feat: speed up matching types in the ServiceBrowser (#1144) ([`68871c3`](https://github.com/python-zeroconf/python-zeroconf/commit/68871c3b5569e41740a66b7d3d7fa5cc41514ea5)) +Include documentation and test files in source distributions, in order to make them more useful for + packagers (Linux distributions, Conda). Testing is an important part of packaging process, and at + least Gentoo users have requested offline documentation for Python packages. Furthermore, the + COPYING file was missing from sdist, even though it was referenced in README. -* feat: include tests and docs in sdist archives (#1142) +- Small cleanups to cache cleanup interval + ([#1146](https://github.com/python-zeroconf/python-zeroconf/pull/1146), + [`b434b60`](https://github.com/python-zeroconf/python-zeroconf/commit/b434b60f14ebe8f114b7b19bb4f54081c8ae0173)) -feat: Include tests and docs in sdist archives +- Speed up matching types in the ServiceBrowser + ([#1144](https://github.com/python-zeroconf/python-zeroconf/pull/1144), + [`68871c3`](https://github.com/python-zeroconf/python-zeroconf/commit/68871c3b5569e41740a66b7d3d7fa5cc41514ea5)) -Include documentation and test files in source distributions, in order -to make them more useful for packagers (Linux distributions, Conda). -Testing is an important part of packaging process, and at least Gentoo -users have requested offline documentation for Python packages. -Furthermore, the COPYING file was missing from sdist, even though it was -referenced in README. ([`da10a3b`](https://github.com/python-zeroconf/python-zeroconf/commit/da10a3b2827cee0719d3bb9152ae897f061c6e2e)) +- Speed up processing records in the ServiceBrowser + ([#1143](https://github.com/python-zeroconf/python-zeroconf/pull/1143), + [`6a327d0`](https://github.com/python-zeroconf/python-zeroconf/commit/6a327d00ffb81de55b7c5b599893c789996680c1)) ## v0.51.0 (2023-04-01) ### Features -* feat: improve performance of constructing ServiceInfo (#1141) ([`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) +- Improve performance of constructing ServiceInfo + ([#1141](https://github.com/python-zeroconf/python-zeroconf/pull/1141), + [`36d5b45`](https://github.com/python-zeroconf/python-zeroconf/commit/36d5b45a4ece1dca902e9c3c79b5a63b8d9ae41f)) ## v0.50.0 (2023-04-01) ### Features -* feat: small speed up to handler dispatch (#1140) ([`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) +- Small speed up to handler dispatch + ([#1140](https://github.com/python-zeroconf/python-zeroconf/pull/1140), + [`5bd1b6e`](https://github.com/python-zeroconf/python-zeroconf/commit/5bd1b6e7b4dd796069461c737ded956305096307)) ## v0.49.0 (2023-04-01) ### Features -* feat: speed up processing incoming records (#1139) ([`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) +- Speed up processing incoming records + ([#1139](https://github.com/python-zeroconf/python-zeroconf/pull/1139), + [`7246a34`](https://github.com/python-zeroconf/python-zeroconf/commit/7246a344b6c0543871b40715c95c9435db4c7f81)) ## v0.48.0 (2023-04-01) ### Features -* feat: reduce overhead to send responses (#1135) ([`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) +- Reduce overhead to send responses + ([#1135](https://github.com/python-zeroconf/python-zeroconf/pull/1135), + [`c4077dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c4077dde6dfde9e2598eb63daa03c36063a3e7b0)) ## v0.47.4 (2023-03-20) ### Bug Fixes -* fix: correct duplicate record entries in windows wheels by updating poetry-core (#1134) ([`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) +- Correct duplicate record entries in windows wheels by updating poetry-core + ([#1134](https://github.com/python-zeroconf/python-zeroconf/pull/1134), + [`a43055d`](https://github.com/python-zeroconf/python-zeroconf/commit/a43055d3fa258cd762c3e9394b01f8bdcb24f97e)) ## v0.47.3 (2023-02-14) ### Bug Fixes -* fix: hold a strong reference to the query sender start task (#1128) ([`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) +- Hold a strong reference to the query sender start task + ([#1128](https://github.com/python-zeroconf/python-zeroconf/pull/1128), + [`808c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/808c3b2194a7f499a469a9893102d328ccee83db)) ## v0.47.2 (2023-02-14) ### Bug Fixes -* fix: missing c extensions with newer poetry (#1129) ([`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) +- Missing c extensions with newer poetry + ([#1129](https://github.com/python-zeroconf/python-zeroconf/pull/1129), + [`44d7fc6`](https://github.com/python-zeroconf/python-zeroconf/commit/44d7fc6483485102f60c91d591d0d697872f8865)) ## v0.47.1 (2022-12-24) ### Bug Fixes -* fix: the equality checks for DNSPointer and DNSService should be case insensitive (#1122) ([`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) +- The equality checks for DNSPointer and DNSService should be case insensitive + ([#1122](https://github.com/python-zeroconf/python-zeroconf/pull/1122), + [`48ae77f`](https://github.com/python-zeroconf/python-zeroconf/commit/48ae77f026a96e2ca475b0ff80cb6d22207ce52f)) ## v0.47.0 (2022-12-22) ### Features -* feat: optimize equality checks for DNS records (#1120) ([`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) +- Optimize equality checks for DNS records + ([#1120](https://github.com/python-zeroconf/python-zeroconf/pull/1120), + [`3a25ff7`](https://github.com/python-zeroconf/python-zeroconf/commit/3a25ff74bea83cd7d50888ce1ebfd7650d704bfa)) ## v0.46.0 (2022-12-21) ### Features -* feat: optimize the dns cache (#1119) ([`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) +- Optimize the dns cache ([#1119](https://github.com/python-zeroconf/python-zeroconf/pull/1119), + [`e80fcef`](https://github.com/python-zeroconf/python-zeroconf/commit/e80fcef967024f8e846e44b464a82a25f5550edf)) ## v0.45.0 (2022-12-20) ### Features -* feat: optimize construction of outgoing packets (#1118) ([`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) +- Optimize construction of outgoing packets + ([#1118](https://github.com/python-zeroconf/python-zeroconf/pull/1118), + [`81e186d`](https://github.com/python-zeroconf/python-zeroconf/commit/81e186d365c018381f9b486a4dbe4e2e4b8bacbf)) ## v0.44.0 (2022-12-18) ### Features -* feat: optimize dns objects by adding pxd files (#1113) ([`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) +- Optimize dns objects by adding pxd files + ([#1113](https://github.com/python-zeroconf/python-zeroconf/pull/1113), + [`919d4d8`](https://github.com/python-zeroconf/python-zeroconf/commit/919d4d875747b4fa68e25bccd5aae7f304d8a36d)) ## v0.43.0 (2022-12-18) ### Features -* feat: optimize incoming parser by reducing call stack (#1116) ([`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) +- Optimize incoming parser by reducing call stack + ([#1116](https://github.com/python-zeroconf/python-zeroconf/pull/1116), + [`11f3f0e`](https://github.com/python-zeroconf/python-zeroconf/commit/11f3f0e699e00c1ee3d6d8ab5e30f62525510589)) ## v0.42.0 (2022-12-18) ### Features -* feat: optimize incoming parser by using unpack_from (#1115) ([`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) +- Optimize incoming parser by using unpack_from + ([#1115](https://github.com/python-zeroconf/python-zeroconf/pull/1115), + [`a7d50ba`](https://github.com/python-zeroconf/python-zeroconf/commit/a7d50baab362eadd2d292df08a39de6836b41ea7)) ## v0.41.0 (2022-12-18) ### Features -* feat: optimize incoming parser by adding pxd files (#1111) ([`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) +- Optimize incoming parser by adding pxd files + ([#1111](https://github.com/python-zeroconf/python-zeroconf/pull/1111), + [`26efeb0`](https://github.com/python-zeroconf/python-zeroconf/commit/26efeb09783050266242542228f34eb4dd83e30c)) ## v0.40.1 (2022-12-18) ### Bug Fixes -* fix: fix project name in pyproject.toml (#1112) ([`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) +- Fix project name in pyproject.toml + ([#1112](https://github.com/python-zeroconf/python-zeroconf/pull/1112), + [`a330f62`](https://github.com/python-zeroconf/python-zeroconf/commit/a330f62040475257c4a983044e1675aeb95e030a)) ## v0.40.0 (2022-12-17) ### Features -* feat: drop async_timeout requirement for python 3.11+ (#1107) ([`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) +- Drop async_timeout requirement for python 3.11+ + ([#1107](https://github.com/python-zeroconf/python-zeroconf/pull/1107), + [`1f4224e`](https://github.com/python-zeroconf/python-zeroconf/commit/1f4224ef122299235013cb81b501f8ff9a30dea1)) ## v0.39.5 (2022-12-17) -### Unknown - -* 0.39.5 ([`2be6fbf`](https://github.com/python-zeroconf/python-zeroconf/commit/2be6fbfe3d10b185096814d2d0de322733d273cf)) - ## v0.39.4 (2022-10-31) -### Unknown - -* Bump version: 0.39.3 → 0.39.4 ([`e620f2a`](https://github.com/python-zeroconf/python-zeroconf/commit/e620f2a1d4f381feb99b639c6ab17845396ba7ea)) - -* Update changelog for 0.39.4 (#1103) ([`03821b6`](https://github.com/python-zeroconf/python-zeroconf/commit/03821b6f4d9fdc40d94d1070f69553649d18909b)) - -* Fix IP changes being missed by ServiceInfo (#1102) ([`524ae89`](https://github.com/python-zeroconf/python-zeroconf/commit/524ae89966d9300e78642a91434ad55643277a48)) - ## v0.39.3 (2022-10-26) -### Unknown - -* Bump version: 0.39.2 → 0.39.3 ([`aee3165`](https://github.com/python-zeroconf/python-zeroconf/commit/aee316539b0778eaf2b8878f78d9ead373760cfb)) - -* Update changelog for 0.39.3 (#1101) ([`39c9842`](https://github.com/python-zeroconf/python-zeroconf/commit/39c9842b80ac7d978e8c7ffef0ad836b3b4700f6)) - -* Fix port changes not being seen by ServiceInfo (#1100) ([`c96f5f6`](https://github.com/python-zeroconf/python-zeroconf/commit/c96f5f69d8e68672bb6760b1e40a0de51b62efd6)) - -* Update CI to use released python 3.11 (#1099) ([`6976980`](https://github.com/python-zeroconf/python-zeroconf/commit/6976980b4874dd65ee533d43be57694bb3b7d0fc)) - ## v0.39.2 (2022-10-20) -### Unknown - -* Bump version: 0.39.1 → 0.39.2 ([`785e475`](https://github.com/python-zeroconf/python-zeroconf/commit/785e475467225ddc4930d5302f130781223fd298)) - -* Update changelog for 0.39.2 (#1098) ([`b197344`](https://github.com/python-zeroconf/python-zeroconf/commit/b19734484b4c5eebb86fe6897a26ad082b07bed5)) - -* Improve cache of decode labels at offset (#1097) ([`d3c475f`](https://github.com/python-zeroconf/python-zeroconf/commit/d3c475f3e2590ae5a3056d85c29a66dc71ae3bdf)) - -* Only reprocess address records if the server changes (#1095) ([`0989336`](https://github.com/python-zeroconf/python-zeroconf/commit/0989336d79bc4dd0ef3b26e8d0f9529fca81c1fb)) - -* Prepare for python 3.11 support by adding rc2 to the CI (#1085) ([`7430ce1`](https://github.com/python-zeroconf/python-zeroconf/commit/7430ce1c462be0dd210712b4f7b3675efd3a6963)) - ## v0.39.1 (2022-09-05) -### Unknown - -* Bump version: 0.39.0 → 0.39.1 ([`6f90896`](https://github.com/python-zeroconf/python-zeroconf/commit/6f90896a590d6d60db75688a1ba753c333c8faab)) - -* Update changelog for 0.39.1 (#1091) ([`cad3963`](https://github.com/python-zeroconf/python-zeroconf/commit/cad3963e566a7bb2dd188088c11e7a0abb6b3924)) - -* Replace pack with to_bytes (#1090) ([`5968b76`](https://github.com/python-zeroconf/python-zeroconf/commit/5968b76ac2ffe6e41b8961c59bdcc5a48ba410eb)) - ## v0.39.0 (2022-08-05) -### Unknown - -* Bump version: 0.38.7 → 0.39.0 ([`60167b0`](https://github.com/python-zeroconf/python-zeroconf/commit/60167b05227ec33668aac5b960a8bc5ba5b833de)) - -* 0.39.0 changelog (#1087) ([`946890a`](https://github.com/python-zeroconf/python-zeroconf/commit/946890aca540bbae95abe8a6ffe66db56fa9e986)) - -* Remove coveralls from dev requirements (#1086) ([`087914d`](https://github.com/python-zeroconf/python-zeroconf/commit/087914da2e914275dd0fff1e4466b3c51ae0c6d3)) - -* Fix run_coro_with_timeout test not running in the CI (#1082) ([`b7a24fe`](https://github.com/python-zeroconf/python-zeroconf/commit/b7a24fef05fc6c166b25cfd4235e59c5cbb96a4c)) - -* Fix flakey service_browser_expire_callbacks test (#1084) ([`d5032b7`](https://github.com/python-zeroconf/python-zeroconf/commit/d5032b70b6ebc5c221a43f778f4d897a1d891f91)) - -* Fix flakey test_sending_unicast on windows (#1083) ([`389658d`](https://github.com/python-zeroconf/python-zeroconf/commit/389658d998a23deecd96023794d3672e51189a35)) - -* Replace wait_event_or_timeout internals with async_timeout (#1081) - -Its unlikely that https://bugs.python.org/issue39032 and -https://github.com/python/cpython/issues/83213 will be fixed -soon. While we moved away from an asyncio.Condition, we still -has a similar problem with waiting for an asyncio.Event which -wait_event_or_timeout played well with. async_timeout avoids -creating a task so its a bit more efficient. Since we call -these when resolving ServiceInfo, avoiding task creation -will resolve a performance problem when ServiceBrowsers -startup as they tend to create task storms when coupled -with ServiceInfo lookups. ([`7ffea9f`](https://github.com/python-zeroconf/python-zeroconf/commit/7ffea9f93e758f75a0eeb9997ff8d9c9d47ec31a)) - -* Update stale docstrings in AsyncZeroconf (#1079) ([`88323d0`](https://github.com/python-zeroconf/python-zeroconf/commit/88323d0c7866f78edde063080c63a72c6e875772)) - ## v0.38.7 (2022-06-14) -### Unknown - -* Bump version: 0.38.6 → 0.38.7 ([`f3a9f80`](https://github.com/python-zeroconf/python-zeroconf/commit/f3a9f804914fec37e961f80f347c4e706c4bae33)) - -* Update changelog for 0.38.7 (#1078) ([`5f7ba0d`](https://github.com/python-zeroconf/python-zeroconf/commit/5f7ba0d7dc9a5a6b2cf3a321b7b2f448d4332de9)) - -* Speed up unpacking incoming packet data (#1076) ([`533ad10`](https://github.com/python-zeroconf/python-zeroconf/commit/533ad10121739997a4925d90792cbe9e00a5ac4f)) - ## v0.38.6 (2022-05-06) -### Unknown - -* Bump version: 0.38.5 → 0.38.6 ([`1aa7842`](https://github.com/python-zeroconf/python-zeroconf/commit/1aa7842ae0f914c10465ae977551698046406d55)) - -* Update changelog for 0.38.6 (#1073) ([`dfd3222`](https://github.com/python-zeroconf/python-zeroconf/commit/dfd3222405f0123a849d376d8be466be46bdb557)) - -* Always return `started` as False once Zeroconf has been marked as done (#1072) ([`ed02e5d`](https://github.com/python-zeroconf/python-zeroconf/commit/ed02e5d92768d1fc41163f59e303a76843bfd9fd)) - -* Avoid waking up ServiceInfo listeners when there is no new data (#1068) ([`59624a6`](https://github.com/python-zeroconf/python-zeroconf/commit/59624a6cfb1839b2654a6021a7317a1bdad179e9)) - -* Remove left-in debug print (#1071) ([`5fb0954`](https://github.com/python-zeroconf/python-zeroconf/commit/5fb0954cf2c6040704c3db1d2b0fece389425e5b)) - -* Use unique name in test_service_browser_expire_callbacks test (#1069) ([`89c9022`](https://github.com/python-zeroconf/python-zeroconf/commit/89c9022f87d3a83cc586b153fb7d5ea3af69ae3b)) - -* Fix CI failures (#1070) ([`f9b2816`](https://github.com/python-zeroconf/python-zeroconf/commit/f9b2816e15b0459f8051079f77b70e983769cd44)) - ## v0.38.5 (2022-05-01) -### Unknown - -* Bump version: 0.38.4 → 0.38.5 ([`3c55388`](https://github.com/python-zeroconf/python-zeroconf/commit/3c5538899b8974e99c9a279ce3ac46971ab5d91c)) - -* Update changelog for 0.38.5 (#1066) ([`ae3635b`](https://github.com/python-zeroconf/python-zeroconf/commit/ae3635b9ee73edeaabe2cbc027b8fb8bd7cd97da)) - -* Fix ServiceBrowsers not getting `ServiceStateChange.Removed` callbacks on PTR record expire (#1064) ([`10ee205`](https://github.com/python-zeroconf/python-zeroconf/commit/10ee2053a80f7c7221b4fa1475d66b01abd21b11)) - -* Fix ci trying to run mypy on pypy (#1065) ([`31662b7`](https://github.com/python-zeroconf/python-zeroconf/commit/31662b7a0bba65bea1fbfc09c70cd2970160c5c6)) - -* Force minimum version of 3.7 and update example (#1060) - -Co-authored-by: J. Nick Koston ([`6e842f2`](https://github.com/python-zeroconf/python-zeroconf/commit/6e842f238b3e1f3b738ed058e0fa4068115f041b)) - -* Fix mypy error in zeroconf._service.info (#1062) ([`e9d25f7`](https://github.com/python-zeroconf/python-zeroconf/commit/e9d25f7749778979b7449464153163587583bf8d)) - -* Refactor to fix mypy error (#1061) ([`6c451f6`](https://github.com/python-zeroconf/python-zeroconf/commit/6c451f64e7cbeaa0bb77f66790936afda2d058ef)) - ## v0.38.4 (2022-02-28) -### Unknown - -* Bump version: 0.38.3 → 0.38.4 ([`5c40e89`](https://github.com/python-zeroconf/python-zeroconf/commit/5c40e89420255b5b978bff4682b21f0820fb4682)) - -* Update changelog for 0.38.4 (#1058) ([`3736348`](https://github.com/python-zeroconf/python-zeroconf/commit/3736348da30ee4b7c50713936f2ae919e5446ffa)) - -* Fix IP Address updates when hostname is uppercase (#1057) ([`79d067b`](https://github.com/python-zeroconf/python-zeroconf/commit/79d067b88f9108259a44f33801e26bd3a25ca759)) - ## v0.38.3 (2022-01-31) -### Unknown - -* Bump version: 0.38.2 → 0.38.3 ([`e42549c`](https://github.com/python-zeroconf/python-zeroconf/commit/e42549cb70796d0577c97be96a09bca0056a5755)) - -* Update changelog for 0.38.2/3 (#1053) ([`d99c7ff`](https://github.com/python-zeroconf/python-zeroconf/commit/d99c7ffea37fd27c315115133dab08445aa417d1)) - ## v0.38.2 (2022-01-31) -### Unknown - -* Bump version: 0.38.1 → 0.38.2 ([`50cd12d`](https://github.com/python-zeroconf/python-zeroconf/commit/50cd12d8c2ced166da8f4852120ba8a28b13cba0)) - -* Make decode errors more helpful in finding the source of the bad data (#1052) ([`25e6123`](https://github.com/python-zeroconf/python-zeroconf/commit/25e6123a07a9560e978a04d5e285bfa74ee41e64)) - ## v0.38.1 (2021-12-23) -### Unknown - -* Bump version: 0.38.0 → 0.38.1 ([`6a11f24`](https://github.com/python-zeroconf/python-zeroconf/commit/6a11f24e1fc9d73f0dbb62efd834f17a9bd451c4)) - -* Update changelog for 0.38.1 (#1045) ([`670d4ac`](https://github.com/python-zeroconf/python-zeroconf/commit/670d4ac3be7e32d02afe85b72264a241b5a25ba8)) - -* Avoid linear type searches in ServiceBrowsers (#1044) ([`ff76634`](https://github.com/python-zeroconf/python-zeroconf/commit/ff766345461a82547abe462b5d690621c755d480)) - -* Improve performance of query scheduler (#1043) ([`27e50ff`](https://github.com/python-zeroconf/python-zeroconf/commit/27e50ff95625d128f71864138b8e5d871503adf0)) - ## v0.38.0 (2021-12-23) -### Unknown - -* Bump version: 0.37.0 → 0.38.0 ([`95ee5dc`](https://github.com/python-zeroconf/python-zeroconf/commit/95ee5dc031c9c512f99536186d1d89a99e4af37f)) - -* Update changelog for 0.38.0 (#1042) ([`de14202`](https://github.com/python-zeroconf/python-zeroconf/commit/de1420213cd7e3bd8f57e727ff1031c7b10cf7a0)) - -* Handle Service types that end with another service type (#1041) - -Co-authored-by: J. Nick Koston ([`a4d619a`](https://github.com/python-zeroconf/python-zeroconf/commit/a4d619a9f094682d9dcfc7f8fa293f17bcae88f2)) - -* Add tests for instance names containing dot(s) (#1039) - -Co-authored-by: J. Nick Koston ([`22ed08c`](https://github.com/python-zeroconf/python-zeroconf/commit/22ed08c7e5403a788b1c177a1bb9558419bce2b1)) - -* Drop python 3.6 support (#1009) ([`631a6f7`](https://github.com/python-zeroconf/python-zeroconf/commit/631a6f7c7863897336a9d6ca4bd1736cc7cc97af)) - ## v0.37.0 (2021-11-18) -### Unknown - -* Bump version: 0.36.13 → 0.37.0 ([`2996e64`](https://github.com/python-zeroconf/python-zeroconf/commit/2996e642f6b1abba1dbb8242ccca4cd4b96696f6)) - -* Update changelog for 0.37.0 (#1035) ([`61a7e3f`](https://github.com/python-zeroconf/python-zeroconf/commit/61a7e3fb65d99db7d51f1df42b286b55710a2e99)) - -* Log an error when listeners are added that do not inherit from RecordUpdateListener (#1034) ([`ee071a1`](https://github.com/python-zeroconf/python-zeroconf/commit/ee071a12f31f7010110eef5ccef80c6cdf469d87)) - -* Throw NotRunningException when Zeroconf is not running (#1033) - -- Before this change the consumer would get a timeout or an EventLoopBlocked - exception when calling `ServiceInfo.*request` when the instance had already been shutdown. - This was quite a confusing result. ([`28938d2`](https://github.com/python-zeroconf/python-zeroconf/commit/28938d20bb62ae0d9aa2f94929f60434fb346704)) - -* Throw EventLoopBlocked instead of concurrent.futures.TimeoutError (#1032) ([`21bd107`](https://github.com/python-zeroconf/python-zeroconf/commit/21bd10762a89ca3f4ca89f598c9d93684a02f51b)) - ## v0.36.13 (2021-11-13) -### Unknown - -* Bump version: 0.36.12 → 0.36.13 ([`4241c76`](https://github.com/python-zeroconf/python-zeroconf/commit/4241c76550130469aecbe88cc1a7cdc13505f8ba)) - -* Update changelog for 0.36.13 (#1030) ([`106cf27`](https://github.com/python-zeroconf/python-zeroconf/commit/106cf27478bb0c1e6e5a7194661ff52947d61c96)) - -* Downgrade incoming corrupt packet logging to debug (#1029) - -- Warning about network traffic we have no control over - is confusing to users as they think there is - something wrong with zeroconf ([`73c52d0`](https://github.com/python-zeroconf/python-zeroconf/commit/73c52d04a140bc744669777a0f353eefc6623ff9)) - -* Skip unavailable interfaces during socket bind (#1028) - -- We already skip these when adding multicast members. - Apply the same logic to the socket bind call ([`aa59998`](https://github.com/python-zeroconf/python-zeroconf/commit/aa59998182ce29c55f8c3dde9a058ce36ac2bb2d)) - ## v0.36.12 (2021-11-05) -### Unknown - -* Bump version: 0.36.11 → 0.36.12 ([`8b0dc48`](https://github.com/python-zeroconf/python-zeroconf/commit/8b0dc48ed42d8edc78750122eb5685a50c3cdc11)) - -* Update changelog for 0.36.12 (#1027) ([`51bf364`](https://github.com/python-zeroconf/python-zeroconf/commit/51bf364b364ecaad16503df4a4c4c3bb5ead2775)) - -* Account for intricacies of floating-point arithmetic in service browser tests (#1026) ([`3c70808`](https://github.com/python-zeroconf/python-zeroconf/commit/3c708080b3e42a02930ad17c96a2cf0dcb06f441)) - -* Prevent service lookups from deadlocking if time abruptly moves backwards (#1006) - -- The typical reason time moves backwards is via an ntp update ([`38380a5`](https://github.com/python-zeroconf/python-zeroconf/commit/38380a58a64f563f105cecc610f194c20056b2b6)) - ## v0.36.11 (2021-10-30) -### Unknown - -* Bump version: 0.36.10 → 0.36.11 ([`3d8f50d`](https://github.com/python-zeroconf/python-zeroconf/commit/3d8f50de74f7b3941d9b35b6ae6e42ba02be9361)) - -* Update changelog for 0.36.11 (#1024) ([`69a9b8e`](https://github.com/python-zeroconf/python-zeroconf/commit/69a9b8e060ae8a596050d393c0a5c8b43beadc8e)) - -* Add readme check to the CI (#1023) ([`c966976`](https://github.com/python-zeroconf/python-zeroconf/commit/c966976531ac9222460763d647d0a3b75459e275)) - ## v0.36.10 (2021-10-30) -### Unknown - -* Bump version: 0.36.9 → 0.36.10 ([`e0b340a`](https://github.com/python-zeroconf/python-zeroconf/commit/e0b340afbfd25ae9d05a59a577938b062287c8b6)) - -* Update changelog for 0.36.10 (#1021) ([`69ce817`](https://github.com/python-zeroconf/python-zeroconf/commit/69ce817a68d65f2db0bfe6d4790d3a6a356ac83f)) - -* Fix test failure when has_working_ipv6 generates an exception (#1022) ([`cd8984d`](https://github.com/python-zeroconf/python-zeroconf/commit/cd8984d3e95bffe6fd32b97eae9844bf5afed4de)) - -* Strip scope_id from IPv6 address if given. (#1020) ([`686febd`](https://github.com/python-zeroconf/python-zeroconf/commit/686febdd181c837fa6a41afce91edeeded731fbe)) - -* Optimize decoding labels from incoming packets (#1019) - -- decode is a bit faster vs str() - -``` ->>> ts = Timer("s.decode('utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") ->>> ts.timeit() -0.09910525000003645 ->>> ts = Timer("str(s, 'utf-8', 'replace')", "s = b'TV Beneden (2)\x10_androidtvremote\x04_tcp\x05local'") ->>> ts.timeit() -0.1304596250000145 -``` ([`4b9a6c3`](https://github.com/python-zeroconf/python-zeroconf/commit/4b9a6c3fd4aec920597e7e63e82e935df68804f4)) - -* Fix typo in changelog (#1017) ([`0fdcd51`](https://github.com/python-zeroconf/python-zeroconf/commit/0fdcd5146264b37daa7cc35bda883519175e362f)) - ## v0.36.9 (2021-10-22) -### Unknown - -* Bump version: 0.36.8 → 0.36.9 ([`d92d3d0`](https://github.com/python-zeroconf/python-zeroconf/commit/d92d3d030558c1b81b2e35f701b585f4b48fa99a)) - -* Update changelog for 0.36.9 (#1016) ([`1427ba7`](https://github.com/python-zeroconf/python-zeroconf/commit/1427ba75a8f7e2962aa0b3105d3c856002134790)) - -* Ensure ServiceInfo orders newest addresess first (#1012) ([`87a4d8f`](https://github.com/python-zeroconf/python-zeroconf/commit/87a4d8f4d5c8365425c2ee969032205f916f80c1)) - ## v0.36.8 (2021-10-10) -### Unknown - -* Bump version: 0.36.7 → 0.36.8 ([`61275ef`](https://github.com/python-zeroconf/python-zeroconf/commit/61275efd05688a61d656b43125b01a5d588f1dba)) - -* Update changelog for 0.36.8 (#1010) ([`1551618`](https://github.com/python-zeroconf/python-zeroconf/commit/15516188f346c70f64a923bb587804b9bf948873)) - -* Fix ServiceBrowser infinite looping when zeroconf is closed before its canceled (#1008) ([`b0e8c8a`](https://github.com/python-zeroconf/python-zeroconf/commit/b0e8c8a21fd721e60adbac4dbf7a03959fc3f641)) - -* Update CI to use python 3.10, pypy 3.7 (#1007) ([`fec9f3d`](https://github.com/python-zeroconf/python-zeroconf/commit/fec9f3dc9626be08eccdf1263dbf4d1686fd27b2)) - -* Cleanup typing in zeroconf._protocol.outgoing (#1000) ([`543558d`](https://github.com/python-zeroconf/python-zeroconf/commit/543558d0498ed03eb9dc4597c4c40484e16ee4e6)) - -* Breakout functions with no self-use in zeroconf._handlers (#1003) ([`af4d082`](https://github.com/python-zeroconf/python-zeroconf/commit/af4d082240a545ba3014eb7f1056c3b32ce2cb70)) - -* Use more f-strings in zeroconf._dns (#1002) ([`d3ed691`](https://github.com/python-zeroconf/python-zeroconf/commit/d3ed69107330f1a29f45d174caafdec1e894f666)) - -* Remove unused code in zeroconf._core (#1001) - -- Breakout functions without self-use ([`8e45ea9`](https://github.com/python-zeroconf/python-zeroconf/commit/8e45ea943be6490b2217f0eb01501e12a5221c16)) - ## v0.36.7 (2021-09-22) -### Unknown - -* Bump version: 0.36.6 → 0.36.7 ([`f44b40e`](https://github.com/python-zeroconf/python-zeroconf/commit/f44b40e26ea8872151ea9ee4762b95ca25790089)) - -* Update changelog for 0.36.7 (#999) ([`d2853c3`](https://github.com/python-zeroconf/python-zeroconf/commit/d2853c31db9ece28fb258c4146ba61cf0e6a6592)) - -* Improve log message when receiving an invalid or corrupt packet (#998) ([`b637846`](https://github.com/python-zeroconf/python-zeroconf/commit/b637846e7df3292d6dcdd38a8eb77b6fa3287c51)) - -* Reduce logging overhead (#994) ([`7df7e4a`](https://github.com/python-zeroconf/python-zeroconf/commit/7df7e4a68e33c3e3a5bddf0168e248a4542a788f)) - -* Reduce overhead to compare dns records (#997) ([`7fa51de`](https://github.com/python-zeroconf/python-zeroconf/commit/7fa51de5b71d03470643a83004b9f6f8d4017214)) - -* Refactor service registry to avoid use of getattr (#996) ([`7622365`](https://github.com/python-zeroconf/python-zeroconf/commit/762236547d4838f2b6a94cfa20221dfdd03e9b94)) - -* Flush CI cache (#995) ([`93ddf7c`](https://github.com/python-zeroconf/python-zeroconf/commit/93ddf7cf9b47d7ff1e341b6c2875254b6f00eef1)) - ## v0.36.6 (2021-09-19) -### Unknown - -* Bump version: 0.36.5 → 0.36.6 ([`0327a06`](https://github.com/python-zeroconf/python-zeroconf/commit/0327a068250c85f3ff84d3f0b809b51f83321c47)) - -* Fix tense of 0.36.6 changelog (#992) ([`29f995f`](https://github.com/python-zeroconf/python-zeroconf/commit/29f995fd3c09604f37980e74f2785b1a451da089)) - -* Update changelog for 0.36.6 (#991) ([`92f5f4a`](https://github.com/python-zeroconf/python-zeroconf/commit/92f5f4a80b8a8e50df5ca06e3cc45480dc39b504)) - -* Simplify the can_send_to check (#990) ([`1887c55`](https://github.com/python-zeroconf/python-zeroconf/commit/1887c554b3f9d0b90a1c01798d7f06a7e4de6900)) - ## v0.36.5 (2021-09-18) -### Unknown - -* Bump version: 0.36.4 → 0.36.5 ([`34f4a26`](https://github.com/python-zeroconf/python-zeroconf/commit/34f4a26c9254d6002bdccb1a003d9822a8798c04)) - -* Update changelog for 0.36.5 (#989) ([`aebabe9`](https://github.com/python-zeroconf/python-zeroconf/commit/aebabe95c59e34f703307340e087b3eab5339a06)) - -* Seperate zeroconf._protocol into an incoming and outgoing modules (#988) ([`87b6a32`](https://github.com/python-zeroconf/python-zeroconf/commit/87b6a32fb77d9bdcea9d2d7ffba189abc5371b50)) - -* Reduce dns protocol attributes and add slots (#987) ([`f4665fc`](https://github.com/python-zeroconf/python-zeroconf/commit/f4665fc67cd762c4ab66271a550d75640d3bffca)) - -* Fix typo in changelog (#986) ([`4398538`](https://github.com/python-zeroconf/python-zeroconf/commit/43985380b9e995d9790d71486aed258326ad86e4)) - ## v0.36.4 (2021-09-16) -### Unknown - -* Bump version: 0.36.3 → 0.36.4 ([`a23f6d2`](https://github.com/python-zeroconf/python-zeroconf/commit/a23f6d2cc40ea696410c3c31b73760065c36f0bf)) - -* Update changelog for 0.36.4 (#985) ([`f4d4164`](https://github.com/python-zeroconf/python-zeroconf/commit/f4d4164989931adbac0e5907b7bf276da1d0d7d7)) - -* Defer decoding known answers until needed (#983) ([`88b9875`](https://github.com/python-zeroconf/python-zeroconf/commit/88b987551cb98757c2df2540ba390f320d46fa7b)) - -* Collapse _GLOBAL_DONE into done (#984) ([`05c4329`](https://github.com/python-zeroconf/python-zeroconf/commit/05c4329d7647c381783ead086c2ed4f3b6b44262)) - -* Remove flake8 requirement restriction as its no longer needed (#981) ([`bc64d63`](https://github.com/python-zeroconf/python-zeroconf/commit/bc64d63ef73e643e71634957fd79e6f6597373d4)) - -* Reduce duplicate code to write records (#979) ([`acf6457`](https://github.com/python-zeroconf/python-zeroconf/commit/acf6457b3c6742c92e9112b0a39a387b33cea4db)) - -* Force CI cache clear (#982) ([`d9ea918`](https://github.com/python-zeroconf/python-zeroconf/commit/d9ea9189def07531d126e01c7397b2596d9a8695)) - -* Reduce name compression overhead and complexity (#978) ([`f1d6fc3`](https://github.com/python-zeroconf/python-zeroconf/commit/f1d6fc3f60e685ff63b1a1cb820cfc3ca5268fcb)) - ## v0.36.3 (2021-09-14) -### Unknown - -* Bump version: 0.36.2 → 0.36.3 ([`769b397`](https://github.com/python-zeroconf/python-zeroconf/commit/769b3973835ebc6f5a34e236a01cb2cd935e81de)) - -* Update changelog for 0.36.3 (#977) ([`84f16bf`](https://github.com/python-zeroconf/python-zeroconf/commit/84f16bff6df41f1907e060e7bd4ce24d173d51c4)) - -* Reduce DNSIncoming parsing overhead (#975) - -- Parsing incoming packets is the most expensive operation - zeroconf performs on networks with high mDNS volume ([`78f9cd5`](https://github.com/python-zeroconf/python-zeroconf/commit/78f9cd5123d0e3c582aba05bd61388419d4dc01e)) - ## v0.36.2 (2021-08-30) -### Unknown - -* Bump version: 0.36.1 → 0.36.2 ([`5f52438`](https://github.com/python-zeroconf/python-zeroconf/commit/5f52438f4c0851bb1a3b78575c0c28e0b6ce561d)) - -* Update changelog for 0.36.2 (#973) ([`b4efa33`](https://github.com/python-zeroconf/python-zeroconf/commit/b4efa33b4ef6d5292d8d477da4258d99d22c4e84)) - -* Include NSEC records for non-existant types when responding with addresses (#972) - -Implements datatracker.ietf.org/doc/html/rfc6762#section-6.2 ([`7a20fd3`](https://github.com/python-zeroconf/python-zeroconf/commit/7a20fd3bc8dc0a703619ca9413faf674b3d7a111)) - -* Add support for writing NSEC records (#971) ([`768a23c`](https://github.com/python-zeroconf/python-zeroconf/commit/768a23c656e3f091ecbecbb6b380b5becbbf9674)) - ## v0.36.1 (2021-08-29) -### Unknown - -* Bump version: 0.36.0 → 0.36.1 ([`e8d8401`](https://github.com/python-zeroconf/python-zeroconf/commit/e8d84017b750ab5f159abc7225f9922d84a8f9fd)) - -* Update changelog for 0.36.1 (#970) ([`d504333`](https://github.com/python-zeroconf/python-zeroconf/commit/d5043337de39a11b2b241e9247a34c41c0c7c2bc)) - -* Skip goodbye packets for addresses when there is another service registered with the same name (#968) ([`d9d3208`](https://github.com/python-zeroconf/python-zeroconf/commit/d9d3208eed84b71b61c458f2992b08b5db259da1)) - -* Fix equality and hash for dns records with the unique bit (#969) ([`574e241`](https://github.com/python-zeroconf/python-zeroconf/commit/574e24125a536dc4fb9a1784797efd495ceb1fdf)) - ## v0.36.0 (2021-08-16) -### Unknown - -* Bump version: 0.35.1 → 0.36.0 ([`e4985c7`](https://github.com/python-zeroconf/python-zeroconf/commit/e4985c7dd2088d4da9fc2be25f67beb65f548e95)) - -* Update changelog for 0.36.0 (#966) ([`bc50bce`](https://github.com/python-zeroconf/python-zeroconf/commit/bc50bce04b650756fef3f8b1cce6defbc5dccee5)) - -* Create full IPv6 address tuple to enable service discovery on Windows (#965) ([`733eb3a`](https://github.com/python-zeroconf/python-zeroconf/commit/733eb3a31ed40c976f5fa4b7b3baf055589ef36b)) - ## v0.35.1 (2021-08-15) -### Unknown - -* Bump version: 0.35.0 → 0.35.1 ([`4281221`](https://github.com/python-zeroconf/python-zeroconf/commit/4281221b668123b770c6d6b0835dd876d1d2f22d)) - -* Fix formatting in 0.35.1 changelog entry (#964) ([`c7c7d47`](https://github.com/python-zeroconf/python-zeroconf/commit/c7c7d4778e9962af5180616af73977d8503e4762)) - -* Update changelog for 0.35.1 (#963) ([`f7bebfe`](https://github.com/python-zeroconf/python-zeroconf/commit/f7bebfe09aeb9bb973dbe6ba147b682472b64246)) - -* Cache DNS record and question hashes (#960) ([`d4c109c`](https://github.com/python-zeroconf/python-zeroconf/commit/d4c109c3abffcba2331a7f9e7bf45c6477a8d4e8)) - -* Fix flakey test: test_future_answers_are_removed_on_send (#962) ([`3b482e2`](https://github.com/python-zeroconf/python-zeroconf/commit/3b482e229d37b85e59765e023ddbca77aa513731)) - -* Add coverage for sending answers removes future queued answers (#961) - -- If we send an answer that is queued to be sent out in the future - we should remove it from the queue as the question has already - been answered and we do not want to generate additional traffic. ([`2d1b832`](https://github.com/python-zeroconf/python-zeroconf/commit/2d1b8329ad39b94f9f4aa5f53caf3bb2813879ca)) - -* Only reschedule types if the send next time changes (#958) -- When the PTR response was seen again, the timer was being canceled and - rescheduled even if the timer was for the same time. While this did - not cause any breakage, it is quite inefficient. ([`7b125a1`](https://github.com/python-zeroconf/python-zeroconf/commit/7b125a1a0a109ef29d0a4e736a27645a7e9b4207)) +## v0.35.0 (2021-08-13) -## v0.35.0 (2021-08-13) +## v0.34.3 (2021-08-09) -### Unknown -* Bump version: 0.34.3 → 0.35.0 ([`1e60e13`](https://github.com/python-zeroconf/python-zeroconf/commit/1e60e13ae15a5b533a48cc955b98951eedd04dbb)) +## v0.34.2 (2021-08-09) -* Update changelog for 0.35.0 (#957) ([`dd40437`](https://github.com/python-zeroconf/python-zeroconf/commit/dd40437f4328f4ee36c43239ecf5f484b6ac261e)) -* Reduce chance of accidental synchronization of ServiceInfo requests (#955) ([`c772936`](https://github.com/python-zeroconf/python-zeroconf/commit/c77293692062ea701037e06c1cf5497f019ae2f2)) +## v0.34.1 (2021-08-08) -* Send unicast replies on the same socket the query was received (#952) -When replying to a QU question, we do not know if the sending host is reachable -from all of the sending sockets. We now avoid this problem by replying via -the receiving socket. This was the existing behavior when `InterfaceChoice.Default` -is set. +## v0.34.0 (2021-08-08) -This change extends the unicast relay behavior to used with `InterfaceChoice.Default` -to apply when `InterfaceChoice.All` or interfaces are explicitly passed when -instantiating a `Zeroconf` instance. -Fixes #951 ([`5fb3e20`](https://github.com/python-zeroconf/python-zeroconf/commit/5fb3e202c06e3a0d30e3c7824397d8e8a9f52555)) +## v0.33.4 (2021-08-06) -* Sort responses to increase chance of name compression (#954) -- When building an outgoing response, sort the names together - to increase the likelihood of name compression. In testing - this reduced the number of packets for large responses - (from 7 packets to 6) ([`ebc23ee`](https://github.com/python-zeroconf/python-zeroconf/commit/ebc23ee5e9592dd7f0235cd57f9b3ad727ec8bff)) +## v0.33.3 (2021-08-05) -## v0.34.3 (2021-08-09) +## v0.33.2 (2021-07-28) -### Unknown -* Bump version: 0.34.2 → 0.34.3 ([`9d69d18`](https://github.com/python-zeroconf/python-zeroconf/commit/9d69d18713bdfab53762a6b8c3aff7fd72ebd025)) +## v0.33.1 (2021-07-18) -* Update changelog for 0.34.3 (#950) ([`23b00e9`](https://github.com/python-zeroconf/python-zeroconf/commit/23b00e983b2e8335431dcc074935f379fd399d46)) -* Fix sending immediate multicast responses (#949) +## v0.33.0 (2021-07-18) -- Fixes a typo in handle_assembled_query that prevented immediate - responses from being sent. ([`02af7f7`](https://github.com/python-zeroconf/python-zeroconf/commit/02af7f78d2e5eabcc5cce8238546ee5170951b28)) +## v0.32.1 (2021-07-05) -## v0.34.2 (2021-08-09) -### Unknown +## v0.32.0 (2021-06-30) -* Bump version: 0.34.1 → 0.34.2 ([`6c21f68`](https://github.com/python-zeroconf/python-zeroconf/commit/6c21f6802b58d949038e9c8501ea204eeda57a16)) -* Update changelog for 0.34.2 (#947) ([`b87f493`](https://github.com/python-zeroconf/python-zeroconf/commit/b87f4934b39af02f26bbbfd6f372c7154fe95906)) +## v0.29.0 (2021-03-25) -* Ensure ServiceInfo requests can be answered with the default timeout with network protection (#946) -- Adjust the time windows to ensure responses that have triggered the -protection against against excessive packet flooding due to -software bugs or malicious attack described in RFC6762 section 6 -can respond in under 1350ms to ensure ServiceInfo can ask two -questions within the default timeout of 3000ms ([`6d7266d`](https://github.com/python-zeroconf/python-zeroconf/commit/6d7266d0e1e6dcb950456da0354b4c43fd5c0ecb)) +## v0.28.8 (2021-01-04) -* Coalesce aggregated multicast answers when the random delay is shorter than the last scheduled response (#945) -- Reduces traffic when we already know we will be sending a group of answers - inside the random delay window described in - https://datatracker.ietf.org/doc/html/rfc6762#section-6.3 +## v0.28.7 (2020-12-13) -closes #944 ([`9a5164a`](https://github.com/python-zeroconf/python-zeroconf/commit/9a5164a7a3231903537231bfb56479e617355f92)) +## v0.28.6 (2020-10-13) -## v0.34.1 (2021-08-08) -### Unknown +## v0.28.5 (2020-09-11) -* Bump version: 0.34.0 → 0.34.1 ([`7878a9e`](https://github.com/python-zeroconf/python-zeroconf/commit/7878a9eed93a8ec2396d8450389a08bf54bd5693)) -* Update changelog for 0.34.1 (#943) ([`9942484`](https://github.com/python-zeroconf/python-zeroconf/commit/9942484172d7a79fe84c47924538c2c02fde7264)) +## v0.28.4 (2020-09-06) -* Ensure multicast aggregation sends responses within 620ms (#942) ([`de96e2b`](https://github.com/python-zeroconf/python-zeroconf/commit/de96e2bf01af68d754bb7c71da949e30de88a77b)) +## v0.28.3 (2020-08-31) -## v0.34.0 (2021-08-08) -### Unknown +## v0.28.2 (2020-08-27) -* Bump version: 0.33.4 → 0.34.0 ([`549ac3d`](https://github.com/python-zeroconf/python-zeroconf/commit/549ac3de27eb3924cc7967088c3d316184722b9d)) -* Update changelog for 0.34.0 (#941) ([`342532e`](https://github.com/python-zeroconf/python-zeroconf/commit/342532e1d13ac24673735dc467a79edebdfb9362)) +## v0.28.1 (2020-08-17) -* Implement Multicast Response Aggregation (#940) -- Responses are now aggregated when possible per rules in RFC6762 section 6.4 -- Responses that trigger the protection against against excessive packet flooding due to - software bugs or malicious attack described in RFC6762 section 6 are delayed instead of discarding as it was causing responders that implement Passive Observation Of Failures (POOF) to evict the records. -- Probe responses are now always sent immediately as there were cases where they would fail to be answered in time to defend a name. +## v0.28.0 (2020-07-07) -closes #939 ([`55efb41`](https://github.com/python-zeroconf/python-zeroconf/commit/55efb4169b588cef093f3065f3a894878ae8bd95)) +## v0.27.1 (2020-06-05) -## v0.33.4 (2021-08-06) -### Unknown +## v0.27.0 (2020-05-27) -* Bump version: 0.33.3 → 0.33.4 ([`7bbacd5`](https://github.com/python-zeroconf/python-zeroconf/commit/7bbacd57a134c12ee1fb61d8318b312dfdae18f8)) -* Update changelog for 0.33.4 (#937) ([`858605d`](https://github.com/python-zeroconf/python-zeroconf/commit/858605db52f909d41198df76130597ff93f64cdd)) +## v0.26.3 (2020-05-26) -* Ensure zeroconf can be loaded when the system disables IPv6 (#933) -Co-authored-by: J. Nick Koston ([`496ac44`](https://github.com/python-zeroconf/python-zeroconf/commit/496ac44e99b56485cc9197490e71bb2dd7bec6f9)) +## v0.26.1 (2020-05-06) -## v0.33.3 (2021-08-05) +## v0.26.0 (2020-04-26) -### Unknown -* Bump version: 0.33.2 → 0.33.3 ([`206671a`](https://github.com/python-zeroconf/python-zeroconf/commit/206671a1237ee8237d302b04c5a84158fed1d50b)) +## v0.25.1 (2020-04-14) -* Update changelog for 0.33.3 (#936) ([`6a140cc`](https://github.com/python-zeroconf/python-zeroconf/commit/6a140cc6b9c7e50e572456662d2f76f6fbc2ed25)) -* Add support for forward dns compression pointers (#934) +## v0.25.0 (2020-04-03) -- nslookup supports these and some implementations (likely avahi) - will generate them -- Careful attention was given to make sure we detect loops - and do not create anti-patterns described in - https://github.com/Forescout/namewreck/blob/main/rfc/draft-dashevskyi-dnsrr-antipatterns-00.txt +## v0.24.5 (2020-03-08) -Fixes https://github.com/home-assistant/core/issues/53937 -Fixes https://github.com/home-assistant/core/issues/46985 -Fixes https://github.com/home-assistant/core/issues/53668 -Fixes #308 ([`5682a4c`](https://github.com/python-zeroconf/python-zeroconf/commit/5682a4c3c89043bf8a10e79232933ada5ab71972)) -* Provide sockname when logging a protocol error (#935) ([`319992b`](https://github.com/python-zeroconf/python-zeroconf/commit/319992bb093d9b965976bad724512d9bcd05aca7)) +## v0.24.4 (2019-12-30) -## v0.33.2 (2021-07-28) +## v0.24.3 (2019-12-23) -### Unknown -* Bump version: 0.33.1 → 0.33.2 ([`4d30c25`](https://github.com/python-zeroconf/python-zeroconf/commit/4d30c25fe57425bcae36a539006e44941ef46e2c)) +## v0.24.2 (2019-12-17) -* Update changelog for 0.33.2 (#931) ([`c80b5f7`](https://github.com/python-zeroconf/python-zeroconf/commit/c80b5f7253e521928d6f7e54681675be59371c6c)) -* Handle duplicate goodbye answers in the same packet (#928) +## v0.24.1 (2019-12-16) -- Solves an exception being thrown when we tried to remove the known answer - from the cache when the second goodbye answer in the same packet was processed -- We previously swallowed all exceptions on cache removal so this was not - visible until 0.32.x which removed the broad exception catch +## v0.24.0 (2019-11-19) -Fixes #926 ([`97e0b66`](https://github.com/python-zeroconf/python-zeroconf/commit/97e0b669be60f716e45e963f1bcfcd35b7213626)) -* Skip ipv6 interfaces that return ENODEV (#930) ([`73e3d18`](https://github.com/python-zeroconf/python-zeroconf/commit/73e3d1865f4167e7c9f7c23ec4cc7ebfac40f512)) +## v0.23.0 (2019-06-04) -* Remove some pylint workarounds (#925) ([`1247acd`](https://github.com/python-zeroconf/python-zeroconf/commit/1247acd2e6f6154a4e5f2e27a820c55329391d8e)) +## v0.22.0 (2019-04-27) -## v0.33.1 (2021-07-18) -### Unknown +## v0.21.3 (2018-09-21) -* Bump version: 0.33.0 → 0.33.1 ([`6774de3`](https://github.com/python-zeroconf/python-zeroconf/commit/6774de3e7f8b461ccb83675bbb05d47949df487b)) -* Update changelog for 0.33.1 (#924) +## v0.21.2 (2018-09-20) -- Fixes overly restrictive directory permissions reported in #923 ([`ed80333`](https://github.com/python-zeroconf/python-zeroconf/commit/ed80333896c0710857cc46b5af4d7ba3a81e07c8)) +## v0.21.1 (2018-09-17) -## v0.33.0 (2021-07-18) -### Unknown +## v0.21.0 (2018-09-16) -* Bump version: 0.32.1 → 0.33.0 ([`cfb28aa`](https://github.com/python-zeroconf/python-zeroconf/commit/cfb28aaf134e566d8a89b397967d1ad1ec66de35)) -* Update changelog for 0.33.0 release (#922) ([`e4a9655`](https://github.com/python-zeroconf/python-zeroconf/commit/e4a96550398c408c3e1e6944662cc3093db912a7)) +## v0.20.0 (2018-02-21) -* Fix examples/async_registration.py attaching to the correct loop (#921) ([`b0b23f9`](https://github.com/python-zeroconf/python-zeroconf/commit/b0b23f96d3b33a627a0d071557a36af97a65dae4)) -* Add support for bump2version (#920) ([`2e00002`](https://github.com/python-zeroconf/python-zeroconf/commit/2e0000252f0aecad8b62a649128326a6528b6824)) +## v0.19.1 (2017-06-13) -* Update changelog for 0.33.0 release (#919) ([`96be961`](https://github.com/python-zeroconf/python-zeroconf/commit/96be9618ede3c941e23cb23398b9aed11bed1ffa)) -* Let connection_lost close the underlying socket (#918) +## v0.19.0 (2017-03-21) -- The socket was closed during shutdown before asyncio's connection_lost - handler had a chance to close it which resulted in a traceback on - win32. -- Fixes #917 ([`919b096`](https://github.com/python-zeroconf/python-zeroconf/commit/919b096d6260a4f9f4306b9b4dddb5b026b49462)) +## v0.18.0 (2017-02-03) -* Reduce complexity of DNSRecord (#915) -- Use constants for calculations in is_expired/is_stale/is_recent ([`b6eaf72`](https://github.com/python-zeroconf/python-zeroconf/commit/b6eaf7249f386f573b0876204ccfdfa02ee9ac5b)) +## v0.17.7 (2017-02-01) -* Remove Zeroconf.wait as its now unused in the codebase (#914) ([`aa71084`](https://github.com/python-zeroconf/python-zeroconf/commit/aa7108481235cc018600d096b093c785447d8769)) -* Switch periodic cleanup task to call_later (#913) +## v0.17.6 (2016-07-08) -- Simplifies AsyncEngine to avoid the long running - task ([`38eb271`](https://github.com/python-zeroconf/python-zeroconf/commit/38eb271c952e89260ecac6fac3e723f4206c4648)) +### Testing -* Update changelog for 0.33.0 (#912) ([`b2a7a00`](https://github.com/python-zeroconf/python-zeroconf/commit/b2a7a00f82d401066166776cecf0857ebbdb56ad)) +- Added test for DNS-SD subtype discovery + ([`914241b`](https://github.com/python-zeroconf/python-zeroconf/commit/914241b92c3097669e1e8c1a380f6c2f23a14cf8)) -* Remove locking from ServiceRegistry (#911) -- All calls to the ServiceRegistry are now done in async context - which makes them thread safe. Locking is no longer needed. ([`2d3da7a`](https://github.com/python-zeroconf/python-zeroconf/commit/2d3da7a77699f88bd90ebc09d36b333690385f85)) +## v0.17.5 (2016-03-14) -* Remove duplicate unregister_all_services code (#910) ([`e63ca51`](https://github.com/python-zeroconf/python-zeroconf/commit/e63ca518c91cda7b9f460436aee4fdac1a7b9567)) -* Rename DNSNsec.next to DNSNsec.next_name (#908) ([`69942d5`](https://github.com/python-zeroconf/python-zeroconf/commit/69942d5bfb4d92c6a312aea7c17f63fce0401e23)) +## v0.17.4 (2015-09-22) -* Upgrade syntax to python 3.6 (#907) ([`0578731`](https://github.com/python-zeroconf/python-zeroconf/commit/057873128ff05a0b2d6eae07510e23d705d10bae)) -* Implement NSEC record parsing (#903) +## v0.17.3 (2015-08-19) -- This is needed for negative responses - https://datatracker.ietf.org/doc/html/rfc6762#section-6.1 ([`bc9e9cf`](https://github.com/python-zeroconf/python-zeroconf/commit/bc9e9cf8a5b997ca924730ed091a829f4f961ca3)) -* Centralize running coroutines from threads (#906) +## v0.17.2 (2015-07-12) -- Cleanup to ensure all coros we run from a thread - use _LOADED_SYSTEM_TIMEOUT ([`9399c57`](https://github.com/python-zeroconf/python-zeroconf/commit/9399c57bb2b280c7b433e7fbea7cca2c2f4417ee)) -* Reduce duplicate code between zeroconf.asyncio and zeroconf._core (#904) ([`e417fc0`](https://github.com/python-zeroconf/python-zeroconf/commit/e417fc0f5ed7eaa47a0dcaffdbc6fe335bfcc058)) +## v0.17.1 (2015-04-10) -* Disable N818 in flake8 (#905) -- We cannot rename these exceptions now without a breaking change - as they have existed for many years ([`f8af0fb`](https://github.com/python-zeroconf/python-zeroconf/commit/f8af0fb251938dcb410127b2af2b8b407989aa08)) - - -## v0.32.1 (2021-07-05) - -### Unknown - -* Release version 0.32.1 ([`675fd6f`](https://github.com/python-zeroconf/python-zeroconf/commit/675fd6fc959e76e4e3690e5c7a02db269ca9ef60)) - -* Fix the changelog's one sentence's tense ([`fc089be`](https://github.com/python-zeroconf/python-zeroconf/commit/fc089be1f412d991f44daeecd0944198d3a638a5)) - -* Update changelog (#899) ([`a93301d`](https://github.com/python-zeroconf/python-zeroconf/commit/a93301d0fd493bf18147187bf8efed1a4ea02214)) - -* Increase timeout in ServiceInfo.request to handle loaded systems (#895) - -It can take a few seconds for a loaded system to run the `async_request` coroutine when the event loop is busy or the system is CPU bound (example being Home Assistant startup). We now add -an additional `_LOADED_SYSTEM_TIMEOUT` (10s) to the `run_coroutine_threadsafe` calls to ensure the coroutine has the total amount of time to run up to its internal timeout (default of 3000ms). - -Ten seconds is a bit large of a timeout; however, its only unused in cases where we wrap other timeouts. We now expect the only instance the `run_coroutine_threadsafe` result timeout will happen in a production circumstance is when someone is running a `ServiceInfo.request()` in a thread and another thread calls `Zeroconf.close()` at just the right moment that the future is never completed unless the system is so loaded that it is nearly unresponsive. - -The timeout for `run_coroutine_threadsafe` is the maximum time a thread can cleanly shut down when zeroconf is closed out in another thread, which should always be longer than the underlying thread operation. ([`56c7d69`](https://github.com/python-zeroconf/python-zeroconf/commit/56c7d692d67b7f56c386a7f1f4e45ebfc4e8366a)) - -* Add test for running sync code within executor (#894) ([`90bc8ca`](https://github.com/python-zeroconf/python-zeroconf/commit/90bc8ca8dce1af26ea81c5d6ecb17cf6ea664a71)) - - -## v0.32.0 (2021-06-30) - -### Unknown - -* Fix readme formatting - -It wasn't proper reStructuredText before: - - % twine check dist/* - Checking dist/zeroconf-0.32.0-py3-none-any.whl: FAILED - `long_description` has syntax errors in markup and would not be rendered on PyPI. - line 381: Error: Unknown target name: "async". - warning: `long_description_content_type` missing. defaulting to `text/x-rst`. - Checking dist/zeroconf-0.32.0.tar.gz: FAILED - `long_description` has syntax errors in markup and would not be rendered on PyPI. - line 381: Error: Unknown target name: "async". - warning: `long_description_content_type` missing. defaulting to `text/x-rst`. ([`82ff150`](https://github.com/python-zeroconf/python-zeroconf/commit/82ff150e0a72a7e20823a0c805f48f117bf1e274)) - -* Release version 0.32.0 ([`ea7bc85`](https://github.com/python-zeroconf/python-zeroconf/commit/ea7bc8592e418332e5b9973007698d3cd79754d9)) - -* Reformat changelog to match prior versions (#892) ([`34f6e49`](https://github.com/python-zeroconf/python-zeroconf/commit/34f6e498dec18b84dab1c27c75348916bceef8e6)) - -* Fix spelling and grammar errors in 0.32.0 changelog (#891) ([`ba235dd`](https://github.com/python-zeroconf/python-zeroconf/commit/ba235dd8bc65de4f461f76fd2bf4647844437e1a)) - -* Rewrite 0.32.0 changelog in past tense (#890) ([`0d91156`](https://github.com/python-zeroconf/python-zeroconf/commit/0d911568d367f1520acb19bdf830fe188b6ffb70)) - -* Reformat backwards incompatible changes to match previous versions (#889) ([`9abb40c`](https://github.com/python-zeroconf/python-zeroconf/commit/9abb40cf331bc0acc5fdbb03fce5c958cec8b41e)) - -* Remove extra newlines between changelog entries (#888) ([`d31fd10`](https://github.com/python-zeroconf/python-zeroconf/commit/d31fd103cc942574f7fbc75e5346cc3d3eaf7ee1)) - -* Collapse changelog for 0.32.0 (#887) ([`14cf936`](https://github.com/python-zeroconf/python-zeroconf/commit/14cf9362c9ae947bcee5911b9c593ca76f50d529)) - -* Disable pylint in the CI (#886) ([`b9dc12d`](https://github.com/python-zeroconf/python-zeroconf/commit/b9dc12dee8b4a7f6d8e1f599948bf16e5e7fab47)) - -* Revert name change of zeroconf.asyncio to zeroconf.aio (#885) - -- Now that `__init__.py` no longer needs to import `asyncio`, - the name conflict is not a concern. - -Fixes #883 ([`b9eae5a`](https://github.com/python-zeroconf/python-zeroconf/commit/b9eae5a6f8f86bfe60446f133cad5fc33d072959)) - -* Update changelog (#879) ([`be1d3bb`](https://github.com/python-zeroconf/python-zeroconf/commit/be1d3bbe0ee12254d11e3d8b75c2faba950fabce)) - -* Add coverage to ensure loading zeroconf._logger does not override logging level (#878) ([`86e2ab9`](https://github.com/python-zeroconf/python-zeroconf/commit/86e2ab9db3c7bd47b6e81837d594280ced3b30f9)) - -* Add coverge for disconnected adapters in add_multicast_member (#877) ([`ab83819`](https://github.com/python-zeroconf/python-zeroconf/commit/ab83819ad6b6ff727a894271dde3e4be6c28cb2c)) - -* Break apart net_socket for easier testing (#875) ([`f0770fe`](https://github.com/python-zeroconf/python-zeroconf/commit/f0770fea80b00f2340815fa983968f68a15c702e)) - -* Fix flapping test test_integration_with_listener_class (#876) ([`decd8a2`](https://github.com/python-zeroconf/python-zeroconf/commit/decd8a26aa8a89ceefcd9452fe562f2eeaa3fecb)) - -* Add coverage to ensure unrelated A records do not generate ServiceBrowser callbacks (#874) - -closes #871 ([`471bacd`](https://github.com/python-zeroconf/python-zeroconf/commit/471bacd3200aa1216054c0e52b2e5842e9760aa0)) - -* Update changelog (#870) ([`972da99`](https://github.com/python-zeroconf/python-zeroconf/commit/972da99e4dd9d0fe1c1e0786da45d66fd43a717a)) - -* Fix deadlock when event loop is shutdown during service registration (#869) ([`4ed9036`](https://github.com/python-zeroconf/python-zeroconf/commit/4ed903698b10f434cfbbe601998f27c10d2fb9db)) - -* Break apart new_socket to be testable (#867) ([`22ff6b5`](https://github.com/python-zeroconf/python-zeroconf/commit/22ff6b56d7b6531d2af5c50dca66fd2be2b276f4)) - -* Add test coverage to ensure ServiceBrowser ignores unrelated updates (#866) ([`dcf18c8`](https://github.com/python-zeroconf/python-zeroconf/commit/dcf18c8a32652c6aa70af180b6a5261f4277faa9)) - -* Add test coverage for duplicate properties in a TXT record (#865) ([`6ef65fc`](https://github.com/python-zeroconf/python-zeroconf/commit/6ef65fc7cafc3d4089a2b943da224c6cb027b4b0)) - -* Update changelog (#864) ([`c64064a`](https://github.com/python-zeroconf/python-zeroconf/commit/c64064ad3b38a40775637c0fd8877d9d00d2d537)) - -* Ensure protocol and sending errors are logged once (#862) ([`c516919`](https://github.com/python-zeroconf/python-zeroconf/commit/c516919064687551299f23e23bf0797888020041)) - -* Remove unreachable code in AsyncListener.datagram_received (#863) ([`f536869`](https://github.com/python-zeroconf/python-zeroconf/commit/f5368692d7907e440ca81f0acee9744f79dbae80)) - -* Add unit coverage for shutdown_loop (#860) ([`af83c76`](https://github.com/python-zeroconf/python-zeroconf/commit/af83c766c2ae72bd23184c6f6300e4d620c7b3e8)) - -* Make a dispatch dict for ServiceStateChange listeners (#859) ([`57cccc4`](https://github.com/python-zeroconf/python-zeroconf/commit/57cccc4dcbdc9df52672297968ccb55054122049)) - -* Cleanup coverage data (#858) ([`3eb7be9`](https://github.com/python-zeroconf/python-zeroconf/commit/3eb7be95fd6cd4960f96f29aa72fc45347c57b6e)) - -* Fix changelog formatting (#857) ([`59247f1`](https://github.com/python-zeroconf/python-zeroconf/commit/59247f1c44b485bf51d4a8d3e3966b9faf40cf82)) - -* Update changelog (#856) ([`cb2e237`](https://github.com/python-zeroconf/python-zeroconf/commit/cb2e237b6f1af0a83bc7352464562cdb7bbcac14)) - -* Only run linters on Linux in CI (#855) - -- The github MacOS and Windows runners are slower and - will have the same results as the Linux runners so there - is no need to wait for them. - -closes #854 ([`03411f3`](https://github.com/python-zeroconf/python-zeroconf/commit/03411f35d82752d5d2633a67db132a011098d9e6)) - -* Speed up test_verify_name_change_with_lots_of_names under PyPy (#853) - -fixes #840 ([`0cd876f`](https://github.com/python-zeroconf/python-zeroconf/commit/0cd876f5a42699aeb0176380ba4cca4d8a536df3)) - -* Make ServiceInfo first question QU (#852) - -- We want an immediate response when making a request with ServiceInfo - by asking a QU question, most responders will not delay the response - and respond right away to our question. This also improves compatibility - with split networks as we may not have been able to see the response - otherwise. If the responder has not multicast the record recently - it may still choose to do so in addition to responding via unicast - -- Reduces traffic when there are multiple zeroconf instances running - on the network running ServiceBrowsers - -- If we don't get an answer on the first try, we ask a QM question - in the event we can't receive a unicast response for some reason - -- This change puts ServiceInfo inline with ServiceBrowser which - also asks the first question as QU since ServiceInfo is commonly - called from ServiceBrowser callbacks - -closes #851 ([`76e0b05`](https://github.com/python-zeroconf/python-zeroconf/commit/76e0b05ca9c601bd638817bf68ca8d981f1d65f8)) - -* Update changelog (#850) ([`8c9d1d8`](https://github.com/python-zeroconf/python-zeroconf/commit/8c9d1d8964d9226d5d3ac38bec908e930954b369)) - -* Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849) - -- Simplifies scheduling as there is no more need to sleep in a loop as - we now schedule future callbacks with call_later - -- Simplifies cancelation as there is no more coroutine to cancel, only a timer handle - We no longer have to handle the canceled error and cleaning up the awaitable - -- Solves the infrequent test failures in test_backoff and test_integration ([`a8c1623`](https://github.com/python-zeroconf/python-zeroconf/commit/a8c16231881de43adedbedbc3f1ea707c0b457f2)) - -* Fix spurious failures in ZeroconfServiceTypes tests (#848) - -- These tests ran the same test twice in 0.5s and would - trigger the duplicate packet suppression. Rather then - making them run longer, we can disable the suppression - for the test. ([`9f71e5b`](https://github.com/python-zeroconf/python-zeroconf/commit/9f71e5b7364d4a23492cafe4f49a5c2acda4178d)) - -* Fix thread safety in handlers test (#847) ([`182c68f`](https://github.com/python-zeroconf/python-zeroconf/commit/182c68ff11ba381444a708e17560e920ae1849ef)) - -* Update changelog (#845) ([`72502c3`](https://github.com/python-zeroconf/python-zeroconf/commit/72502c303a1a889cf84906b8764fd941a840e6d3)) - -* Increase timeout in test_integration (#844) - -- The github macOS runners tend to be a bit loaded and these - sometimes fail because of it ([`dd86f2f`](https://github.com/python-zeroconf/python-zeroconf/commit/dd86f2f9fee4bbaebce956b330c1837a6e9c6c99)) - -* Use AAAA records instead of A records in test_integration_with_listener_ipv6 (#843) ([`688c518`](https://github.com/python-zeroconf/python-zeroconf/commit/688c5184dce67e5af857c138639ced4bdcec1e57)) - -* Fix ineffective patching on PyPy (#842) - -- Use patch in all places so its easier to find where we need - to clean up ([`ecd9c94`](https://github.com/python-zeroconf/python-zeroconf/commit/ecd9c941810e4b413b20dc55929b3ae1a7e57b27)) - -* Limit duplicate packet suppression to 1s intervals (#841) - -- Only suppress duplicate packets that happen within the same - second. Legitimate queriers will retry the question if they - are suppressed. The limit was reduced to one second to be - in line with rfc6762: - - To protect the network against excessive packet flooding due to - software bugs or malicious attack, a Multicast DNS responder MUST NOT - (except in the one special case of answering probe queries) multicast - a record on a given interface until at least one second has elapsed - since the last time that record was multicast on that particular ([`7fb11bf`](https://github.com/python-zeroconf/python-zeroconf/commit/7fb11bfc03c06cbe9ed5a4303b3e632d69665bb1)) - -* Skip dependencies install in CI on cache hit (#839) - -There is no need to reinstall dependencies in the CI when we have a cache hit. ([`937be52`](https://github.com/python-zeroconf/python-zeroconf/commit/937be522a42830b27326b5253d49003b57998bc9)) - -* Adjust restore key for CI cache (#838) ([`3fdd834`](https://github.com/python-zeroconf/python-zeroconf/commit/3fdd8349553c160586fb6831c9466410f19a3308)) - -* Make multipacket known answer suppression per interface (#836) - -- The suppression was happening per instance of Zeroconf instead - of per interface. Since the same network can be seen on multiple - interfaces (usually and wifi and ethernet), this would confuse the - multi-packet known answer supression since it was not expecting - to get the same data more than once - -Fixes #835 ([`7297f3e`](https://github.com/python-zeroconf/python-zeroconf/commit/7297f3ef71c9984296c3e28539ce7a4b42f04a05)) - -* Ensure coverage.xml is written for codecov (#837) ([`0b1abbc`](https://github.com/python-zeroconf/python-zeroconf/commit/0b1abbc8f2b09235cfd44e5586024c7b82dc5289)) - -* Wait for startup in test_integration (#834) ([`540c652`](https://github.com/python-zeroconf/python-zeroconf/commit/540c65218eb9d1aedc88a3d3724af97f39ccb88e)) - -* Cache dependency installs in CI (#833) ([`0bf4f75`](https://github.com/python-zeroconf/python-zeroconf/commit/0bf4f7537a042a00d9d3f815afcdf7ebe29d9f53)) - -* Annotate test failures on github (#831) ([`4039b0b`](https://github.com/python-zeroconf/python-zeroconf/commit/4039b0b755a3d0fe15e4cb1a7cb1592c35e048e1)) - -* Show 20 slowest tests on each run (#832) ([`8230e3f`](https://github.com/python-zeroconf/python-zeroconf/commit/8230e3f40da5d2d152942725d67d5f8c0b8c647b)) - -* Disable duplicate question suppression for test_integration (#830) - -- This test waits until we get 50 known answers. It would - sometimes fail because it could not ask enough - unsuppressed questions in the allowed time. ([`10f4a7f`](https://github.com/python-zeroconf/python-zeroconf/commit/10f4a7f8d607d09673be56e5709912403503d86b)) - -* Convert test_integration to asyncio to avoid testing threading races (#828) - -Fixes #768 ([`4c4b388`](https://github.com/python-zeroconf/python-zeroconf/commit/4c4b388ba125ad23a03722b30c71da86853fe05a)) - -* Update changelog (#827) ([`82f80c3`](https://github.com/python-zeroconf/python-zeroconf/commit/82f80c301a6324d2f1711ca751e81069e90030ec)) - -* Drop oversize packets before processing them (#826) - -- Oversized packets can quickly overwhelm the system and deny - service to legitimate queriers. In practice this is usually - due to broken mDNS implementations rather than malicious - actors. ([`6298ef9`](https://github.com/python-zeroconf/python-zeroconf/commit/6298ef9078cf2408bc1e57660ee141e882d13469)) - -* Guard against excessive ServiceBrowser queries from PTR records significantly lower than recommended (#824) - -* We now enforce a minimum TTL for PTR records to avoid -ServiceBrowsers generating excessive queries refresh queries. -Apple uses a 15s minimum TTL, however we do not have the same -level of rate limit and safe guards so we use 1/4 of the recommended value. ([`7f6d003`](https://github.com/python-zeroconf/python-zeroconf/commit/7f6d003210244b6f7df133bd474d7ddf64098422)) - -* Update changelog (#822) ([`4a82769`](https://github.com/python-zeroconf/python-zeroconf/commit/4a8276941a07188180ee31dc4ca578306c2df92b)) - -* Only wake up the query loop when there is a change in the next query time (#818) - -The ServiceBrowser query loop (async_browser_task) was being awoken on -every packet because it was using `zeroconf.async_wait` which wakes -up on every new packet. We only need to awaken the loop when the next time -we are going to send a query has changed. - -fixes #814 fixes #768 ([`4062fe2`](https://github.com/python-zeroconf/python-zeroconf/commit/4062fe21d8baaad36960f8cae0f59ac7083a6b55)) - -* Fix reliablity of tests that patch sending (#820) ([`a7b4f8e`](https://github.com/python-zeroconf/python-zeroconf/commit/a7b4f8e070de69db1ed872e2ff7a953ec624394c)) - -* Fix default v6_flow_scope argument with tests that mock send (#819) ([`f9d3529`](https://github.com/python-zeroconf/python-zeroconf/commit/f9d35299a39fee0b1632a3b2ac00170f761d53b1)) - -* Turn on logging in the types test (#816) - -- Will be needed to track down #813 ([`ffd2532`](https://github.com/python-zeroconf/python-zeroconf/commit/ffd2532f72a59ede86732b310512774b8fa344e7)) - -* New ServiceBrowsers now request QU in the first outgoing when unspecified (#812) ([`e32bb5d`](https://github.com/python-zeroconf/python-zeroconf/commit/e32bb5d98be0dc7ed130224206a4de699bcd68e3)) - -* Update changelog (#811) ([`13c558c`](https://github.com/python-zeroconf/python-zeroconf/commit/13c558cf3f40e52a13347a39b050e49a9241c269)) - -* Simplify wait_event_or_timeout (#810) - -- This function always did the same thing on timeout and - wait complete so we can use the same callback. This - solves the CI failing due to the test coverage flapping - back and forth as the timeout would rarely happen. ([`d4c8f0d`](https://github.com/python-zeroconf/python-zeroconf/commit/d4c8f0d3ffdcdc609810aca383492a57f9e1a723)) - -* Make DNSHinfo and DNSAddress use the same match order as DNSPointer and DNSText (#808) - -We want to check the data that is most likely to be unique first -so we can reject the __eq__ as soon as possible. ([`f9bbbce`](https://github.com/python-zeroconf/python-zeroconf/commit/f9bbbce388f2c6c24109c15ef843c10eeccf008f)) - -* Format tests/services/test_info.py with newer black (#809) ([`0129ac0`](https://github.com/python-zeroconf/python-zeroconf/commit/0129ac061db4a950f7bddf1084309e44aaabdbdf)) - -* Qualify IPv6 link-local addresses with scope_id (#343) - -Co-authored-by: Lokesh Prajapati -Co-authored-by: de Angelis, Antonio - -When a service is advertised on an IPv6 address where -the scope is link local, i.e. fe80::/64 (see RFC 4007) -the resolved IPv6 address must be extended with the -scope_id that identifies through the "%" symbol the -local interface to be used when routing to that address. -A new API `parsed_scoped_addresses()` is provided to -return qualified addresses to avoid breaking compatibility -on the existing parsed_addresses(). ([`05bb21b`](https://github.com/python-zeroconf/python-zeroconf/commit/05bb21b9b43f171e30b48fad6a756df49162b557)) - -* Tag 0.32.0b3 (#805) ([`5dccf34`](https://github.com/python-zeroconf/python-zeroconf/commit/5dccf3496a9bd4c268da4c39aab545ddcd50ac57)) - -* Update changelog (#804) ([`59e4bd2`](https://github.com/python-zeroconf/python-zeroconf/commit/59e4bd25347aac254700dc3a1518676042982b3a)) - -* Skip network adapters that are disconnected (#327) - -Co-authored-by: J. Nick Koston ([`df66da2`](https://github.com/python-zeroconf/python-zeroconf/commit/df66da2a943b9ff978602680b746f1edeba048dc)) - -* Add slots to DNS classes (#803) - -- On a busy network that receives many mDNS packets per second, we - will not know the answer to most of the questions being asked. - In this case the creating the DNS* objects are usually garbage - collected within 1s as they are not needed. We now set __slots__ - to speed up the creation and destruction of these objects ([`18fe341`](https://github.com/python-zeroconf/python-zeroconf/commit/18fe341300e28ed93d7b5d7ca8e07edb119bd597)) - -* Update changelog (#802) ([`58ae3cf`](https://github.com/python-zeroconf/python-zeroconf/commit/58ae3cf553cd925ac90f3db551f4085ea5bc8b79)) - -* Update changelog (#801) ([`662ed61`](https://github.com/python-zeroconf/python-zeroconf/commit/662ed6166282b9b5b6e83a596c0576a57f8962d2)) - -* Ensure we handle threadsafe shutdown under PyPy with multiple event loops (#800) ([`bbc9124`](https://github.com/python-zeroconf/python-zeroconf/commit/bbc91241a86f3339aa27cae7b4ea2ab9d7c1f37d)) - -* Update changelog (#798) ([`9961dce`](https://github.com/python-zeroconf/python-zeroconf/commit/9961dce598d3c6eeda68a2f874a7a50ec33f819c)) - -* Ensure fresh ServiceBrowsers see old_record as None when replaying the cache (#793) ([`38e66ec`](https://github.com/python-zeroconf/python-zeroconf/commit/38e66ec5ba5fcb96cef17b8949385075807a2fb7)) - -* Update changelog (#797) ([`c36099a`](https://github.com/python-zeroconf/python-zeroconf/commit/c36099a41a71298d58e7afa42ecdc7a54d3b010a)) - -* Pass both the new and old records to async_update_records (#792) - -* Pass the old_record (cached) as the value and the new_record (wire) -to async_update_records instead of forcing each consumer to -check the cache since we will always have the old_record -when generating the async_update_records call. This avoids -the overhead of multiple cache lookups for each listener. ([`d637d67`](https://github.com/python-zeroconf/python-zeroconf/commit/d637d67378698e0a505be90afbce4e2264b49444)) - -* Remove unused constant from zeroconf._handlers (#796) ([`cb91484`](https://github.com/python-zeroconf/python-zeroconf/commit/cb91484670ba76c8c453dc49502e89195561b31e)) - -* Make add_listener and remove_listener threadsafe (#794) ([`2bfbcbe`](https://github.com/python-zeroconf/python-zeroconf/commit/2bfbcbe9e05b9df98bba66a73deb0041c0e7c13b)) - -* Fix test_tc_bit_defers_last_response_missing failures due to thread safety (#795) ([`6aac0eb`](https://github.com/python-zeroconf/python-zeroconf/commit/6aac0eb0c1e394ec7ee21ddd6e98e446417d0e07)) - -* Ensure outgoing ServiceBrowser questions are seen by the question history (#790) ([`ecad4e8`](https://github.com/python-zeroconf/python-zeroconf/commit/ecad4e84c44ffd21dbf15e969c08f7b3376b131c)) - -* Update changelog (#788) ([`5d23628`](https://github.com/python-zeroconf/python-zeroconf/commit/5d2362825110e9f7a9c9259218a664e2e927e821)) - -* Add async_apple_scanner example (#719) ([`62dc9c9`](https://github.com/python-zeroconf/python-zeroconf/commit/62dc9c91c277bc4755f81597adca030a43d0ce5f)) - -* Add support for requesting QU questions to ServiceBrowser and ServiceInfo (#787) ([`135983c`](https://github.com/python-zeroconf/python-zeroconf/commit/135983cb96a27e3ad3750234286d1d9bfa6ff44f)) - -* Update changelog (#786) ([`3b3ecf0`](https://github.com/python-zeroconf/python-zeroconf/commit/3b3ecf09d2f30ee39c6c29b4d85e000577b2c4b9)) - -* Ensure the queue is created before adding listeners to ServiceBrowser (#785) - -* Ensure the queue is created before adding listeners to ServiceBrowser - -- The callback from the listener could generate an event that would - fire in async context that should have gone to the queue which - could result in the consumer running a sync call in the event loop - and blocking it. - -* add comments - -* add comments - -* add comments - -* add comments - -* black ([`97f5b50`](https://github.com/python-zeroconf/python-zeroconf/commit/97f5b502815075f2ff29bee3ace7cde6ad725dfb)) - -* Add a guard to prevent running ServiceInfo.request in async context (#784) - -* Add a guard to prevent running ServiceInfo.request in async context - -* test ([`dd85ae7`](https://github.com/python-zeroconf/python-zeroconf/commit/dd85ae7defd3f195ed0511a2fdb6512326ca0562)) - -* Inline utf8 decoding when processing incoming packets (#782) ([`3be1bc8`](https://github.com/python-zeroconf/python-zeroconf/commit/3be1bc84bff5ee2840040ddff41185b257a1055c)) - -* Drop utf cache from _dns (#781) - -- The cache did not make enough difference to justify the additional - complexity after additional testing was done ([`1b87343`](https://github.com/python-zeroconf/python-zeroconf/commit/1b873436e2d9ff36876a71c48fa697d277fd3ffa)) - -* Switch to using a simple cache instead of lru_cache (#779) ([`7aeafbf`](https://github.com/python-zeroconf/python-zeroconf/commit/7aeafbf3b990ab671ff691b6c20cd410f69808bf)) - -* Reformat test_handlers (#780) ([`767ae8f`](https://github.com/python-zeroconf/python-zeroconf/commit/767ae8f6cd92493f8f43d66edc70c8fd856ed11e)) - -* Fix Responding to Address Queries (RFC6762 section 6.2) (#777) ([`ac9f72a`](https://github.com/python-zeroconf/python-zeroconf/commit/ac9f72a986ae314af0043cae6fb6219baabea7e6)) - -* Implement duplicate question supression (#770) - -https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 ([`c0f4f48`](https://github.com/python-zeroconf/python-zeroconf/commit/c0f4f48e2bb996ce18cb569aa5369356cbc919ff)) - -* Fix deadlock on ServiceBrowser shutdown with PyPy (#774) ([`b5d54e4`](https://github.com/python-zeroconf/python-zeroconf/commit/b5d54e485d9dbcde1b7b472760a0b307198b8ec8)) - -* Add a guard against the task list changing when shutting down (#776) ([`e8836b1`](https://github.com/python-zeroconf/python-zeroconf/commit/e8836b134c47080edaf47532d7cb844b307dfb08)) - -* Verify async callers can still use Zeroconf without migrating to AsyncZeroconf (#775) ([`f23df4f`](https://github.com/python-zeroconf/python-zeroconf/commit/f23df4f5f05e3911cbf96234b198ea88691aadad)) - -* Implement accidental synchronization protection (RFC2762 section 5.2) (#773) ([`b600547`](https://github.com/python-zeroconf/python-zeroconf/commit/b600547a47878775e1c6fb8df46682a670beccba)) - -* Improve performance of parsing DNSIncoming by caching read_utf (#769) ([`5d44a36`](https://github.com/python-zeroconf/python-zeroconf/commit/5d44a36a59c21ef7869ba9e6dde9f658d3502793)) - -* Add test coverage to ensure RecordManager.add_listener callsback known question answers (#767) ([`e70431e`](https://github.com/python-zeroconf/python-zeroconf/commit/e70431e1fdc92c155309a1d40c89fed48737970c)) - -* Switch to using an asyncio.Event for async_wait (#759) - -- We no longer need to check for thread safety under a asyncio.Condition - as the ServiceBrowser and ServiceInfo internals schedule coroutines - in the eventloop. ([`6c82fa9`](https://github.com/python-zeroconf/python-zeroconf/commit/6c82fa9efd0f434f0f7c83e3bd98bd7851ede4cf)) - -* Break test_lots_of_names into two tests (#764) ([`85532e1`](https://github.com/python-zeroconf/python-zeroconf/commit/85532e13e42447fcd6d4d4b0060f04d33c3ab780)) - -* Fix test_lots_of_names overflowing the incoming buffer (#763) ([`38b59a6`](https://github.com/python-zeroconf/python-zeroconf/commit/38b59a64592f41b2bb547b35c72a010a925a2941)) - -* Fix race condition in ServiceBrowser test_integration (#762) - -- The event was being cleared in the wrong thread which - meant if the test was fast enough it would not be seen - the second time and give a spurious failure ([`fc0e599`](https://github.com/python-zeroconf/python-zeroconf/commit/fc0e599eec77477dd8f21ecd68b238e6a27f1bcf)) - -* Add 60s timeout for each test (#761) ([`936500a`](https://github.com/python-zeroconf/python-zeroconf/commit/936500a47cc33d9daa86f9012b1791986361ff63)) - -* Add missing coverage for SignalRegistrationInterface (#758) ([`9f68fc8`](https://github.com/python-zeroconf/python-zeroconf/commit/9f68fc8b1b834d0194e8ba1069d052aa853a8d38)) - -* Update changelog (#757) ([`1c93baa`](https://github.com/python-zeroconf/python-zeroconf/commit/1c93baa486b1b0f44487891766e0a0c1de3eb252)) - -* Simplify ServiceBrowser callsbacks (#756) ([`f24ebba`](https://github.com/python-zeroconf/python-zeroconf/commit/f24ebba9ecc4d1626d570956a7cc735206d7ff6e)) - -* Revert: Fix thread safety in _ServiceBrowser.update_records_complete (#708) (#755) - -- This guarding is no longer needed as the ServiceBrowser loop - now runs in the event loop and the thread safety guard is no - longer needed ([`f53c88b`](https://github.com/python-zeroconf/python-zeroconf/commit/f53c88b52ed080c80e2e98d3da91a830f0c7ebca)) - -* Drop AsyncServiceListener (#754) ([`04cd268`](https://github.com/python-zeroconf/python-zeroconf/commit/04cd2688022ebd07c1f875fefc73f8d15c4ed56c)) - -* Run ServiceBrowser queries in the event loop (#752) ([`4d0a8f3`](https://github.com/python-zeroconf/python-zeroconf/commit/4d0a8f3c643a0fc5c3a40420bab96ef18dddaecb)) - -* Remove unused argument from AsyncZeroconf (#751) ([`e7adce2`](https://github.com/python-zeroconf/python-zeroconf/commit/e7adce2bf6ea0b4af1709369a36421acd9757b4a)) - -* Fix warning about Zeroconf._async_notify_all not being awaited in sync shutdown (#750) ([`3b9baf0`](https://github.com/python-zeroconf/python-zeroconf/commit/3b9baf07278290b2b4eb8ac5850bccfbd8b107d8)) - -* Update async_service_info_request example to ensure it runs in the right event loop (#749) ([`0f702c6`](https://github.com/python-zeroconf/python-zeroconf/commit/0f702c6a41bb33ed63872249b82d1111bdac4fa6)) - -* Run ServiceInfo requests in the event loop (#748) ([`0dbcabf`](https://github.com/python-zeroconf/python-zeroconf/commit/0dbcabfade41057a055ebefffd410d1afc3eb0ea)) - -* Remove support for notify listeners (#733) ([`7b3b4b5`](https://github.com/python-zeroconf/python-zeroconf/commit/7b3b4b5b8303a684165fcd53c0d9c36a1b8dda3d)) - -* Update changelog (#747) ([`0909c80`](https://github.com/python-zeroconf/python-zeroconf/commit/0909c80c67287ba92ed334ab6896136aec0f3f24)) - -* Relocate service info tests to tests/services/test_info.py (#746) ([`541292e`](https://github.com/python-zeroconf/python-zeroconf/commit/541292e55fee8bbafe687afcb8d152f6fe0efb5f)) - -* Relocate service browser tests to tests/services/test_browser.py (#745) ([`869c95a`](https://github.com/python-zeroconf/python-zeroconf/commit/869c95a51e228131eb7debe1acc47c105b9bf7b5)) - -* Relocate ServiceBrowser to zeroconf._services.browser (#744) ([`368163d`](https://github.com/python-zeroconf/python-zeroconf/commit/368163d3c30325d60021203430711e10fd6d97e9)) - -* Relocate ServiceInfo to zeroconf._services.info (#741) ([`f0d727b`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d727bd9addd6dab373b75008f04a6f8547928b)) - -* Run question answer callbacks from add_listener in the event loop (#740) ([`c8e15dd`](https://github.com/python-zeroconf/python-zeroconf/commit/c8e15dd2bb5f6d2eb3a8ef5f26ad044517b70c47)) - -* Fix flakey cache bit flush test (#739) ([`e227d6e`](https://github.com/python-zeroconf/python-zeroconf/commit/e227d6e4c337ef9d5aa626c41587a8046313e416)) - -* Remove second level caching from ServiceBrowsers (#737) ([`5feda7e`](https://github.com/python-zeroconf/python-zeroconf/commit/5feda7e318f7d164d2b04b2d243a804372517da6)) - -* Breakout ServiceBrowser handler from listener creation (#736) ([`35ac7a3`](https://github.com/python-zeroconf/python-zeroconf/commit/35ac7a39d1fab00898ed6075e7e930424716b627)) - -* Add fast cache lookup functions (#732) ([`9d31245`](https://github.com/python-zeroconf/python-zeroconf/commit/9d31245f9ed4f6b1f7d9d7c51daf0ca394fd208f)) - -* Switch to using DNSRRSet in RecordManager (#735) ([`c035925`](https://github.com/python-zeroconf/python-zeroconf/commit/c035925f47732a889c76a2ff0989b92c6687c950)) - -* Add test coverage to ensure the cache flush bit is properly handled (#734) ([`50af944`](https://github.com/python-zeroconf/python-zeroconf/commit/50af94493ff6bf5d21445eaa80d3a96f348b0d11)) - -* Fix server cache to be case-insensitive (#731) ([`3ee9b65`](https://github.com/python-zeroconf/python-zeroconf/commit/3ee9b650bedbe61d59838897f653ad43a6d51910)) - -* Update changelog (#730) ([`733f79d`](https://github.com/python-zeroconf/python-zeroconf/commit/733f79d28c7dd4500a1598b279ee638ead8bdd55)) - -* Prefix cache functions that are non threadsafe with async_ (#724) ([`3503e76`](https://github.com/python-zeroconf/python-zeroconf/commit/3503e7614fc31bbfe2c919f13689468cc73179fd)) - -* Fix cache handling of records with different TTLs (#729) - -- There should only be one unique record in the cache at - a time as having multiple unique records will different - TTLs in the cache can result in unexpected behavior since - some functions returned all matching records and some - fetched from the right side of the list to return the - newest record. Intead we now store the records in a dict - to ensure that the newest record always replaces the same - unique record and we never have a source of truth problem - determining the TTL of a record from the cache. ([`88aa610`](https://github.com/python-zeroconf/python-zeroconf/commit/88aa610274bf79aef6c74998f2bfca8c8de0dccb)) - -* Add tests for the DNSCache class (#728) - -- There is currently a bug in the implementation where an entry - can exist in two places in the cache with different TTLs. Since - a known answer cannot be both expired and expired at the same - time, this is a bug that needs to be fixed. ([`ceb79bd`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb79bd7f7bdad434cbe5b4846492cd434ea883b)) - -* Update changelog (#727) ([`9cc834d`](https://github.com/python-zeroconf/python-zeroconf/commit/9cc834d501fa5e582adeb4468b02775288e1fa11)) - -* Rename handlers and internals to make it clear what is threadsafe (#726) - -- It was too easy to get confused about what was threadsafe and - what was not threadsafe which lead to unexpected failures. - Rename functions to make it clear what will be run in the event - loop and what is expected to be threadsafe ([`f91af79`](https://github.com/python-zeroconf/python-zeroconf/commit/f91af79c8779ac235598f5584f439c78b3bdcca2)) - -* Fix ServiceInfo with multiple A records (#725) ([`3338594`](https://github.com/python-zeroconf/python-zeroconf/commit/33385948da9123bc9348374edce7502abd898e82)) - -* Relocate cache tests to tests/test_cache.py (#722) ([`e2d4d98`](https://github.com/python-zeroconf/python-zeroconf/commit/e2d4d98db70b376c53883367b3a24c1d2510c2b5)) - -* Synchronize time for fate sharing (#718) ([`18ddb8d`](https://github.com/python-zeroconf/python-zeroconf/commit/18ddb8dbeef3edad3bb97131803dfecde4355467)) - -* Update changelog (#717) ([`1ab6859`](https://github.com/python-zeroconf/python-zeroconf/commit/1ab685960bc0e412d36baf6794fde06350998474)) - -* Cleanup typing in zero._core and document ignores (#714) ([`8183640`](https://github.com/python-zeroconf/python-zeroconf/commit/818364008e911757fca24e41a4eb36e0eef49bfa)) - -* Update README (#716) ([`0f2f4e2`](https://github.com/python-zeroconf/python-zeroconf/commit/0f2f4e207cb5007112ba09e87a332b1a46cd1577)) - -* Cleanup typing in zeroconf._logger (#715) ([`3fcdcfd`](https://github.com/python-zeroconf/python-zeroconf/commit/3fcdcfd9a3efc56a34f0334ffb8706613e07d19d)) - -* Cleanup typing in zeroconf._utils.net (#713) ([`a50b3ee`](https://github.com/python-zeroconf/python-zeroconf/commit/a50b3eeda5f275c31b36cdc1c8312f61599e72bf)) - -* Cleanup typing in zeroconf._services (#711) ([`a42512c`](https://github.com/python-zeroconf/python-zeroconf/commit/a42512ca6a6a4c15f37ab623a96deb2aa06dd053)) - -* Cleanup typing in zeroconf._services.registry (#712) ([`6b923de`](https://github.com/python-zeroconf/python-zeroconf/commit/6b923deb3682088d0fe9182377b5603d0ade1e1a)) - -* Add setter for DNSQuestion to easily make a QU question (#710) - -Closes #703 ([`aeb1b23`](https://github.com/python-zeroconf/python-zeroconf/commit/aeb1b23defa2d5956a6f19acca4ce410d6a04cc9)) - -* Synchronize created time for incoming and outgoing queries (#709) ([`c366c8c`](https://github.com/python-zeroconf/python-zeroconf/commit/c366c8cc45f565c4066fc72b481c6a960bac1cb9)) - -* Set stale unique records to expire 1s in the future instead of instant removal (#706) - -- Fixes #475 - -- https://tools.ietf.org/html/rfc6762#section-10.2 - Queriers receiving a Multicast DNS response with a TTL of zero SHOULD - NOT immediately delete the record from the cache, but instead record - a TTL of 1 and then delete the record one second later. In the case - of multiple Multicast DNS responders on the network described in - Section 6.6 above, if one of the responders shuts down and - incorrectly sends goodbye packets for its records, it gives the other - cooperating responders one second to send out their own response to - "rescue" the records before they expire and are deleted. ([`f3eeecd`](https://github.com/python-zeroconf/python-zeroconf/commit/f3eeecd84413b510b9b8e05e2d1f6ad99d0dc37d)) - -* Fix thread safety in _ServiceBrowser.update_records_complete (#708) ([`dc0c613`](https://github.com/python-zeroconf/python-zeroconf/commit/dc0c6137742edf97626c972e5c9191dfbffaecdc)) - -* Split DNSOutgoing/DNSIncoming/DNSMessage into zeroconf._protocol (#705) ([`f39bde0`](https://github.com/python-zeroconf/python-zeroconf/commit/f39bde0f6cba7a3c1b8fe8bc1a4ab4388801e486)) - -* Update changelog (#699) ([`c368e1c`](https://github.com/python-zeroconf/python-zeroconf/commit/c368e1c67c82598e920ca52b1f7a47ed6e1cf738)) - -* Efficiently bucket queries with known answers (#698) ([`7e30848`](https://github.com/python-zeroconf/python-zeroconf/commit/7e308480238fdf2cfe08474d679121e77f746fa6)) - -* Abstract DNSOutgoing ttl write into _write_ttl (#695) ([`26fa2fb`](https://github.com/python-zeroconf/python-zeroconf/commit/26fa2fb479fff87ca5af17c2c09a557c4b6176b5)) - -* Use unique names in service types tests (#697) ([`767546b`](https://github.com/python-zeroconf/python-zeroconf/commit/767546b656d7db6df0cbf2b257953498f1bc3996)) - -* Rollback data in one call instead of poping one byte at a time in DNSOutgoing (#696) ([`5cbaa3f`](https://github.com/python-zeroconf/python-zeroconf/commit/5cbaa3fc02f635e6c735e1ee5f1ca19b84c0a069)) - -* Fix off by 1 in test_tc_bit_defers_last_response_missing (#694) ([`32b7dc4`](https://github.com/python-zeroconf/python-zeroconf/commit/32b7dc40e2c3621fcacb2f389d51408ab35ac832)) - -* Suppress additionals when answer is suppressed (#690) ([`0cdba98`](https://github.com/python-zeroconf/python-zeroconf/commit/0cdba98e65dd3dce2db8aa607e97e3b67b97721a)) - -* Move setting DNS created and ttl into its own function (#692) ([`993a82e`](https://github.com/python-zeroconf/python-zeroconf/commit/993a82e414db8aadaee0e0475e178e75df417a71)) - -* Remove AA flags from handlers test (#693) - -- The flag was added by mistake when copying from other tests ([`b60f307`](https://github.com/python-zeroconf/python-zeroconf/commit/b60f307d59e342983d1baa6040c3d997f84538ab)) - -* Implement multi-packet known answer supression (#687) - -- Implements https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - -- Fixes https://github.com/jstasiak/python-zeroconf/issues/499 ([`8a25a44`](https://github.com/python-zeroconf/python-zeroconf/commit/8a25a44ec5e4f21c6bdb282fefb8f6c2d296a70b)) - -* Remove sleeps from services types test (#688) - -- Instead of registering the services and doing the broadcast - we now put them in the registry directly. ([`4865d2b`](https://github.com/python-zeroconf/python-zeroconf/commit/4865d2ba782d0313c0f7d878f5887453086febaa)) - -* Add truncated property to DNSMessage to lookup the TC bit (#686) ([`e816053`](https://github.com/python-zeroconf/python-zeroconf/commit/e816053af4d900f57100c07c48f384165ba28b9a)) - -* Update changelog (#684) ([`6fd1bf2`](https://github.com/python-zeroconf/python-zeroconf/commit/6fd1bf2364da4fc2949a905d2e4acb7da003e84d)) - -* Add coverage to verify ServiceInfo tolerates bytes or string in the txt record (#683) ([`95ddb36`](https://github.com/python-zeroconf/python-zeroconf/commit/95ddb36de64ddf3be9e93f07a1daa8389410f73d)) - -* Fix logic reversal in apple_p2p test (#681) ([`00b972c`](https://github.com/python-zeroconf/python-zeroconf/commit/00b972c062fd0ed3f2fcc4ceaec84c43b9a613be)) - -* Check if SO_REUSEPORT exists instead of using an exception catch (#682) ([`d2b5e51`](https://github.com/python-zeroconf/python-zeroconf/commit/d2b5e51d0dcde801e171a4c1e43ef1f86abde825)) - -* Use DNSRRSet for known answer suppression (#680) - -- DNSRRSet uses hash table lookups under the hood which - is much faster than the linear searches used by - DNSRecord.suppressed_by ([`e5ea9bb`](https://github.com/python-zeroconf/python-zeroconf/commit/e5ea9bb6c0a3bce7d05241f275a205ddd9e6b615)) - -* Add DNSRRSet class for quick hashtable lookups of records (#678) - -- This class will be used to do fast checks to see - if records should be suppressed by a set of answers. ([`691c29e`](https://github.com/python-zeroconf/python-zeroconf/commit/691c29eeb049e17a12d6f0a6e3bce2c3f8c2aa02)) - -* Allow unregistering a service multiple times (#679) ([`d3d439a`](https://github.com/python-zeroconf/python-zeroconf/commit/d3d439ad5d475cff094a4ea83f19d17939527021)) - -* Remove unreachable BadTypeInNameException check in _ServiceBrowser (#677) ([`57c94bb`](https://github.com/python-zeroconf/python-zeroconf/commit/57c94bb25e056e1827f15c234d7e0bcb5702a0e3)) - -* Make calculation of times in DNSRecord lazy (#676) - -- Most of the time we only check one of the time attrs - or none at all. Wait to calculate them until they are - requested. ([`ba2a4f9`](https://github.com/python-zeroconf/python-zeroconf/commit/ba2a4f960d0f9478198968a1466a8b48c963b772)) - -* Add oversized packet to the invalid packet test (#671) ([`8535110`](https://github.com/python-zeroconf/python-zeroconf/commit/8535110dd661ce406904930994a9f86faf897597)) - -* Add test for sending unicast responses (#670) ([`d274cd3`](https://github.com/python-zeroconf/python-zeroconf/commit/d274cd3a3409997b764c49d3eae7e8ee2fba33b6)) - -* Add missing coverage for ServiceInfo address changes (#669) ([`d59fb8b`](https://github.com/python-zeroconf/python-zeroconf/commit/d59fb8be29d8602ad66d89f595b26671a528fd77)) - -* Add missing coverage for ServiceListener (#668) ([`75347b4`](https://github.com/python-zeroconf/python-zeroconf/commit/75347b4e30429e130716b666da52953700f0f8e9)) - -* Update async_browser.py example to use AsyncZeroconfServiceTypes (#665) ([`481cc42`](https://github.com/python-zeroconf/python-zeroconf/commit/481cc42d000f5b0258f1be3b6df7cb7b24428b7f)) - -* Permit the ServiceBrowser to browse overlong types (#666) - -- At least one type "tivo-videostream" exists in the wild - so we are permissive about what we will look for, and - strict about what we will announce. - -Fixes #661 ([`e76c7a5`](https://github.com/python-zeroconf/python-zeroconf/commit/e76c7a5b76485efce0929ee8417aa2e0f262c04c)) - -* Add an AsyncZeroconfServiceTypes to mirror ZeroconfServiceTypes to zeroconf.aio (#658) ([`aaf8a36`](https://github.com/python-zeroconf/python-zeroconf/commit/aaf8a368063f080be4a9c01fe671243e63bdf576)) - -* Fix flakey ZeroconfServiceTypes types test (#662) ([`72db0c1`](https://github.com/python-zeroconf/python-zeroconf/commit/72db0c10246e948c15d9a53f60a54b835ccc67bc)) - -* Add test for launching with apple_p2p=True (#660) - -- Switch to using `sys.platform` to detect Mac instead of - `platform.system()` since `platform.system()` is not intended - to be machine parsable and is only for humans. - -Closes #650 ([`0e52be0`](https://github.com/python-zeroconf/python-zeroconf/commit/0e52be059065e23ebe9e11c465adc20655b6080e)) - -* Add test for Zeroconf.get_service_info failure case (#657) ([`5752ace`](https://github.com/python-zeroconf/python-zeroconf/commit/5752ace7727bffa34cdac0455125a941014ab123)) - -* Add coverage for registering a service with a custom ttl (#656) ([`87fe529`](https://github.com/python-zeroconf/python-zeroconf/commit/87fe529a33b920532b2af688bb66182ae832a3ad)) - -* Improve aio utils tests to validate high lock contention (#655) ([`efd6bfb`](https://github.com/python-zeroconf/python-zeroconf/commit/efd6bfbe81f448da2ee68b91d49cbe1982271da3)) - -* Add test coverage for normalize_interface_choice exception paths (#654) ([`3c61d03`](https://github.com/python-zeroconf/python-zeroconf/commit/3c61d03f5954c3e45229d6c1399a63c0f7331d55)) - -* Remove all calls to the executor in AsyncZeroconf (#653) ([`7d8994b`](https://github.com/python-zeroconf/python-zeroconf/commit/7d8994bc3cb4d5978bb1ff189bb5a4b7c81b5c4c)) - -* Set __all__ in zeroconf.aio to ensure private functions do now show in the docs (#652) ([`b940f87`](https://github.com/python-zeroconf/python-zeroconf/commit/b940f878fe1f8e6b8dfe2554b781cd6034dee722)) - -* Ensure interface_index_to_ip6_address skips ipv4 adapters (#651) ([`df9f8d9`](https://github.com/python-zeroconf/python-zeroconf/commit/df9f8d9a0110cc9135b7c2f0b4cd47e985da9a7e)) - -* Add async_unregister_all_services to AsyncZeroconf (#649) ([`72e709b`](https://github.com/python-zeroconf/python-zeroconf/commit/72e709b40caed016ba981be3752c439bbbf40ec7)) - -* Use cache clear helper in aio tests (#648) ([`79e39c0`](https://github.com/python-zeroconf/python-zeroconf/commit/79e39c0e923a1f6d87353761809f34f0fe1f0800)) - -* Ensure services are removed from the registry when calling unregister_all_services (#644) - -- There was a race condition where a query could be answered for a service - in the registry while goodbye packets which could result a fresh record - being broadcast after the goodbye if a query came in at just the right - time. To avoid this, we now remove the services from the registry right - after we generate the goodbye packet ([`cf0b5b9`](https://github.com/python-zeroconf/python-zeroconf/commit/cf0b5b9e2cfa4779425401b3d205f5d913621864)) - -* Use ServiceInfo.key/ServiceInfo.server_key instead of lowering in ServiceRegistry (#647) ([`a83d390`](https://github.com/python-zeroconf/python-zeroconf/commit/a83d390bef042da51d93014c222c65af81723a20)) - -* Add missing coverage to ServiceRegistry (#646) ([`9354ab3`](https://github.com/python-zeroconf/python-zeroconf/commit/9354ab39f350e4e6451dc4965225591761ada40d)) - -* Ensure the ServiceInfo.key gets updated when the name is changed externally (#645) ([`330e36c`](https://github.com/python-zeroconf/python-zeroconf/commit/330e36ceb4202c579fe979958c63c37033ababbb)) - -* Ensure cache is cleared before starting known answer enumeration query test (#639) ([`5ebd954`](https://github.com/python-zeroconf/python-zeroconf/commit/5ebd95452b16e76c37649486b232856a80390ac3)) - -* Ensure AsyncZeroconf.async_close can be called multiple times like Zeroconf.close (#638) ([`ce6912a`](https://github.com/python-zeroconf/python-zeroconf/commit/ce6912a75392cde41d8950b224ba3d14460993ff)) - -* Update changelog (#637) ([`09c18a4`](https://github.com/python-zeroconf/python-zeroconf/commit/09c18a4173a013e67da5a1cdc7089452ba6f67ee)) - -* Ensure eventloop shutdown is threadsafe (#636) - -- Prevent ConnectionResetError from being thrown on - Windows with ProactorEventLoop on cpython 3.8+ ([`bbbbddf`](https://github.com/python-zeroconf/python-zeroconf/commit/bbbbddf40d78dbd62a84f2439763d0a59211c5b9)) - -* Update changelog (#635) ([`c854d03`](https://github.com/python-zeroconf/python-zeroconf/commit/c854d03efd31e1d002518a43221b347fa6ca5de5)) - -* Clear cache in ZeroconfServiceTypes tests to ensure responses can be mcast before the timeout (#634) - -- We prevent the same record from being multicast within 1s - because of RFC6762 sec 14. Since these test timeout after - 0.5s, the answers they are looking for many be suppressed. - Since a legitimate querier will retry again later, we need - to clear the cache to simulate that the record has not - been multicast recently ([`a0977a1`](https://github.com/python-zeroconf/python-zeroconf/commit/a0977a1ddfd7a7a1abcf74c1d90c18021aebc910)) - -* Mark DNSOutgoing write functions as protected (#633) ([`5f66caa`](https://github.com/python-zeroconf/python-zeroconf/commit/5f66caaccf44c1504988cb82c1cba78d28dde7e7)) - -* Return early in the shutdown/close process (#632) ([`4ce33e4`](https://github.com/python-zeroconf/python-zeroconf/commit/4ce33e48e2094f17d8358cf221c7e2f9a8cb3568)) - -* Update changelog (#631) ([`64f6dd7`](https://github.com/python-zeroconf/python-zeroconf/commit/64f6dd7e244c86d58b962f48a50d07625f2a2a33)) - -* Remove unreachable cache check for DNSAddresses (#629) - -- The ServiceBrowser would check to see if a DNSAddress was - already in the cache and return early to avoid sending - updates when the address already was held in the cache. - This check was not needed since there is already a check - a few lines before as `self.zc.cache.get(record)` which - effectively does the same thing. This lead to the check - never being covered in the tests and 2 cache lookups when - only one was needed. ([`2b31612`](https://github.com/python-zeroconf/python-zeroconf/commit/2b31612e3f128b1193da9e0d2640f4e93fab2e3a)) - -* Add test for wait_condition_or_timeout_times_out util (#630) ([`2065b1d`](https://github.com/python-zeroconf/python-zeroconf/commit/2065b1d7ec7cb5d41c34826c2d8887bdd8a018b6)) - -* Return early on invalid data received (#628) - -- Improve coverage for handling invalid incoming data ([`28a614e`](https://github.com/python-zeroconf/python-zeroconf/commit/28a614e0586a0ca1c5c1651b59c9a4d9c1af9a1b)) - -* Update changelog (#627) ([`215d6ba`](https://github.com/python-zeroconf/python-zeroconf/commit/215d6badb3db796b13a000b26953cb57c557e5e5)) - -* Add test to ensure ServiceBrowser sees port change as an update (#625) ([`113874a`](https://github.com/python-zeroconf/python-zeroconf/commit/113874a7b59ac9cc887b1b626ac1486781c7d56f)) - -* Fix random test failures due to monkey patching not being undone between tests (#626) - -- Switch patching to use unitest.mock.patch to ensure the patch - is reverted when the test is completed - -Fixes #505 ([`5750f7c`](https://github.com/python-zeroconf/python-zeroconf/commit/5750f7ceef0441fe1cedc0d96e7ef5ccc232d875)) - -* Ensure zeroconf can be loaded when the system disables IPv6 (#624) ([`42d53c7`](https://github.com/python-zeroconf/python-zeroconf/commit/42d53c7c04a7bbf4e60e691e2e58fe7acfec8ad9)) - -* Update changelog (#623) ([`4d05961`](https://github.com/python-zeroconf/python-zeroconf/commit/4d05961088efa8b503cad5658afade874eaeec76)) - -* Eliminate aio sender thread (#622) ([`f15e84f`](https://github.com/python-zeroconf/python-zeroconf/commit/f15e84f3ee7a644792fe98edde84dd216b3497cb)) - -* Replace select loop with asyncio loop (#504) ([`8f00cfc`](https://github.com/python-zeroconf/python-zeroconf/commit/8f00cfca0e67dde6afda399da6984ed7d8f929df)) - -* Add support for handling QU questions (#621) - -- Implements RFC 6762 sec 5.4: - Questions Requesting Unicast Responses - https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 ([`9a32db8`](https://github.com/python-zeroconf/python-zeroconf/commit/9a32db8582588e4bf812fd5670a7e61c50631a2e)) - -* Add is_recent property to DNSRecord (#620) - -- RFC 6762 defines recent as not multicast within one quarter of its TTL - https://datatracker.ietf.org/doc/html/rfc6762#section-5.4 ([`1f36754`](https://github.com/python-zeroconf/python-zeroconf/commit/1f36754f3964738e496a1da9c24380e204aaff01)) - -* Protect the network against excessive packet flooding (#619) ([`0e644ad`](https://github.com/python-zeroconf/python-zeroconf/commit/0e644ad650627024c7a3f926a86f7d9ecc66e591)) - -* Ensure matching PTR queries are returned with the ANY query (#618) - -Fixes #464 ([`b6365aa`](https://github.com/python-zeroconf/python-zeroconf/commit/b6365aa1f889a3045aa185f67354de622bd7ebd3)) - -* Suppress additionals when they are already in the answers section (#617) ([`427b728`](https://github.com/python-zeroconf/python-zeroconf/commit/427b7285269984cbb6f28c87a8bf8f864a5e15d7)) - -* Fix queries for AAAA records (#616) ([`0100c08`](https://github.com/python-zeroconf/python-zeroconf/commit/0100c08c5a3fb90d0795cf57f0bd3e11c7a94a0b)) - -* Breakout the query response handler into its own class (#615) ([`c828c75`](https://github.com/python-zeroconf/python-zeroconf/commit/c828c7555ed1fb82ff95ed578262d1553f19d903)) - -* Avoid including additionals when the answer is suppressed by known-answer supression (#614) ([`219aa3e`](https://github.com/python-zeroconf/python-zeroconf/commit/219aa3e54c944b2935c9a40cc15de19284aded3c)) - -* Add the ability for ServiceInfo.dns_addresses to filter by address type (#612) ([`aea2c8a`](https://github.com/python-zeroconf/python-zeroconf/commit/aea2c8ab24d4be19b34f407c854241e0d73d0525)) - -* Make DNSRecords hashable (#611) - -- Allows storing them in a set for de-duplication - -- Needed to be able to check for duplicates to solve https://github.com/jstasiak/python-zeroconf/issues/604 ([`b7d8678`](https://github.com/python-zeroconf/python-zeroconf/commit/b7d867878153fa600053869265260992e5462b2d)) - -* Ensure the QU bit is set for probe queries (#609) - -- The bit should be set per - https://datatracker.ietf.org/doc/html/rfc6762#section-8.1 ([`22bd147`](https://github.com/python-zeroconf/python-zeroconf/commit/22bd1475fb58c7c421c0009cd0c5c791cedb225d)) - -* Log destination when sending packets (#606) ([`850e211`](https://github.com/python-zeroconf/python-zeroconf/commit/850e2115aa79c10765dfc45a290a68193397de6c)) - -* Fix docs version to match readme (cpython 3.6+) (#602) ([`809b6df`](https://github.com/python-zeroconf/python-zeroconf/commit/809b6df376205e6ab5ce8fb5fe3a92e77662fe2d)) - -* Add ZeroconfServiceTypes to zeroconf.__all__ (#601) - -- This class is in the readme, but is not exported by - default ([`f6cd8f6`](https://github.com/python-zeroconf/python-zeroconf/commit/f6cd8f6d23459f9ed48ad06ff6702e606d620eaf)) - -* Ensure unicast responses can be sent to any source port (#598) - -- Unicast responses were only being sent if the source port - was 53, this prevented responses when testing with dig: - - dig -p 5353 @224.0.0.251 media-12.local - - The above query will now see a response ([`3556c22`](https://github.com/python-zeroconf/python-zeroconf/commit/3556c22aacc72e62c318955c084533b70311bcc9)) - -* Add id_ param to allow setting the id in the DNSOutgoing constructor (#599) ([`cb64e0d`](https://github.com/python-zeroconf/python-zeroconf/commit/cb64e0dd5d1c621f61d0d0f92ea282d287a9c242)) - -* Fix lookup of uppercase names in registry (#597) - -- If the ServiceInfo was registered with an uppercase name and the query was - for a lowercase name, it would not be found and vice-versa. ([`fe72524`](https://github.com/python-zeroconf/python-zeroconf/commit/fe72524dbaf934ca63ebce053e34f3e838743460)) - -* Add unicast property to DNSQuestion to determine if the QU bit is set (#593) ([`d2d8262`](https://github.com/python-zeroconf/python-zeroconf/commit/d2d826220bd4f287835ebb4304450cc2311d1db6)) - -* Reduce branching in DNSOutgoing.add_answer_at_time (#592) ([`35e25fd`](https://github.com/python-zeroconf/python-zeroconf/commit/35e25fd46f8d3689b723dd845eba9862a5dc8a22)) - -* Move notify listener tests to test_core (#591) ([`72032d6`](https://github.com/python-zeroconf/python-zeroconf/commit/72032d6dde2ee7388b8cb4545554519d3ffa8508)) - -* Set mypy follow_imports to skip as ignore is not a valid option (#590) ([`fd70ac1`](https://github.com/python-zeroconf/python-zeroconf/commit/fd70ac1b6bdded992f8fbbb723ca92f5395abf23)) - -* Relocate handlers tests to tests/test_handlers (#588) ([`8aa14d3`](https://github.com/python-zeroconf/python-zeroconf/commit/8aa14d33849c057c91a00e1093606081ade488e7)) - -* Relocate ServiceRegistry tests to tests/services/test_registry (#587) ([`ae6530a`](https://github.com/python-zeroconf/python-zeroconf/commit/ae6530a59e2d8ddb9a7367243c29c5e00665a82f)) - -* Disable flakey ServiceTypesQuery ipv6 win32 test (#586) ([`5cb5702`](https://github.com/python-zeroconf/python-zeroconf/commit/5cb5702fca2845e99b457e4427428497c3cd9b31)) - -* Relocate network utils tests to tests/utils/test_net (#585) ([`12f5676`](https://github.com/python-zeroconf/python-zeroconf/commit/12f567695b5364c9c5c5af0a7017d877de84274d)) - -* Relocate ServiceTypesQuery tests to tests/services/test_types (#584) ([`1fe282b`](https://github.com/python-zeroconf/python-zeroconf/commit/1fe282ba246505d172356cc8672307c7d125820d)) - -* Mark zeroconf.services as protected by renaming to zeroconf._services (#583) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`4a88066`](https://github.com/python-zeroconf/python-zeroconf/commit/4a88066d66b2f2a00ebc388c5cda478c52cb9e6c)) - -* Mark zeroconf.utils as protected by renaming to zeroconf._utils (#582) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`cc5bc36`](https://github.com/python-zeroconf/python-zeroconf/commit/cc5bc36f6f7597a0adb0d637147c2f93ca243ff4)) - -* Mark zeroconf.cache as protected by renaming to zeroconf._cache (#581) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`a16e85b`](https://github.com/python-zeroconf/python-zeroconf/commit/a16e85b20c2069aa9cee0510c618cb61d46dc19c)) - -* Mark zeroconf.exceptions as protected by renaming to zeroconf._exceptions (#580) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`241700a`](https://github.com/python-zeroconf/python-zeroconf/commit/241700a07a76a8c45afbe1bdd8325cd9f0eb0168)) - -* Fix flakey backoff test race on startup (#579) ([`dd9ada7`](https://github.com/python-zeroconf/python-zeroconf/commit/dd9ada781fdb1d5efc7c6ad194426e92550245b1)) - -* Mark zeroconf.logger as protected by renaming to zeroconf._logger (#578) ([`500066f`](https://github.com/python-zeroconf/python-zeroconf/commit/500066f940aa89737f343976ee0387eae97eac37)) - -* Mark zeroconf.handlers as protected by renaming to zeroconf._handlers (#577) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`1a2ee68`](https://github.com/python-zeroconf/python-zeroconf/commit/1a2ee6892e996c1e84ba97082e5cda609d1d55d7)) - -* Log zeroconf.asyncio deprecation warning with the logger module (#576) ([`c29a235`](https://github.com/python-zeroconf/python-zeroconf/commit/c29a235eb59ed3b4883305cf11f8bf9fa06284d3)) - -* Mark zeroconf.core as protected by renaming to zeroconf._core (#575) ([`601e8f7`](https://github.com/python-zeroconf/python-zeroconf/commit/601e8f70499638a6f24291bc0a28054fd78243c0)) - -* Mark zeroconf.dns as protected by renaming to zeroconf._dns (#574) - -- The public API should only access zeroconf and zeroconf.aio - as internals may be relocated between releases ([`0e61b15`](https://github.com/python-zeroconf/python-zeroconf/commit/0e61b1502c7fd3412f979bc4d651ee016e712de9)) - -* Update changelog (#573) ([`f10a562`](https://github.com/python-zeroconf/python-zeroconf/commit/f10a562471ad89527e6eef6ba935a27177bb1417)) - -* Relocate services tests to test_services (#570) ([`ae552e9`](https://github.com/python-zeroconf/python-zeroconf/commit/ae552e94732568fd798e1f2d0e811849edff7790)) - -* Remove DNSOutgoing.packet backwards compatibility (#569) - -- DNSOutgoing.packet only returned a partial message when the - DNSOutgoing contents exceeded _MAX_MSG_ABSOLUTE or _MAX_MSG_TYPICAL - This was a legacy function that was replaced with .packets() - which always returns a complete payload in #248 As packet() - should not be used since it will end up missing data, it has - been removed ([`1e7c074`](https://github.com/python-zeroconf/python-zeroconf/commit/1e7c07481bb0cd08fe492dab02be888c6a1dadf2)) - -* Breakout DNSCache into zeroconf.cache (#568) ([`0e0bc2a`](https://github.com/python-zeroconf/python-zeroconf/commit/0e0bc2a901ed1d64e357c63e9fb8655f3a6e9298)) - -* Removed protected imports from zeroconf namespace (#567) - -- These protected items are not intended to be part of the - public API ([`a8420cd`](https://github.com/python-zeroconf/python-zeroconf/commit/a8420cde192647486eba4da4e54df9d0fe65adba)) - -* Update setup.py for utils and services (#562) ([`7807fa0`](https://github.com/python-zeroconf/python-zeroconf/commit/7807fa0dfdab20d950c446f17b7233a8c65cbab1)) - -* Move additional dns tests to test_dns (#561) ([`ae1ce09`](https://github.com/python-zeroconf/python-zeroconf/commit/ae1ce092de7eb4797da0f56e9eb8e538c95a8cc1)) - -* Move exceptions tests to test_exceptions (#560) ([`b5d848d`](https://github.com/python-zeroconf/python-zeroconf/commit/b5d848de1ed95c55f8c262bcf0811248818da901)) - -* Move additional tests to test_core (#559) ([`eb37f08`](https://github.com/python-zeroconf/python-zeroconf/commit/eb37f089579fdc5a405dbc2f0ce5620cf9d1b011)) - -* Relocate additional dns tests to test_dns (#558) ([`18b9d0a`](https://github.com/python-zeroconf/python-zeroconf/commit/18b9d0a8bd07c0a0d2923763a5f131905c31e0df)) - -* Relocate dns tests to test_dns (#557) ([`f0d99e2`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d99e2e68791376a8517254338c708a3244f178)) - -* Relocate some of the services tests to test_services (#556) ([`715cd9a`](https://github.com/python-zeroconf/python-zeroconf/commit/715cd9a1d208139862e6d9d718114e1e472efd28)) - -* Fix invalid typing in ServiceInfo._set_text (#554) ([`3d69656`](https://github.com/python-zeroconf/python-zeroconf/commit/3d69656c4e5fbd8f90d54826877a04120d5ec951)) - -* Add missing coverage for ipv6 network utils (#555) ([`3dfda64`](https://github.com/python-zeroconf/python-zeroconf/commit/3dfda644efef83640e80876e4fe7da10e87b5990)) - -* Move ZeroconfServiceTypes to zeroconf.services.types (#553) ([`e50b62b`](https://github.com/python-zeroconf/python-zeroconf/commit/e50b62bb633916d5b84df7bcf7a804c9e3ef7fc2)) - -* Add recipe for TYPE_CHECKING to .coveragerc (#552) ([`e7fb4e5`](https://github.com/python-zeroconf/python-zeroconf/commit/e7fb4e5fb2a6b2163b143a63e2a9e8c5d1eca482)) - -* Move QueryHandler and RecordManager handlers into zeroconf.handlers (#551) ([`5b489e5`](https://github.com/python-zeroconf/python-zeroconf/commit/5b489e5b15ff89a0ffc000ccfeab2a8af346a65e)) - -* Move ServiceListener to zeroconf.services (#550) ([`ffdc988`](https://github.com/python-zeroconf/python-zeroconf/commit/ffdc9887ede1f867c155743b344efc53e0ceee42)) - -* Move the ServiceRegistry into its own module (#549) ([`4086fb4`](https://github.com/python-zeroconf/python-zeroconf/commit/4086fb4304b0653153865306e46c865c90137922)) - -* Move ServiceStateChange to zeroconf.services (#548) ([`c8a0a71`](https://github.com/python-zeroconf/python-zeroconf/commit/c8a0a71c31252bbc4a242701bc786eb419e1a8e8)) - -* Relocate core functions into zeroconf.core (#547) ([`bf0e867`](https://github.com/python-zeroconf/python-zeroconf/commit/bf0e867ead1e48e05a27fe8db69900d9dc387ea2)) - -* Breakout service classes into zeroconf.services (#544) ([`bdea21c`](https://github.com/python-zeroconf/python-zeroconf/commit/bdea21c0a61b6d9d0af3810f18dbc2fc2364c484)) - -* Move service_type_name to zeroconf.utils.name (#543) ([`b4814f5`](https://github.com/python-zeroconf/python-zeroconf/commit/b4814f5f216cd4072bafdd7dd1e68ee522f329c2)) - -* Relocate DNS classes to zeroconf.dns (#541) ([`1e3e7df`](https://github.com/python-zeroconf/python-zeroconf/commit/1e3e7df8b7fdacd90cf5d864411e5db5a915be94)) - -* Update zeroconf.aio import locations (#539) ([`8733cad`](https://github.com/python-zeroconf/python-zeroconf/commit/8733cad2eae71ebdf94ecadc6fd5439882477235)) - -* Move int2byte to zeroconf.utils.struct (#540) ([`6af42b5`](https://github.com/python-zeroconf/python-zeroconf/commit/6af42b54640ebba541302bfcf7688b3926453b15)) - -* Breakout network utils into zeroconf.utils.net (#537) ([`5af3eb5`](https://github.com/python-zeroconf/python-zeroconf/commit/5af3eb58bfdc1736e6db175c4c6f7c6f2c05b694)) - -* Move time utility functions into zeroconf.utils.time (#536) ([`7ff810a`](https://github.com/python-zeroconf/python-zeroconf/commit/7ff810a02e608fae39634be09d6c3ce0a93485b8)) - -* Avoid making DNSOutgoing aware of the Zeroconf object (#535) - -- This is not a breaking change since this code has not - yet shipped ([`2976cc2`](https://github.com/python-zeroconf/python-zeroconf/commit/2976cc2001cbba2c0afc57b9a3d301f382ddac8a)) - -* Add missing coverage for QuietLogger (#534) ([`328c1b9`](https://github.com/python-zeroconf/python-zeroconf/commit/328c1b9acdcd5cafa2df3e5b4b833b908d299500)) - -* Move logger into zeroconf.logger (#533) ([`e2e4eed`](https://github.com/python-zeroconf/python-zeroconf/commit/e2e4eede9117827f47c66a4852dd2d236b46ecda)) - -* Move exceptions into zeroconf.exceptions (#532) ([`5100506`](https://github.com/python-zeroconf/python-zeroconf/commit/5100506f896b649e6a6a8e2efb592362cd2644d3)) - -* Move constants into const.py (#531) ([`89d4755`](https://github.com/python-zeroconf/python-zeroconf/commit/89d4755106a6c3bced395b0a26eb3082c1268fa1)) - -* Move asyncio utils into zeroconf.utils.aio (#530) ([`2d8a27a`](https://github.com/python-zeroconf/python-zeroconf/commit/2d8a27a54aee298af74121986b4ea76f1f50b421)) - -* Relocate tests to tests directory (#527) ([`3f1a5a7`](https://github.com/python-zeroconf/python-zeroconf/commit/3f1a5a7b7a929d5f699812a809347b0c2f799fbf)) - -* Fix flakey test_update_record test (round 2) (#528) ([`14542bd`](https://github.com/python-zeroconf/python-zeroconf/commit/14542bd2bd327fd9b3d93cfb48a3bf09d6c89e15)) - -* Move ipversion auto detection code into its own function (#524) ([`16d40b5`](https://github.com/python-zeroconf/python-zeroconf/commit/16d40b50ccab6a8d53fe4aeb7b0006f7fd67ef53)) - -* Fix flakey test_update_record (#525) - -- Ensure enough time has past that the first record update - was processed before sending the second one ([`f49342c`](https://github.com/python-zeroconf/python-zeroconf/commit/f49342cdaff2d012ad23635b49ae746ad71333df)) - -* Update python compatibility as PyPy3 7.2 is required (#523) - -- When the version requirement changed to cpython 3.6, PyPy - was not bumped as well ([`b37d115`](https://github.com/python-zeroconf/python-zeroconf/commit/b37d115a233b61e2989d1439f65cdd911b86f407)) - -* Make the cache cleanup interval a constant (#522) ([`7ce29a2`](https://github.com/python-zeroconf/python-zeroconf/commit/7ce29a2f736af13886aa66dc1c49e15768e6fdcc)) - -* Add test helper to inject DNSIncoming (#518) ([`ef7aa25`](https://github.com/python-zeroconf/python-zeroconf/commit/ef7aa250e140d70b8c62abf4d13dcaa36f128c63)) - -* Remove broad exception catch from RecordManager.remove_listener (#517) ([`e125239`](https://github.com/python-zeroconf/python-zeroconf/commit/e12523933819087d2a087b8388e79b24af058a58)) - -* Small cleanups to RecordManager.add_listener (#516) ([`f80a051`](https://github.com/python-zeroconf/python-zeroconf/commit/f80a0515cf73b1e304d0615f8cee91ae38ac1ae8)) - -* Move RecordUpdateListener management into RecordManager (#514) ([`6cc3adb`](https://github.com/python-zeroconf/python-zeroconf/commit/6cc3adb020115ef9626caf61bb5f7550a2da8b4c)) - -* Update changelog (#513) ([`3d6c682`](https://github.com/python-zeroconf/python-zeroconf/commit/3d6c68278713a2ca66e27938feedcc451a078369)) - -* Break out record updating into RecordManager (#512) ([`9a766a2`](https://github.com/python-zeroconf/python-zeroconf/commit/9a766a2a96abd0f105056839b5c30f2ede31ea2e)) - -* Remove uneeded wait in the Engine thread (#511) - -- It is not longer necessary to wait since the socketpair - was added in #243 which will cause the select to unblock - when a new socket is added or removed. ([`70b455b`](https://github.com/python-zeroconf/python-zeroconf/commit/70b455ba53ce43e9280c02612e8a89665abd57f6)) - -* Stop monkey patching send in the TTL test (#510) ([`954ca3f`](https://github.com/python-zeroconf/python-zeroconf/commit/954ca3fb498bdc7cd5a6a168c40ad5b6b2476e71)) - -* Stop monkey patching send in the PTR optimization test (#509) ([`db866f7`](https://github.com/python-zeroconf/python-zeroconf/commit/db866f7d032ed031e6aa5e14fba24b3dafeafa8d)) - -* Extract code for handling queries into QueryHandler (#507) ([`1cfcc56`](https://github.com/python-zeroconf/python-zeroconf/commit/1cfcc5636a845924eb683ad4acf4d9a36ef85fb7)) - -* Update changelog for zeroconf.asyncio -> zeroconf.aio (#506) ([`26b7005`](https://github.com/python-zeroconf/python-zeroconf/commit/26b70050ffe7dee4fb34428f285be377d1d8f210)) - -* Rename zeroconf.asyncio to zeroconf.aio (#503) - -- The asyncio name could shadow system asyncio in some cases. If - zeroconf is in sys.path, this would result in loading zeroconf.asyncio - when system asyncio was intended. - -- An `zeroconf.asyncio` shim module has been added that imports `zeroconf.aio` - that was available in 0.31 to provide backwards compatibility in 0.32. - This module will be removed in 0.33 to fix the underlying problem - detailed in #502 ([`bfca3b4`](https://github.com/python-zeroconf/python-zeroconf/commit/bfca3b46fd9a395f387bd90b68c523a3ca84bde4)) - -* Update changelog, move breaking changes to the top of the list (#501) ([`9b480bc`](https://github.com/python-zeroconf/python-zeroconf/commit/9b480bc1abb2c2702f60796f2edae76ce03ca5d4)) - -* Set the TC bit for query packets where the known answers span multiple packets (#494) ([`f04a2eb`](https://github.com/python-zeroconf/python-zeroconf/commit/f04a2eb43745eba7c43c9c56179ed1fceb992bd8)) - -* Ensure packets are properly seperated when exceeding maximum size (#498) - -- Ensure that questions that exceed the max packet size are - moved to the next packet. This fixes DNSQuestions being - sent in multiple packets in violation of: - https://datatracker.ietf.org/doc/html/rfc6762#section-7.2 - -- Ensure only one resource record is sent when a record - exceeds _MAX_MSG_TYPICAL - https://datatracker.ietf.org/doc/html/rfc6762#section-17 ([`e2908c6`](https://github.com/python-zeroconf/python-zeroconf/commit/e2908c6c89802ba7a0ea51ac351da40bce3f1cb6)) - -* Make a base class for DNSIncoming and DNSOutgoing (#497) ([`38e4b42`](https://github.com/python-zeroconf/python-zeroconf/commit/38e4b42b847e700db52bc51973210efc485d8c23)) - -* Update internal version check to match docs (3.6+) (#491) ([`20f8b3d`](https://github.com/python-zeroconf/python-zeroconf/commit/20f8b3d6fb8d117b0c3c794c4075a00e117e3f31)) - -* Remove unused __ne__ code from Python 2 era (#492) ([`f0c02a0`](https://github.com/python-zeroconf/python-zeroconf/commit/f0c02a02c1a2d7c914c62479bad4957b06471661)) - -* Lint before testing in the CI (#488) ([`69880ae`](https://github.com/python-zeroconf/python-zeroconf/commit/69880ae6ca4d4f0a7d476b0271b89adea92b9389)) - -* Add AsyncServiceBrowser example (#487) ([`ef9334f`](https://github.com/python-zeroconf/python-zeroconf/commit/ef9334f1279d029752186bc6f4a1ebff6229bf5b)) - -* Move threading daemon property into ServiceBrowser class (#486) ([`275765a`](https://github.com/python-zeroconf/python-zeroconf/commit/275765a4fd3b477b79163c04f6411709e14506b9)) - -* Enable test_integration_with_listener_class test on PyPy (#485) ([`49db96d`](https://github.com/python-zeroconf/python-zeroconf/commit/49db96dae466a602662f4fde1537f62a8c8d3110)) - -* RecordUpdateListener now uses update_records instead of update_record (#419) ([`0a69aa0`](https://github.com/python-zeroconf/python-zeroconf/commit/0a69aa0d37e13cb2c65ceb5cc3ab0fd7e9d34b22)) - -* AsyncServiceBrowser must recheck for handlers to call when holding condition (#483) - -- There was a short race condition window where the AsyncServiceBrowser - could add to _handlers_to_call in the Engine thread, have the - condition notify_all called, but since the AsyncServiceBrowser was - not yet holding the condition it would not know to stop waiting - and process the handlers to call. ([`9606936`](https://github.com/python-zeroconf/python-zeroconf/commit/960693628006e23fd13fcaefef915ca0c84401b9)) - -* Relocate ServiceBrowser wait time calculation to seperate function (#484) - -- Eliminate the need to duplicate code between the ServiceBrowser - and AsyncServiceBrowser to calculate the wait time. ([`9c06ce1`](https://github.com/python-zeroconf/python-zeroconf/commit/9c06ce15db31ebffe3a556896393d48cb786b5d9)) - -* Switch from using an asyncio.Event to asyncio.Condition for waiting (#482) ([`393910b`](https://github.com/python-zeroconf/python-zeroconf/commit/393910b67ac667a660ee9351cc8f94310937f654)) - -* ServiceBrowser must recheck for handlers to call when holding condition (#477) ([`8da00ca`](https://github.com/python-zeroconf/python-zeroconf/commit/8da00caf31e007153e10a8038a0a484edea03c2f)) - -* Provide a helper function to convert milliseconds to seconds (#481) ([`849e9bc`](https://github.com/python-zeroconf/python-zeroconf/commit/849e9bc792c6cc77b879b4761195192bea1720ce)) - -* Fix AsyncServiceInfo.async_request not waiting long enough (#480) - -- The call to async_wait should have been in milliseconds, but - the time was being passed in seconds which resulted in waiting - 1000x shorter ([`b0c0cdc`](https://github.com/python-zeroconf/python-zeroconf/commit/b0c0cdc6779dc095cf03ebd92652af69800b7bca)) - -* Add support for updating multiple records at once to ServiceInfo (#474) - -- Adds `update_records` method to `ServiceInfo` ([`ed53f62`](https://github.com/python-zeroconf/python-zeroconf/commit/ed53f6283265eb8fb506d4af8fb31bd4eaa7292b)) - -* Narrow exception catch in DNSAddress.__repr__ to only expected exceptions (#473) ([`b853413`](https://github.com/python-zeroconf/python-zeroconf/commit/b8534130ec31a6be191fcc60615ab2fd02fd8d7a)) - -* Add test coverage to ensure ServiceInfo rejects expired records (#468) ([`d0f5a60`](https://github.com/python-zeroconf/python-zeroconf/commit/d0f5a60275ccf810407055c63ca9080fa6654443)) - -* Reduce branching in service_type_name (#472) ([`00af5ad`](https://github.com/python-zeroconf/python-zeroconf/commit/00af5adc4be76afd23135d37653119f45c57a531)) - -* Fix flakey test_update_record (#470) ([`1eaeef2`](https://github.com/python-zeroconf/python-zeroconf/commit/1eaeef2d6f07efba67e91699529f8361226233ce)) - -* Reduce branching in Zeroconf.handle_response (#467) - -- Adds `add_records` and `remove_records` to `DNSCache` to - permit multiple records to be added or removed in one call - -- This change is not enough to remove the too-many-branches - pylint disable, however when combined with #419 it should - no longer be needed ([`8a9ae29`](https://github.com/python-zeroconf/python-zeroconf/commit/8a9ae29b6f6643f3625938ac44df66dcc556de46)) - -* Ensure PTR questions asked in uppercase are answered (#465) ([`7a50402`](https://github.com/python-zeroconf/python-zeroconf/commit/7a5040247cbaad6bed3fc1204820dfc31ed9b0ae)) - -* Clear cache between ServiceTypesQuery tests (#466) - -- Ensures the test relies on the ZeroconfServiceTypes.find making - the correct calls instead of the cache from the previous call ([`c3365e1`](https://github.com/python-zeroconf/python-zeroconf/commit/c3365e1fd060cebc63cc42443260bd785077c246)) - -* Break apart Zeroconf.handle_query to reduce branching (#462) ([`c1ed987`](https://github.com/python-zeroconf/python-zeroconf/commit/c1ed987ede34b0049e6466e673b1629d7cd0cd6a)) - -* Support for context managers in Zeroconf and AsyncZeroconf (#284) - -Co-authored-by: J. Nick Koston ([`4c4b529`](https://github.com/python-zeroconf/python-zeroconf/commit/4c4b529c841f015108a7489bd8f3b92a5e57e827)) - -* Use constant for service type enumeration (#461) ([`558cec3`](https://github.com/python-zeroconf/python-zeroconf/commit/558cec3687ac7e7f494ab7aa4ce574c1e784b81f)) - -* Reduce branching in Zeroconf.handle_response (#459) ([`ceb0def`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb0def1b43f2e55bb17e33d13d4efdaa055221c)) - -* Reduce branching in Zeroconf.handle_query (#460) ([`5e24da0`](https://github.com/python-zeroconf/python-zeroconf/commit/5e24da08bc463bf79b27eb3768ec01755804f403)) - -* Enable pylint (#438) ([`6fafdee`](https://github.com/python-zeroconf/python-zeroconf/commit/6fafdee241571d68937e29ee0a2b1bd5ef0038d9)) - -* Trap OSError directly in Zeroconf.send instead of checking isinstance (#453) - -- Fixes: Instance of 'Exception' has no 'errno' member (no-member) ([`9510808`](https://github.com/python-zeroconf/python-zeroconf/commit/9510808cfd334b0b2f6381da8214225c4cfbf6a0)) - -* Disable protected-access on the ServiceBrowser usage of _handlers_lock (#452) - -- This will be fixed in https://github.com/jstasiak/python-zeroconf/pull/419 ([`69c4cf6`](https://github.com/python-zeroconf/python-zeroconf/commit/69c4cf69bbc34474e70eac3ad0fe905be7ab4eb4)) - -* Mark functions with too many branches in need of refactoring (#455) ([`5fce89d`](https://github.com/python-zeroconf/python-zeroconf/commit/5fce89db2707b163231aec216e4c4fc310527e4c)) - -* Disable pylint no-self-use check on abstract methods (#451) ([`7544cdf`](https://github.com/python-zeroconf/python-zeroconf/commit/7544cdf956c4eeb4b688729432ba87278f606b7c)) - -* Use unique name in test_async_service_browser test (#450) ([`f26a92b`](https://github.com/python-zeroconf/python-zeroconf/commit/f26a92bc2abe61f5a2b5acd76991f81d07452201)) - -* Disable no-member check for WSAEINVAL false positive (#454) ([`ef0cf8e`](https://github.com/python-zeroconf/python-zeroconf/commit/ef0cf8e393a8ffdccb3cd2094a8764f707f518c1)) - -* Mark methods used by asyncio without self use (#447) ([`7e03f83`](https://github.com/python-zeroconf/python-zeroconf/commit/7e03f836dd7a4ee938bfff21cd150e863f608b5e)) - -* Extract _get_queue from _AsyncSender (#444) ([`18851ed`](https://github.com/python-zeroconf/python-zeroconf/commit/18851ed4c0f605996798472e1a68dded16d41ff6)) - -* Add missing update_service method to ZeroconfServiceTypes (#449) ([`ffc6cbb`](https://github.com/python-zeroconf/python-zeroconf/commit/ffc6cbb94d7401a70ebd6f747ed6c5e56e528bb0)) - -* Fix redefining argument with the local name 'record' in ServiceInfo.update_record (#448) ([`929ba12`](https://github.com/python-zeroconf/python-zeroconf/commit/929ba12d046496782491d96160e6cb8d0d04cfe5)) - -* Remove unneeded-not in new_socket (#445) ([`424c002`](https://github.com/python-zeroconf/python-zeroconf/commit/424c00257083f1d091a52ff0c966b306eea70efb)) - -* Disable broad except checks in places we still catch broad exceptions (#443) ([`6002c9c`](https://github.com/python-zeroconf/python-zeroconf/commit/6002c9c88a9a49814f86070c07925f798a61461a)) - -* Merge _TYPE_CNAME and _TYPE_PTR comparison in DNSIncoming.read_others (#442) ([`41be4f4`](https://github.com/python-zeroconf/python-zeroconf/commit/41be4f4db0501adb9fbaa6b353fbcb36a45e6e21)) - -* Convert unnecessary use of a comprehension to a list (#441) ([`a70370a`](https://github.com/python-zeroconf/python-zeroconf/commit/a70370a0f653df911cc6f641522cec0fcc8471a3)) - -* Remove unused now argument from ServiceInfo._process_record (#440) ([`594da70`](https://github.com/python-zeroconf/python-zeroconf/commit/594da709273c2e0a53fee2f9ad7fcec607ad0868)) - -* Disable pylint too-many-branches for functions that need refactoring (#439) ([`4bcb698`](https://github.com/python-zeroconf/python-zeroconf/commit/4bcb698bda0ec7266d5e454b5e81a07eb64be32a)) - -* Cleanup unused variables (#437) ([`8412eb7`](https://github.com/python-zeroconf/python-zeroconf/commit/8412eb791dd5ad1c287c1d7cc24c5db75a5291b7)) - -* Cleanup unnecessary else after returns (#436) ([`1d3f986`](https://github.com/python-zeroconf/python-zeroconf/commit/1d3f986e00e18682c209cecbdea2481f4ca987b5)) - -* Update changelog for latest changes (#435) ([`6737e13`](https://github.com/python-zeroconf/python-zeroconf/commit/6737e13d8e6227b96d5cc0e776c62889b7dc4fd3)) - -* Add zeroconf.asyncio to the docs (#434) ([`5460cae`](https://github.com/python-zeroconf/python-zeroconf/commit/5460caef83b5cdb9c5d637741ed95dea6b328f08)) - -* Fix warning when generating sphinx docs (#432) - -- `docstring of zeroconf.ServiceInfo:5: WARNING: Unknown target name: "type".` ([`e5a0c9a`](https://github.com/python-zeroconf/python-zeroconf/commit/e5a0c9a45df93a668f3611ddf5c41a1800cb4556)) - -* Implement an AsyncServiceBrowser to compliment the sync ServiceBrowser (#429) ([`415a7b7`](https://github.com/python-zeroconf/python-zeroconf/commit/415a7b762030e9d236bef71f39156686a0b277f9)) - -* Seperate non-thread specific code from ServiceBrowser into _ServiceBrowserBase (#428) ([`e7b2bb5`](https://github.com/python-zeroconf/python-zeroconf/commit/e7b2bb5e351f04f4f1e14ef5a20ed2111f8097c4)) - -* Remove is_type_unique as it is unused (#426) ([`e68e337`](https://github.com/python-zeroconf/python-zeroconf/commit/e68e337cd482e06a422b2d2e2e6ae12ce1673ce5)) - -* Avoid checking the registry when answering requests for _services._dns-sd._udp.local. (#425) - -- _services._dns-sd._udp.local. is a special case and should never - be in the registry ([`47e266e`](https://github.com/python-zeroconf/python-zeroconf/commit/47e266eb66be36b355f1738cd4d2f7369712b7b3)) - -* Remove unused argument from ServiceInfo.dns_addresses (#423) - -- This should always return all addresses since its _CLASS_UNIQUE ([`fc97e5c`](https://github.com/python-zeroconf/python-zeroconf/commit/fc97e5c3ad35da789373a1898c00efe0f13a3b5f)) - -* A methods to generate DNSRecords from ServiceInfo (#422) ([`41de419`](https://github.com/python-zeroconf/python-zeroconf/commit/41de419453c0679c5a04ec248339783afbeb0e4f)) - -* Seperate logic for consuming records in ServiceInfo (#421) ([`8bca030`](https://github.com/python-zeroconf/python-zeroconf/commit/8bca0305deae0db8ced7e213be3aaee975985c56)) - -* Seperate query generation for ServiceBrowser (#420) ([`58cfcf0`](https://github.com/python-zeroconf/python-zeroconf/commit/58cfcf0c902b5e27937f118bf4f7a855db635301)) - -* Add async_request example with browse (#415) ([`7f08826`](https://github.com/python-zeroconf/python-zeroconf/commit/7f08826c03b7997758ff0236834bf6f1a091c558)) - -* Add async_register_service/async_unregister_service example (#414) ([`71cfbcb`](https://github.com/python-zeroconf/python-zeroconf/commit/71cfbcb85bdd5948f1b96a871b10e9e35ab76c3b)) - -* Update changelog for 0.32.0 (#411) ([`bb83edf`](https://github.com/python-zeroconf/python-zeroconf/commit/bb83edfbca339fb6ec20b821d79b171220f5e675)) - -* Add async_get_service_info to AsyncZeroconf and async_request to AsyncServiceInfo (#408) ([`0fa049c`](https://github.com/python-zeroconf/python-zeroconf/commit/0fa049c2e0f5e9f18830583a8df2736630c891e2)) - -* Add async_wait function to AsyncZeroconf (#410) ([`53306e1`](https://github.com/python-zeroconf/python-zeroconf/commit/53306e1b99d9133590d47081994ee77cef468828)) - -* Add support for registering notify listeners (#409) - -- Notify listeners will be used by AsyncZeroconf to set - asyncio.Event objects when new data is received - -- Registering a notify listener: - notify_listener = YourNotifyListener() - Use zeroconf.add_notify_listener(notify_listener) - -- Unregistering a notify listener: - Use zeroconf.remove_notify_listener(notify_listener) - -- Notify listeners must inherit from the NotifyListener - class ([`745087b`](https://github.com/python-zeroconf/python-zeroconf/commit/745087b234dd5ff65b4b041a7221d58030a69cdd)) - -* Remove unreachable code in ServiceInfo.get_name (#407) ([`ff31f38`](https://github.com/python-zeroconf/python-zeroconf/commit/ff31f386273fbe9fd0b466bbe5f724c815745215)) - -* Allow passing in a sync Zeroconf instance to AsyncZeroconf (#406) - -- Uses the same pattern as ZeroconfServiceTypes.find ([`2da6198`](https://github.com/python-zeroconf/python-zeroconf/commit/2da6198b2e60a598580637e80b3bd579c1f845a5)) - -* Use a dedicated thread for sending outgoing packets with asyncio (#404) - -- Sends now go into a queue and are processed by the thread FIFO - -- Avoids overwhelming the executor when registering multiple - services in parallel ([`1e7b46c`](https://github.com/python-zeroconf/python-zeroconf/commit/1e7b46c36f6e0735b44d3edd9740891a2dc0c761)) - -* Seperate query generation for Zeroconf (#403) - -- Will be used to send the query in asyncio ([`e753078`](https://github.com/python-zeroconf/python-zeroconf/commit/e753078f0345fa28ffceb8de69542c8549d2994c)) - -* Seperate query generation in ServiceInfo (#401) ([`bddf69c`](https://github.com/python-zeroconf/python-zeroconf/commit/bddf69c0839eda966376987a8c4a1fbe3d865529)) - -* Remove unreachable code in ServiceInfo (part 2) (#402) - -- self.server is never None ([`4ae27be`](https://github.com/python-zeroconf/python-zeroconf/commit/4ae27beba29c6e9ac1782f40eadda584b4722af7)) - -* Remove unreachable code in ServiceInfo (#400) - -- self.server is never None ([`dd63835`](https://github.com/python-zeroconf/python-zeroconf/commit/dd6383589b161e828def0ed029519a645e434512)) - -* Update changelog with latest changes (#394) ([`a6010a9`](https://github.com/python-zeroconf/python-zeroconf/commit/a6010a94b626a9a1585cc47417c08516020729d7)) - -* Add test coverage for multiple AAAA records (#391) ([`acf174d`](https://github.com/python-zeroconf/python-zeroconf/commit/acf174db93ee60f1a80d501eb691d9cb434a90b7)) - -* Enable IPv6 in the CI (#393) ([`ec2fafd`](https://github.com/python-zeroconf/python-zeroconf/commit/ec2fafd904cd2d341a3815fcf6d34508dcddda5a)) - -* Fix IPv6 setup under MacOS when binding to "" (#392) - -- Setting IP_MULTICAST_TTL and IP_MULTICAST_LOOP does not work under - MacOS when the bind address is "" ([`d67d5f4`](https://github.com/python-zeroconf/python-zeroconf/commit/d67d5f41effff4c01735de0ae64ed25a5dbe7567)) - -* Update changelog for 0.32.0 (Unreleased) (#390) ([`33a3a6a`](https://github.com/python-zeroconf/python-zeroconf/commit/33a3a6ae42ef8c4ea0f606ad2a02df3f6bc13752)) - -* Ensure ZeroconfServiceTypes.find always cancels the ServiceBrowser (#389) ([`8f4d2e8`](https://github.com/python-zeroconf/python-zeroconf/commit/8f4d2e858a5efadeb33120322c1169f3ce7d6e0c)) - -* Fix flapping test: test_update_record (#388) ([`ba8d8e3`](https://github.com/python-zeroconf/python-zeroconf/commit/ba8d8e3e658c71e0d603db3f4c5bdfe8e508710a)) - -* Simplify DNSPointer processing in ServiceBrowser (#386) ([`709bd9a`](https://github.com/python-zeroconf/python-zeroconf/commit/709bd9abae63cf566220693501cd37cf74391ccf)) - -* Ensure listeners do not miss initial packets if Engine starts too quickly (#387) ([`62a02d7`](https://github.com/python-zeroconf/python-zeroconf/commit/62a02d774fd874340fa3043bd3bf260a77ffe3d8)) - -* Update changelog with latest commits (#384) ([`69d9357`](https://github.com/python-zeroconf/python-zeroconf/commit/69d9357b3dae7a99d302bf4ad71d4ed45cbe3e42)) - -* Ensure the cache is checked for name conflict after final service query with asyncio (#382) - -- The check was not happening after the last query ([`5057f97`](https://github.com/python-zeroconf/python-zeroconf/commit/5057f97b9b724c041d2bee65972fe3637bf04f0b)) - -* Fix multiple unclosed instances in tests (#383) ([`69a79b9`](https://github.com/python-zeroconf/python-zeroconf/commit/69a79b9fd48a24d311520e228c78b2aae52d1dd5)) - -* Update changelog with latest merges (#381) ([`2b502bc`](https://github.com/python-zeroconf/python-zeroconf/commit/2b502bc2e21efa2f840c42ed79f850b276a8c103)) - -* Complete ServiceInfo request as soon as all questions are answered (#380) - -- Closes a small race condition where there were no questions - to ask because the cache was populated in between checks ([`3afa5c1`](https://github.com/python-zeroconf/python-zeroconf/commit/3afa5c13f2be956505428c5b01f6ce507845131a)) - -* Coalesce browser questions scheduled at the same time (#379) - -- With multiple types, the ServiceBrowser questions can be - chatty because it would generate a question packet for - each type. If multiple types are due to be requested, - try to combine the questions into a single outgoing - packet(s) ([`60c1895`](https://github.com/python-zeroconf/python-zeroconf/commit/60c1895e67a6147ab8c6ba7d21d4fe5adec3e590)) - -* Bump version to 0.31.0 to match released version (#378) ([`23442d2`](https://github.com/python-zeroconf/python-zeroconf/commit/23442d2e5a0336a64646cb70f2ce389746744ce0)) - -* Update changelog with latest merges (#377) ([`5535ea8`](https://github.com/python-zeroconf/python-zeroconf/commit/5535ea8c365557681721fdafdcabfc342c75daf5)) - -* Ensure duplicate packets do not trigger duplicate updates (#376) - -- If TXT or SRV records update was already processed and then - recieved again, it was possible for a second update to be - called back in the ServiceBrowser ([`b158b1c`](https://github.com/python-zeroconf/python-zeroconf/commit/b158b1cff31620d5cf27969e475d788332f4b38c)) - -* Only trigger a ServiceStateChange.Updated event when an ip address is added (#375) ([`5133742`](https://github.com/python-zeroconf/python-zeroconf/commit/51337425c9be08d59d496c6783d07d5e4e2382d4)) - -* Fix RFC6762 Section 10.2 paragraph 2 compliance (#374) ([`03f2eb6`](https://github.com/python-zeroconf/python-zeroconf/commit/03f2eb688859a78807305771d04b216e20e72064)) - -* Reduce length of ServiceBrowser thread name with many types (#373) - -- Before - -"zeroconf-ServiceBrowser__ssh._tcp.local.-_enphase-envoy._tcp.local.-_hap._udp.local." -"-_nut._tcp.local.-_Volumio._tcp.local.-_kizbox._tcp.local.-_home-assistant._tcp.local." -"-_viziocast._tcp.local.-_dvl-deviceapi._tcp.local.-_ipp._tcp.local.-_touch-able._tcp.local." -"-_hap._tcp.local.-_system-bridge._udp.local.-_dkapi._tcp.local.-_airplay._tcp.local." -"-_elg._tcp.local.-_miio._udp.local.-_wled._tcp.local.-_esphomelib._tcp.local." -"-_ipps._tcp.local.-_fbx-api._tcp.local.-_xbmc-jsonrpc-h._tcp.local.-_powerview._tcp.local." -"-_spotify-connect._tcp.local.-_leap._tcp.local.-_api._udp.local.-_plugwise._tcp.local." -"-_googlecast._tcp.local.-_printer._tcp.local.-_axis-video._tcp.local.-_http._tcp.local." -"-_mediaremotetv._tcp.local.-_homekit._tcp.local.-_bond._tcp.local.-_daap._tcp.local._243" - -- After - -"zeroconf-ServiceBrowser-_miio._udp-_mediaremotetv._tcp-_dvl-deviceapi._tcp-_ipp._tcp" -"-_dkapi._tcp-_hap._udp-_xbmc-jsonrpc-h._tcp-_hap._tcp-_googlecast._tcp-_airplay._tcp" -"-_viziocast._tcp-_api._udp-_kizbox._tcp-_spotify-connect._tcp-_home-assistant._tcp" -"-_bond._tcp-_powerview._tcp-_daap._tcp-_http._tcp-_leap._tcp-_elg._tcp-_homekit._tcp" -"-_ipps._tcp-_plugwise._tcp-_ssh._tcp-_esphomelib._tcp-_Volumio._tcp-_fbx-api._tcp" -"-_wled._tcp-_touch-able._tcp-_enphase-envoy._tcp-_axis-video._tcp-_printer._tcp" -"-_system-bridge._udp-_nut._tcp-244" ([`5d4aa28`](https://github.com/python-zeroconf/python-zeroconf/commit/5d4aa2800d1196274cfdd0bf3e631f49ab5b78bd)) - -* Update changelog for 0.32.0 (unreleased) (#372) ([`82fb26f`](https://github.com/python-zeroconf/python-zeroconf/commit/82fb26f14518a8e59f886b8d7b0708a68725bf48)) - -* Remove Callable quoting (#371) - -- The current minimum supported cpython is 3.6+ which does not need - the quoting ([`7f45bef`](https://github.com/python-zeroconf/python-zeroconf/commit/7f45bef8db444b0436c5f80b4f4b31b2f1d7ec2f)) - -* Abstract check to see if a record matches a type the ServiceBrowser wants (#369) ([`4819ef8`](https://github.com/python-zeroconf/python-zeroconf/commit/4819ef8c97ddbbadcd6e7cf1b5fee36f573bde45)) - -* Reduce complexity of ServiceBrowser enqueue_callback (#368) - -- The handler key was by name, however ServiceBrowser can have multiple - types which meant the check to see if a state change was an add - remove, or update was overly complex. Reduce the complexity by - making the key (name, type_) ([`4657a77`](https://github.com/python-zeroconf/python-zeroconf/commit/4657a773690a34c897c80894a10ac33b6edadf8b)) - -* Fix empty answers being added in ServiceInfo.request (#367) ([`5a4c1e4`](https://github.com/python-zeroconf/python-zeroconf/commit/5a4c1e46510956276de117d86bee9d2ccb602802)) - -* Ensure ServiceInfo populates all AAAA records (#366) - -- Use get_all_by_details to ensure all records are loaded - into addresses. - -- Only load A/AAAA records from cache once in load_from_cache - if there is a SRV record present - -- Move duplicate code that checked if the ServiceInfo was complete - into its own function ([`bae3a9b`](https://github.com/python-zeroconf/python-zeroconf/commit/bae3a9b97672581e77255c4937b815173c8547b4)) - -* Remove black python 3.5 exception block (#365) ([`6d29e6c`](https://github.com/python-zeroconf/python-zeroconf/commit/6d29e6c93bdcf6cf31fcfa133258257704945dfc)) - -* Small cleanup of ServiceInfo.update_record (#364) - -- Return as record is not viable (None or expired) - -- Switch checks to isinstance since its needed by mypy anyways - -- Prepares for supporting multiple AAAA records (via https://github.com/jstasiak/python-zeroconf/pull/361) ([`1b8b291`](https://github.com/python-zeroconf/python-zeroconf/commit/1b8b2917e7e70e3996e9a96204dd5df3dfb39072)) - -* Add new cache function get_all_by_details (#363) - -- When working with IPv6, multiple AAAA records can exist - for a given host. get_by_details would only return the - latest record in the cache. - -- Fix a case where the cache list can change during - iteration ([`d8c3240`](https://github.com/python-zeroconf/python-zeroconf/commit/d8c32401ada4f430cd75617324b6d8ecd1dbe1f2)) - -* Small cleanups to asyncio tests (#362) ([`7e960b7`](https://github.com/python-zeroconf/python-zeroconf/commit/7e960b78cac8008beca9c5451c6d465e2674a050)) - -* Improve test coverage for name conflicts (#357) ([`c0674e9`](https://github.com/python-zeroconf/python-zeroconf/commit/c0674e97aee4f61212389337340fc8ff4472eb25)) - -* Return task objects created by AsyncZeroconf (#360) ([`8c1c394`](https://github.com/python-zeroconf/python-zeroconf/commit/8c1c394e9b4aa01e08a2c3e240396b533792be55)) - -* Separate cache loading from I/O in ServiceInfo (#356) - -Provides a load_from_cache method on ServiceInfo that does no I/O - -- When a ServiceBrowser is running for a type there is no need - to make queries on the network since the entries will already - be in the cache. When discovering many devices making queries - that will almost certainly fail for offline devices delays the - startup of online devices. - -- The DNSEntry and ServiceInfo classes were matching on the name - instead of the key (lowercase name). These classes now treat dns - names the same reguardless of case. - - https://datatracker.ietf.org/doc/html/rfc6762#section-16 - > The simple rules for case-insensitivity in Unicast DNS [RFC1034] - > [RFC1035] also apply in Multicast DNS; that is to say, in name - > comparisons, the lowercase letters "a" to "z" (0x61 to 0x7A) match - > their uppercase equivalents "A" to "Z" (0x41 to 0x5A). Hence, if a - > querier issues a query for an address record with the name - > "myprinter.local.", then a responder having an address record with - > the name "MyPrinter.local." should issue a response. ([`87ba2a3`](https://github.com/python-zeroconf/python-zeroconf/commit/87ba2a3960576cfcf4207ea74a711b2c0cc584a7)) - -* 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. ([`a41d7b8`](https://github.com/python-zeroconf/python-zeroconf/commit/a41d7b8aa5572f3faf29eb087cc18a1343bbcdfa)) - -* Eliminate the reaper thread (#349) - -- Cache is now purged between reads when the interval is reached - -- Reduce locking since we are already making a copy of the readers - and not reading under the lock - -- Simplify shutdown process ([`7816278`](https://github.com/python-zeroconf/python-zeroconf/commit/781627864efbb3c8285e1b75144d688083414cf3)) - -* Return early when already closed (#350) - -- Reduce indentation with a return early guard in close ([`523aefb`](https://github.com/python-zeroconf/python-zeroconf/commit/523aefb0b0c477489e4e1e4ab763ce56c57295b7)) - -* Skip socket creation if add_multicast_member fails (windows) (#341) - -Co-authored-by: Timothee 'TTimo' Besset ([`beccad1`](https://github.com/python-zeroconf/python-zeroconf/commit/beccad1f0b41730f541b2e90ea2eaa2496de5044)) - -* Simplify cache iteration (#340) - -- Remove the need to trap runtime error -- Only copy the names of the keys when iterating the cache -- Fixes RuntimeError: list changed size during iterating entries_from_name -- Cache services -- The Repear thread is no longer aware of the cache internals ([`fe94810`](https://github.com/python-zeroconf/python-zeroconf/commit/fe948105cc0923336ffa6d93cbe7d45470612a36)) - - -## v0.29.0 (2021-03-25) - -### Unknown - -* Release version 0.29.0 ([`203ec2e`](https://github.com/python-zeroconf/python-zeroconf/commit/203ec2e26e6f0f676e7d88b4a1b0c80ad74659f1)) - -* Fill a missing changelog entry ([`53cb804`](https://github.com/python-zeroconf/python-zeroconf/commit/53cb8044bfb4256f570d438817fd37acc8b78511)) - -* Make mypy configuration more lenient - -We want to be able to call untyped modules. ([`f871b90`](https://github.com/python-zeroconf/python-zeroconf/commit/f871b90d25c0f788590ceb14237b08a6b5e6eeeb)) - -* Silence a flaky test on PyPy ([`bc6ef8c`](https://github.com/python-zeroconf/python-zeroconf/commit/bc6ef8c65b22d982798104d5bdf11b78746a8ddd)) - -* Silence a mypy false-positive ([`6482da0`](https://github.com/python-zeroconf/python-zeroconf/commit/6482da05344e6ae8c4da440da4a704a20c344bb6)) - -* Switch from Travis CI/Coveralls to GH Actions/Codecov - -Travis CI free tier is going away and Codecov is my go-to code coverage -service now. - -Closes GH-332. ([`bd80d20`](https://github.com/python-zeroconf/python-zeroconf/commit/bd80d20682c0af5e15a4b7102dcfe814cdba3a01)) - -* Drop Python 3.5 compatibilty, it reached its end of life ([`ab67a7a`](https://github.com/python-zeroconf/python-zeroconf/commit/ab67a7aecd63042178061f0d1a76f9a7f6e1559a)) - -* Use a single socket for InterfaceChoice.Default - -When using multiple sockets with multi-cast, the outgoing -socket's responses could be read back on the incoming socket, -which leads to duplicate processing and could fill up the -incoming buffer before it could be processed. - -This behavior manifested with error similar to -`OSError: [Errno 105] No buffer space available` - -By using a single socket with InterfaceChoice.Default -we avoid this case. ([`6beefbb`](https://github.com/python-zeroconf/python-zeroconf/commit/6beefbbe76a0e261394b308c8cc68545be653019)) - -* Simplify read_name - -(venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result = "".join((result, "thisisaname" + "."))' -20000 loops, best of 5: 16.4 usec per loop -(venv) root@ha-dev:~/python-zeroconf# python3 -m timeit -s 'result=""' -u usec 'result += "thisisaname" + "."' -2000000 loops, best of 5: 0.105 usec per loop ([`5e268fa`](https://github.com/python-zeroconf/python-zeroconf/commit/5e268faeaa99f0a513c7bbeda8f447f4eb36a747)) - -* Fix link to readme md --> rst (#324) ([`c5a675d`](https://github.com/python-zeroconf/python-zeroconf/commit/c5a675d22788aa905a4e47feb1d4c30f30416356)) - - -## v0.28.8 (2021-01-04) - -### Unknown - -* Release version 0.28.8 ([`1d726b5`](https://github.com/python-zeroconf/python-zeroconf/commit/1d726b551a49e945b134df6e29b352697030c5a9)) - -* Ensure the name cache is rolled back when the packet reaches maximum size - -If the packet was too large, it would be rolled back at the end of write_record. -We need to remove the names that were added to the name cache (self.names) -as well to avoid a case were we would create a pointer to a name that was -rolled back. - -The size of the packet was incorrect at the end after the inserts because -insert_short would increase self.size even though it was already accounted -before. To resolve this insert_short_at_start was added which does not -increase self.size. This did not cause an actual bug, however it sure -made debugging this problem far more difficult. - -Additionally the size now inserted and then replaced when the actual -size is known because it made debugging quite difficult since the size -did not previously agree with the data. ([`86b4e11`](https://github.com/python-zeroconf/python-zeroconf/commit/86b4e11434d44e2f9a42354109a10f601c44d66a)) - - -## v0.28.7 (2020-12-13) - -### Unknown - -* Release version 0.28.7 ([`8f7effd`](https://github.com/python-zeroconf/python-zeroconf/commit/8f7effd2f89c542162d0e5ac257c561501690d16)) - -* Refactor to move service registration into a registry - -This permits removing the broad exception catch that -was expanded to avoid a crash in when adding or -removing a service ([`2708fef`](https://github.com/python-zeroconf/python-zeroconf/commit/2708fef6052f7e6e6eb36a157438b316e6d38b21)) - -* Prevent crash when a service is added or removed during handle_response - -Services are now modified under a lock. The service processing -is now done in a try block to ensure RuntimeError is caught -which prevents the zeroconf engine from unexpectedly -terminating. ([`4136858`](https://github.com/python-zeroconf/python-zeroconf/commit/41368588e5fcc6ec9596f306e39e2eaac2a9ec18)) - -* Restore IPv6 addresses output - -Before this change, script `examples/browser.py` printed IPv4 only, even with `--v6` argument. -With this change, `examples/browser.py` prints both IPv4 + IPv6 by default, and IPv6 only with `--v6-only` argument. - -I took the idea from the fork -https://github.com/ad3angel1s/python-zeroconf/blob/master/examples/browser.py ([`4da1612`](https://github.com/python-zeroconf/python-zeroconf/commit/4da1612b728acbcf2ab0c4bee09891c46f387bfb)) - - -## v0.28.6 (2020-10-13) - -### Unknown - -* Release version 0.28.6 ([`4744427`](https://github.com/python-zeroconf/python-zeroconf/commit/474442750d5d529436a118fda98a0b5f4680dc4d)) - -* Merge strict and allow_underscores (#309) - -Those really serve the same purpose -- are we receiving data (and want -to be flexible) or registering services (and want to be strict). ([`6a0c5dd`](https://github.com/python-zeroconf/python-zeroconf/commit/6a0c5dd4e84c30264747847e8f1045ece2a14288)) - -* Loosen validation to ensure get_service_info can handle production devices (#307) - -Validation of names was too strict and rejected devices that are otherwise -functional. A partial list of devices that unexpectedly triggered -a BadTypeInNameException: - - Bose Soundtouch - Yeelights - Rachio Sprinklers - iDevices ([`6ab0cd0`](https://github.com/python-zeroconf/python-zeroconf/commit/6ab0cd0a0446f158a1d8a64a3bc548cf9e103179)) - - -## v0.28.5 (2020-09-11) - -### Unknown - -* Release version 0.28.5 ([`eda1b3d`](https://github.com/python-zeroconf/python-zeroconf/commit/eda1b3dd17329c40a59b628b4bbca15c42af43b7)) - -* Fix AttributeError: module 'unittest' has no attribute 'mock' (#302) - -We only had module-level unittest import before now, but code accessing -mock through unittest.mock was working because we have a test-level -import from unittest.mock which causes unittest to gain the mock -attribute and if the test was run before other tests (those using -unittest.mock.patch) all was good. If the test was not run before them, -though, they'd fail. - -Closes GH-295. ([`2db7fff`](https://github.com/python-zeroconf/python-zeroconf/commit/2db7fff033937a929cdfee1fc7c93c594872799e)) - -* Ignore duplicate messages (#299) - -When watching packet captures, I noticed that zeroconf was processing -incoming data 3x on a my Home Assistant OS install because there are -three interfaces. - -We can skip processing duplicate packets in order to reduce the overhead -of decoding data we have already processed. - -Before - -Idle cpu ~8.3% - -recvfrom 4 times - - 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 - 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 - 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 - 267 recvfrom(8, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 - -sendto 8 times - - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - 267 sendto(8, "\0\0\204\0\0\0\0\1\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300K\0\1\200\1\0\0\0x\0\4\300\250\325\232", 335, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 335 - -After - -Idle cpu ~4.1% - -recvfrom 4 times (no change): - - 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 - 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("192.168.210.102")}, [16]) = 71 - 267 recvfrom(7, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 - 267 recvfrom(9, "\0\0\204\0\0\0\0\1\0\0\0\0\v_esphomelib\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\31\26masterbed_tvcabinet_32\300\f", 8966, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("172.30.32.1")}, [16]) = 71 - -sendto 2 times (reduced by 4x): - - 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 - 267 sendto(9, "\0\0\204\0\0\0\0\2\0\0\0\3\17_home-assistant\4_tcp\5local\0\0\f\0\1\0\0\21\224\0\7\4Home\300\f\t_services\7_dns-sd\4_udp\300!\0\f\0\1\0\0\21\224\0\2\300\f\3002\0!\200\1\0\0\0x\0)\0\0\0\0\37\273 66309dfc726446799c8a2c0f1cb0480f\300!\3002\0\20\200\1\0\0\21\224\0\305\22location_name=Home%uuid=66309dfc726446799c8a2c0f1cb0480f\24version=0.116.0.dev0\rexternal_url=(internal_url=http://192.168.213.154:8123$base_url=http://192.168.213.154:8123\32requires_api_password=True\300p\0\1\200\1\0\0\0x\0\4\300\250\325\232", 372, 0, {sa_family=AF_INET, sin_port=htons(5353), sin_addr=inet_addr("224.0.0.251")}, 16) = 372 - -With debug logging on for ~5 minutes - - bash-5.0# grep 'Received from' home-assistant.log |wc - 11458 499196 19706165 - bash-5.0# grep 'Ignoring' home-assistant.log |wc - 9357 210562 9299687 ([`f321932`](https://github.com/python-zeroconf/python-zeroconf/commit/f3219326e65f4410d45ace05f88082354a2f7525)) - -* Test with the development version of Python 3.9 (#300) - -There've been reports of test failures on Python 3.9, let's verify this. -Allowing failures for now until it goes stable. ([`1f81e0b`](https://github.com/python-zeroconf/python-zeroconf/commit/1f81e0bcad1cae735ba532758d167368925c8ede)) - - -## v0.28.4 (2020-09-06) - -### Unknown - -* Release version 0.28.4 ([`fb876d6`](https://github.com/python-zeroconf/python-zeroconf/commit/fb876d6013979cdaa8c0ddebe81e7520e9ee8cc9)) - -* Add ServiceListener to __all__ for Zeroconf module (#298) - -It's part of the public API. ([`0265a9d`](https://github.com/python-zeroconf/python-zeroconf/commit/0265a9d57630a4a19bcd3638a6bb3f4b18eba01b)) - -* Avoid copying the entires cache and reduce frequency of Reaper - -The cache reaper was running at least every 10 seconds, making -a copy of the cache, and iterated all the entries to -check if they were expired so they could be removed. - -In practice the reaper was actually running much more frequently -because it used self.zc.wait which would unblock any time -a record was updated, a listener was added, or when a -listener was removed. - -This change ensures the reaper frequency is only every 10s, and -will first attempt to iterate the cache before falling back to -making a copy. - -Previously it made sense to expire the cache more frequently -because we had places were we frequently had to enumerate -all the cache entries. With #247 and #232 we no longer -have to account for this concern. - -On a mostly idle RPi running HomeAssistant and a busy -network the total time spent reaping the cache was -more than the total time spent processing the mDNS traffic. - -Top 10 functions, idle RPi (before) - - %Own %Total OwnTime TotalTime Function (filename:line) - 0.00% 0.00% 2.69s 2.69s handle_read (zeroconf/__init__.py:1367) <== Incoming mDNS - 0.00% 0.00% 1.51s 2.98s run (zeroconf/__init__.py:1431) <== Reaper - 0.00% 0.00% 1.42s 1.42s is_expired (zeroconf/__init__.py:502) <== Reaper - 0.00% 0.00% 1.12s 1.12s entries (zeroconf/__init__.py:1274) <== Reaper - 0.00% 0.00% 0.620s 0.620s do_execute (sqlalchemy/engine/default.py:593) - 0.00% 0.00% 0.620s 0.620s read_utf (zeroconf/__init__.py:837) - 0.00% 0.00% 0.610s 0.610s do_commit (sqlalchemy/engine/default.py:546) - 0.00% 0.00% 0.540s 1.16s read_name (zeroconf/__init__.py:853) - 0.00% 0.00% 0.380s 0.380s do_close (sqlalchemy/engine/default.py:549) - 0.00% 0.00% 0.340s 0.340s write (asyncio/selector_events.py:908) - -After this change, the Reaper code paths do not show up in the top -10 function sample. - - %Own %Total OwnTime TotalTime Function (filename:line) - 4.00% 4.00% 2.72s 2.72s handle_read (zeroconf/__init__.py:1378) <== Incoming mDNS - 4.00% 4.00% 1.81s 1.81s read_utf (zeroconf/__init__.py:837) - 1.00% 5.00% 1.68s 3.51s read_name (zeroconf/__init__.py:853) - 0.00% 0.00% 1.32s 1.32s do_execute (sqlalchemy/engine/default.py:593) - 0.00% 0.00% 0.960s 0.960s readinto (socket.py:669) - 0.00% 0.00% 0.950s 0.950s create_connection (urllib3/util/connection.py:74) - 0.00% 0.00% 0.910s 0.910s do_commit (sqlalchemy/engine/default.py:546) - 1.00% 1.00% 0.880s 0.880s write (asyncio/selector_events.py:908) - 0.00% 0.00% 0.700s 0.810s __eq__ (zeroconf/__init__.py:606) - 2.00% 2.00% 0.670s 0.670s unpack (zeroconf/__init__.py:737) ([`1e4aaea`](https://github.com/python-zeroconf/python-zeroconf/commit/1e4aaeaa10c306b9447dacefa03b89ce1e9d7493)) - -* Add an author in the last changelog entry ([`9e27d12`](https://github.com/python-zeroconf/python-zeroconf/commit/9e27d126d75c73466584c417ab35c1d6cf47ca8b)) - - -## v0.28.3 (2020-08-31) - -### Unknown - -* Release version 0.28.3 ([`0e49aec`](https://github.com/python-zeroconf/python-zeroconf/commit/0e49aeca6497ede18a3f0c71ea69f2343934ba19)) - -* Reduce the time window that the handlers lock is held - -Only hold the lock if we have an update. ([`5a359bb`](https://github.com/python-zeroconf/python-zeroconf/commit/5a359bb0931fbda8444e30d07a50e59cf4ccca8e)) - -* Reformat using the latest black (20.8b1) ([`57d89d8`](https://github.com/python-zeroconf/python-zeroconf/commit/57d89d85e52dea1f8cb7f6d4b02c0281d5ba0540)) - - -## v0.28.2 (2020-08-27) - -### Unknown - -* Release version 0.28.2 ([`f64768a`](https://github.com/python-zeroconf/python-zeroconf/commit/f64768a7253829f9d8f7796a6a5c8129b92f2aad)) - -* Increase test coverage for dns cache ([`3be96b0`](https://github.com/python-zeroconf/python-zeroconf/commit/3be96b014d61c94d71ae3aa23ba223eead4f4cb7)) - -* Don't ask already answered questions (#292) - -Fixes GH-288. - -Co-authored-by: Erik ([`fca090d`](https://github.com/python-zeroconf/python-zeroconf/commit/fca090db06a0d481ad7f608c4fde3e936ad2f80e)) - -* Remove initial delay before querying for service info ([`0f73664`](https://github.com/python-zeroconf/python-zeroconf/commit/0f7366423fab8369700be086f3007c20897fde1f)) - - -## v0.28.1 (2020-08-17) - -### Unknown - -* Release version 0.28.1 ([`3c5d385`](https://github.com/python-zeroconf/python-zeroconf/commit/3c5d3856e286824611712de13aa0fcbe94e4313f)) - -* Ensure all listeners are cleaned up on ServiceBrowser cancelation (#290) - -When creating listeners for a ServiceBrowser with multiple types -they would not all be removed on cancelation. This led -to a build up of stale listeners when ServiceBrowsers were -frequently added and removed. ([`c9f3c91`](https://github.com/python-zeroconf/python-zeroconf/commit/c9f3c91da568fdbd26d571eed8a636a49e527b15)) - -* Gitignore some build artifacts ([`19e33a6`](https://github.com/python-zeroconf/python-zeroconf/commit/19e33a6829846008b50f408c77ac3e8e73176529)) - - -## v0.28.0 (2020-07-07) - -### Unknown - -* Release version 0.28.0 ([`0fdbf5e`](https://github.com/python-zeroconf/python-zeroconf/commit/0fdbf5e197a9f76e9e9c91a5e0908a0c66370dbd)) - -* Advertise Python 3.8 compatibility ([`02bcad9`](https://github.com/python-zeroconf/python-zeroconf/commit/02bcad902c516a5a2d2aa3302bca9871900da6e3)) - -* Fix an OS X edge case (#270, #188) - -This contains two major changes: - -* Listen on data from respond_sockets in addition to listen_socket -* Do not bind respond sockets to 0.0.0.0 or ::/0 - -The description of the original change by Emil: - -<<< -Without either of these changes, I get no replies at all when browsing for -services using the browser example. I'm on a corporate network, and when -connecting to a different network it works without these changes, so maybe -it's something about the network configuration in this particular network -that breaks the previous behavior. - -Unfortunately, I have no idea how this affects other platforms, or what -the changes really mean. However, it works for me and it seems reasonable -to get replies back on the same socket where they are sent. ->>> - -The tests pass and it's been confirmed to a reasonable degree that this -doesn't break the previously working use cases. - -Additionally this removes a memory leak where data sent to some of the -respond sockets would not be ever read from them (#171). - -Co-authored-by: Emil Styrke ([`fc92b1e`](https://github.com/python-zeroconf/python-zeroconf/commit/fc92b1e2635868792aa7ebe937a9cfef2e2f0418)) - -* Stop using socket.if_nameindex (#282) - -This improves Windows compatibility ([`a7f9823`](https://github.com/python-zeroconf/python-zeroconf/commit/a7f9823cbed254b506a09cc514d86d9f5dc61ad3)) - -* Make Mypy happy (#281) - -Otherwise it'd complain: - - % make mypy - mypy examples/*.py zeroconf/*.py - zeroconf/__init__.py:2039: error: Returning Any from function declared to return "int" - Found 1 error in 1 file (checked 6 source files) - make: *** [mypy] Error 1 ([`4381784`](https://github.com/python-zeroconf/python-zeroconf/commit/4381784150e07625b4acd2034b253bf2ed320c5f)) - -* Use Adapter.index from ifaddr. (#280) - -Co-authored-by: PhilippSelenium ([`64056ab`](https://github.com/python-zeroconf/python-zeroconf/commit/64056ab4aa55eb11c185c9879462ba1f82c7e886)) - -* Exclude a problematic pep8-naming version ([`023e72d`](https://github.com/python-zeroconf/python-zeroconf/commit/023e72d821faed9513ee0ef3a22a00231d87389e)) - -* Log listen and respond sockets just in case ([`3b6906a`](https://github.com/python-zeroconf/python-zeroconf/commit/3b6906ab94f8d9ebeb1c97b6026ab7f9be226eab)) - -* Fix one log format string (we use a socket object here) ([`328abfc`](https://github.com/python-zeroconf/python-zeroconf/commit/328abfc54138e68e36a9f5381650bd6997701e73)) - -* Add support for passing text addresses to ServiceInfo - -Not sure if parsed_addresses is the best way to name the parameter, but -we already have a parsed_addresses property so for the sake of -consistency let's stick to that. ([`0a9aa8d`](https://github.com/python-zeroconf/python-zeroconf/commit/0a9aa8d31bffec5d7b7291b84fbc95222b10d189)) - -* Support Windows when using socket errno checks (#274) - -Windows reports errno.WSAEINVAL(10022) instead of errno.EINVAL(22). -This issue is triggered when a device has two IP's assigned under -windows. - -This fixes #189 ([`c31ae7f`](https://github.com/python-zeroconf/python-zeroconf/commit/c31ae7fd519df04f41939d3c60c2b88960737fd6)) - - -## v0.27.1 (2020-06-05) - -### Unknown - -* Release version 0.27.1 ([`0538abf`](https://github.com/python-zeroconf/python-zeroconf/commit/0538abf135f5502d94dd883475bcb2781ce5ddd2)) - -* Fix false warning (#273) - -When there is nothing to write, we don't need to warn about not making progress. ([`10065b9`](https://github.com/python-zeroconf/python-zeroconf/commit/10065b976247ae9247cddaff8f3e9d7b331e66d7)) - -* Improve logging (mainly include sockets in some messages) (#271) ([`beff998`](https://github.com/python-zeroconf/python-zeroconf/commit/beff99897f0a5ece17e224a7ea9b12ebd420044f)) - -* Simplify DNSHinfo constructor, cpu and os are always text (#266) ([`d6593af`](https://github.com/python-zeroconf/python-zeroconf/commit/d6593af2a3811b262d70bbc75c2c91613de41b21)) - -* Improve ImportError message (wrong supported Python version) ([`8045191`](https://github.com/python-zeroconf/python-zeroconf/commit/8045191ae6300da47d38e5cd82957965139359d2)) - -* Remove old Python 2-specific code ([`6f876a7`](https://github.com/python-zeroconf/python-zeroconf/commit/6f876a7f14f0b172860005b0d6d959d82f7c1bbf)) - - -## v0.27.0 (2020-05-27) - -### Unknown - -* Release version 0.27.0 ([`0502f19`](https://github.com/python-zeroconf/python-zeroconf/commit/0502f1904b0a8b9134ea2a09333232b30b3b6897)) - -* Remove no longer needed typing dependency - -We don't support Python older than 3.5. ([`d881aba`](https://github.com/python-zeroconf/python-zeroconf/commit/d881abaf591f260ad019f4ff86e7f70a6f018a64)) - -* Add --find option to example/browser.py (#263, rebased #175) - -Co-authored-by: Perry Kundert ([`781ac83`](https://github.com/python-zeroconf/python-zeroconf/commit/781ac834da38708d95bfe6e5f5ec7dd0f31efc54)) - -* Restore missing warnings import ([`178cec7`](https://github.com/python-zeroconf/python-zeroconf/commit/178cec75bd9a065b150b3542dfdb40682f6745b6)) - -* Warn on every call to missing update_service() listener method - -This is in order to provide visibility to the library users that this -method exists - without it the client code may be missing data. ([`488ee1e`](https://github.com/python-zeroconf/python-zeroconf/commit/488ee1e85762dc5856d8e132da54762e5e712c5a)) - -* Separately send large mDNS responses to comply with RFC 6762 (#248) - -This fixes issue #245 - -Split up large multi-response packets into separate packets instead of relying on IP Fragmentation. IP Fragmentation of mDNS packets causes ChromeCast Audios to -crash their mDNS responder processes and RFC 6762 -(https://tools.ietf.org/html/rfc6762) section 17 states some -requirements for Multicast DNS Message Size, and the fourth paragraph reads: - -"A Multicast DNS packet larger than the interface MTU, which is sent -using fragments, MUST NOT contain more than one resource record." - -This change makes this implementation conform with this MUST NOT clause. ([`87a0fe2`](https://github.com/python-zeroconf/python-zeroconf/commit/87a0fe27a7be9d96af08f8a007f37a16105c64a0)) - -* Remove deprecated ServiceInfo address parameter/property (#260) ([`ab72aa8`](https://github.com/python-zeroconf/python-zeroconf/commit/ab72aa8e5a6a83e50d24d7fb187e8fa8a549a847)) - - -## v0.26.3 (2020-05-26) - -### Unknown - -* Release version 0.26.3 ([`fbcefca`](https://github.com/python-zeroconf/python-zeroconf/commit/fbcefca592632304579c1b3f9c7bd3dd342e1618)) - -* Don't call callbacks when holding _handlers_lock (#258) - -Closes #255 - -Background: -#239 adds the lock _handlers_lock: - -python-zeroconf/zeroconf/__init__.py - - self._handlers_lock = threading.Lock() # ensure we process a full message in one go - -Which is used in the engine thread: - - def handle_response(self, msg: DNSIncoming) -> None: - """Deal with incoming response packets. All answers - are held in the cache, and listeners are notified.""" - - with self._handlers_lock: - - -And also by the service browser when issuing the state change callbacks: - - if len(self._handlers_to_call) > 0 and not self.zc.done: - with self.zc._handlers_lock: - handler = self._handlers_to_call.popitem(False) - self._service_state_changed.fire( - zeroconf=self.zc, service_type=self.type, name=handler[0], state_change=handler[1] - ) - -Both pychromecast and Home Assistant calls Zeroconf.get_service_info from the service callbacks which means the lock may be held for several seconds which will starve the engine thread. ([`fe86566`](https://github.com/python-zeroconf/python-zeroconf/commit/fe865667e4610d57067a8f710f4d818eaa5e14dc)) - -* Give threads unique names (#257) ([`54d116f`](https://github.com/python-zeroconf/python-zeroconf/commit/54d116fd69a66062f91be04d84ceaebcfb13cc43)) - -* Use equality comparison instead of identity comparison for ints - -Integers aren't guaranteed to have the same identity even though they -may be equal. ([`445d7f5`](https://github.com/python-zeroconf/python-zeroconf/commit/445d7f5dbe38947bd0bd1e3a5b8d649c1819c21f)) - -* Merge 0.26.2 release commit - -I accidentally only pushed 0.26.2 tag (commit ffb42e5836bd) without -pushing the commit to master and now I merged aa9de4de7202 so this is -the best I can do without force-pushing to master. Tag 0.26.2 will -continue to point to that dangling commit. ([`1c4d3fc`](https://github.com/python-zeroconf/python-zeroconf/commit/1c4d3fcbf34b09364e52a773783dc9c924a7b17a)) - -* Improve readability of logged incoming data (#254) ([`aa9de4d`](https://github.com/python-zeroconf/python-zeroconf/commit/aa9de4de7202b3ab0a60f14532d227f63d7d981b)) - -* Add support for multiple types to ServiceBrowsers - -As each ServiceBrowser runs in its own thread there -is a scale problem when listening for many types. - -ServiceBrowser can now accept a list of types -in addition to a single type. ([`a6ad100`](https://github.com/python-zeroconf/python-zeroconf/commit/a6ad100a60e8434cef6b411208eef98f68d594d3)) - -* Fix race condition where a listener gets -a message before the lock is created. ([`24a0619`](https://github.com/python-zeroconf/python-zeroconf/commit/24a06191ea35469948d12124a07429207b3c1b3b)) - -* Fix flake8 E741 in setup.py (#252) ([`4b1d953`](https://github.com/python-zeroconf/python-zeroconf/commit/4b1d953979287e08f914857867da1000634ca3af)) - - -## v0.26.1 (2020-05-06) - -### Unknown - -* Release version 0.26.1 ([`4c359e2`](https://github.com/python-zeroconf/python-zeroconf/commit/4c359e2e7cdf104efca90ffd9912ea7c7792e3bf)) - -* Remove unwanted pylint directives - -Those are results of a bad conflict resolution I did when merging [1]. - -[1] 552a030eb592 ("Call UpdateService on SRV & A/AAAA updates as well as TXT (#239)") ([`0dd6fe4`](https://github.com/python-zeroconf/python-zeroconf/commit/0dd6fe44ca3895375ba447fed5f138042ab12ebf)) - -* Avoid iterating the entire cache when an A/AAAA address has not changed (#247) - -Iterating the cache is an expensive operation -when there is 100s of devices generating zeroconf -traffic as there can be 1000s of entries in the -cache. ([`0540342`](https://github.com/python-zeroconf/python-zeroconf/commit/0540342bacd859f38f6d2a3743a7959cd3ae4d02)) - -* Update .gitignore for Visual Studio config files (#244) ([`16431b6`](https://github.com/python-zeroconf/python-zeroconf/commit/16431b6cb51f561a4c5d2897e662b254ca4243ec)) - - -## v0.26.0 (2020-04-26) - -### Unknown - -* Release version 0.26.0 ([`36941ae`](https://github.com/python-zeroconf/python-zeroconf/commit/36941aeb72711f7954d40f0abeab4802174636df)) - -* Call UpdateService on SRV & A/AAAA updates as well as TXT (#239) - -Fix https://github.com/jstasiak/python-zeroconf/issues/235 - -Contains: - -* Add lock around handlers list -* Reverse DNSCache order to ensure newest records take precedence - - When there are multiple records in the cache, the behaviour was - inconsistent. Whilst the DNSCache.get() method returned the newest, - any function which iterated over the entire cache suffered from - a last write winds issue. This change makes this behaviour consistent - and allows the removal of an (incorrect) wait from one of the unit tests. ([`552a030`](https://github.com/python-zeroconf/python-zeroconf/commit/552a030eb592a0c07feaa7a01ece1464da4b1d0b)) - - -## v0.25.1 (2020-04-14) - -### Unknown - -* Release version 0.25.1 ([`f8fe400`](https://github.com/python-zeroconf/python-zeroconf/commit/f8fe400e4be833728f015a3d6396bfc3f7c185c0)) - -* Update Engine to immediately notify its worker thread (#243) ([`976e3dc`](https://github.com/python-zeroconf/python-zeroconf/commit/976e3dcf9d6d897b063ab6f0b7831bcfa6ac1814)) - -* Remove unstable IPv6 tests from Travis (#241) ([`cf0382b`](https://github.com/python-zeroconf/python-zeroconf/commit/cf0382ba771bcc22284fd719c80a26eaa05ba5cd)) - -* Switch to pytest for test running (#240) - -Nose is dead for all intents and purposes (last release in 2015) and -pytest provide a very valuable feature of printing relevant extra -information in case of assertion failure (from[1]): - - ================================= FAILURES ================================= - _______________________________ test_answer ________________________________ - - def test_answer(): - > assert func(3) == 5 - E assert 4 == 5 - E + where 4 = func(3) - - test_sample.py:6: AssertionError - ========================= short test summary info ========================== - FAILED test_sample.py::test_answer - assert 4 == 5 - ============================ 1 failed in 0.12s ============================= - -This should be helpful in debugging tests intermittently failing on -PyPy. - -Several TestCase.assertEqual() calls have been replaced by plain -assertions now that that method no longer provides anything we can't get -without it. Few assertions have been modified to not explicitly provide -extra information in case of failure – pytest will provide this -automatically. - -Dev dependencies are forced to be the latest versions to make sure -we don't fail because of outdated ones on Travis. - -[1] https://docs.pytest.org/en/latest/getting-started.html#create-your-first-test ([`f071f3d`](https://github.com/python-zeroconf/python-zeroconf/commit/f071f3d49d82ab212b86f889532200c94b36aea6)) - - -## v0.25.0 (2020-04-03) - -### Unknown - -* Release version 0.25.0 ([`0cbced8`](https://github.com/python-zeroconf/python-zeroconf/commit/0cbced809989283893e02914e251a94739a41062)) - -* Improve ServiceInfo documentation ([`e839c40`](https://github.com/python-zeroconf/python-zeroconf/commit/e839c40081ba15e228d447969b725ee42f1ef2ad)) - -* Remove uniqueness assertions - -The assertions, added in [1] and modified in [2] introduced a -regression. When browsing in the presence of devices advertising SRV -records not marked as unique there would be an undesired crash (from [3]): - - Exception in thread zeroconf-ServiceBrowser__hap._tcp.local.: - Traceback (most recent call last): - File "/usr/lib/python3.7/threading.py", line 917, in _bootstrap_inner - self.run() - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1504, in run - handler(self.zc) - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1444, in - zeroconf=zeroconf, service_type=self.type, name=name, state_change=state_change - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1322, in fire - h(**kwargs) - File "browser.py", line 20, in on_service_state_change - info = zeroconf.get_service_info(service_type, name) - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 2191, in get_service_info - if info.request(self, timeout): - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 1762, in request - out.add_answer_at_time(zc.cache.get_by_details(self.name, _TYPE_SRV, _CLASS_IN), now) - File "/home/pi/homekit-debugging/venv/lib/python3.7/site-packages/zeroconf/__init__.py", line 907, in add_answer_at_time - assert record.unique - AssertionError - -The intention is to bring those assertions back in a way that only -enforces uniqueness when sending records, not when receiving them. - -[1] bef8f593ae82 ("Ensure all TXT, SRV, A records are unique") -[2] 5e4f496778d9 ("Refactor out unique assertion") -[3] https://github.com/jstasiak/python-zeroconf/issues/236 ([`a79015e`](https://github.com/python-zeroconf/python-zeroconf/commit/a79015e7c4bdc843d97bd5c82ef8ed4eeae01a34)) - -* Rationalize handling of values in TXT records - -* Do not interpret received values; use None if a property has no value -* When encoding values, use either raw bytes or UTF-8 ([`8e3adf8`](https://github.com/python-zeroconf/python-zeroconf/commit/8e3adf8300a6f2b0bc0dcc4cde54d8890e0727e9)) - - -## v0.24.5 (2020-03-08) - -### Unknown - -* Release version 0.24.5 ([`aba2858`](https://github.com/python-zeroconf/python-zeroconf/commit/aba28583f5431f584587770b6c149e4a607a987e)) - -* Resolve memory leak in DNSCache - -When all the records for a given name were removed from the cache, the -name itself that contain the list was never removed. This left an empty list -in memory for every device that was no longer broadcasting on the -network. ([`eac53f4`](https://github.com/python-zeroconf/python-zeroconf/commit/eac53f45bddb8d3d559b1d4672a926b746435771)) - -* Optimize handle_response cache check - -The handle_response loop would encounter a unique record -it would search the cache in order to remove keys that -matched the DNSEntry for the record. - -Since the cache is stored as a list of records with the key as the record name, - we can avoid searching the entire cache each time and on -search for the DNSEntry of the record. In practice this means -with 5000 entries and records in the cache we now only need to search -4 or 5. - -When looping over the cache entries for the name, we now check the expire time -first as its cheaper than calling DNSEntry.__eq__ - -Test environment: - - Home Assistant running on home networking with a /22 - and a significant amount of broadcast traffic - - Testing was done with py-spy v0.3.3 - (https://github.com/benfred/py-spy/releases) - - # py-spy top --pid - -Before: -``` -Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) -Total Samples 10200 -GIL: 0.00%, Active: 0.00%, Threads: 35 - - %Own %Total OwnTime TotalTime Function (filename:line) - 0.00% 0.00% 18.13s 18.13s _worker (concurrent/futures/thread.py:78) - 0.00% 0.00% 2.51s 2.56s run (zeroconf/__init__.py:1221) - 0.00% 0.00% 0.420s 0.420s __eq__ (zeroconf/__init__.py:394) - 0.00% 0.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1260) - 0.00% 0.00% 0.240s 0.670s handle_response (zeroconf/__init__.py:2452) - 0.00% 0.00% 0.230s 0.230s __eq__ (zeroconf/__init__.py:606) - 0.00% 0.00% 0.200s 0.810s handle_response (zeroconf/__init__.py:2449) - 0.00% 0.00% 0.140s 0.150s __eq__ (zeroconf/__init__.py:632) - 0.00% 0.00% 0.130s 0.130s entries (zeroconf/__init__.py:1185) - 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) - 0.00% 0.00% 0.080s 0.080s read_utf (zeroconf/__init__.py:818) - 0.00% 0.00% 0.080s 0.080s __eq__ (zeroconf/__init__.py:678) - 0.00% 0.00% 0.070s 0.080s __eq__ (zeroconf/__init__.py:533) - 0.00% 0.00% 0.060s 0.060s __eq__ (zeroconf/__init__.py:677) - 0.00% 0.00% 0.050s 0.050s get (zeroconf/__init__.py:1146) - 0.00% 0.00% 0.050s 0.050s do_commit (sqlalchemy/engine/default.py:541) - 0.00% 0.00% 0.040s 2.86s run (zeroconf/__init__.py:1226) -``` - -After -``` -Collecting samples from '/usr/local/bin/python3 -m homeassistant --config /config' (python v3.7.6) -Total Samples 10200 -GIL: 7.00%, Active: 61.00%, Threads: 35 - - %Own %Total OwnTime TotalTime Function (filename:line) - 47.00% 47.00% 24.84s 24.84s _worker (concurrent/futures/thread.py:78) - 5.00% 5.00% 2.97s 2.97s run (zeroconf/__init__.py:1226) - 1.00% 1.00% 0.390s 0.390s handle_read (zeroconf/__init__.py:1265) - 1.00% 1.00% 0.200s 0.200s read_utf (zeroconf/__init__.py:818) - 0.00% 0.00% 0.120s 0.120s unpack (zeroconf/__init__.py:723) - 0.00% 1.00% 0.120s 0.320s read_name (zeroconf/__init__.py:834) - 0.00% 0.00% 0.100s 0.240s update_record (zeroconf/__init__.py:2440) - 0.00% 0.00% 0.090s 0.090s notify (threading.py:352) - 0.00% 0.00% 0.070s 0.070s update_record (zeroconf/__init__.py:1469) - 0.00% 0.00% 0.060s 0.070s __eq__ (zeroconf/__init__.py:606) - 0.00% 0.00% 0.050s 0.050s acquire (logging/__init__.py:843) - 0.00% 0.00% 0.050s 0.050s unpack (zeroconf/__init__.py:722) - 0.00% 0.00% 0.050s 0.050s read_name (zeroconf/__init__.py:828) - 0.00% 0.00% 0.050s 0.050s is_expired (zeroconf/__init__.py:494) - 0.00% 0.00% 0.040s 0.040s emit (logging/__init__.py:1028) - 1.00% 1.00% 0.040s 0.040s __init__ (zeroconf/__init__.py:386) - 0.00% 0.00% 0.040s 0.040s __enter__ (threading.py:241) -``` ([`37fa0a0`](https://github.com/python-zeroconf/python-zeroconf/commit/37fa0a0d59a5b5d09295a462bf911e82d2d770ed)) - -* Support cooperating responders (#224) ([`1ca023f`](https://github.com/python-zeroconf/python-zeroconf/commit/1ca023fae4b586679446ceaf3e2e9955ea5bf180)) - -* Remove duplciate update messages sent to listeners - -The prior code used to send updates even when the new record was identical to the old. - -This resulted in duplciate update messages when there was in fact no update (apart from TTL refresh) ([`d8caa4e`](https://github.com/python-zeroconf/python-zeroconf/commit/d8caa4e2d71025ed42b33abb4d329329437b44fb)) - -* Refactor out unique assertion ([`5e4f496`](https://github.com/python-zeroconf/python-zeroconf/commit/5e4f496778d91ccfc65e946d3d94c39ab6388b29)) - -* Fix representation of IPv6 DNSAddress (#230) ([`f6690d2`](https://github.com/python-zeroconf/python-zeroconf/commit/f6690d2048cb87cb0fb3a7c3b832cf1a1f40e61a)) - -* Do not exclude interfaces with host-only netmasks from InterfaceChoice.All (#227) - -Host-only netmasks do not forbid multicast. - -Tested on Debian 10 running in Qubes and on Ubuntu 18.04. ([`ca8e53d`](https://github.com/python-zeroconf/python-zeroconf/commit/ca8e53de55a563f5c7049be2eda14ae0ecd1a7cf)) - -* Ensure all TXT, SRV, A records are unique - -Fixes issues with shared records being used where they shouldn't be. - -PTR records should be shared, but SRV, TXT and A/AAAA records should be unique. - -Whilst mDNS and DNS-SD in theory support shared records for these types of record, they are not implemented in python-zeroconf at the moment. - -See zeroconf.check_service() method which verifies the service is unique on the network before registering. ([`bef8f59`](https://github.com/python-zeroconf/python-zeroconf/commit/bef8f593ae820eb8465934de91eb27468edf6444)) - - -## v0.24.4 (2019-12-30) - -### Unknown - -* Release version 0.24.4 ([`29432bf`](https://github.com/python-zeroconf/python-zeroconf/commit/29432bfffd057cf4da7636ba0c28c9d8a7ad4357)) - -* Clean up output of ttl remaining to be whole seconds only ([`ba1b78d`](https://github.com/python-zeroconf/python-zeroconf/commit/ba1b78dbdcc64f8d35c951e7ca53d2898e7d7900)) - -* Clean up format to cleanly separate [question]=ttl,answer ([`4b735dc`](https://github.com/python-zeroconf/python-zeroconf/commit/4b735dc5411f7b563f23b60b5c2aa806151cca1a)) - -* Update DNS entries so all subclasses of DNSRecord use to_string for display - -All records based on DNSRecord now properly use to_string in repr, some were -only dumping the answer without the question (inconsistent). ([`8ccad54`](https://github.com/python-zeroconf/python-zeroconf/commit/8ccad54dab4a0ab7f573996f6fc0c2f2bad7eafe)) - -* Fix resetting of TTL (#209) - -Fix resetting of TTL - -Previously the reset_ttl method changed the time created and the TTL value, but did not change the expiration time or stale times. As a result a record would expire even when this method had been called. ([`b47efd8`](https://github.com/python-zeroconf/python-zeroconf/commit/b47efd8eed0b5ed9d3b6bca8573a6ed1916c982a)) - - -## v0.24.3 (2019-12-23) - -### Unknown - -* Release version 0.24.3 ([`2316027`](https://github.com/python-zeroconf/python-zeroconf/commit/2316027e5e96d8f10fae7607da5b72a9bab819fc)) - -* Fix import-time TypeError on CPython 3.5.2 - -The error: TypeError: 'ellipsis' object is not iterable." - -Explanation can be found here: https://github.com/jstasiak/python-zeroconf/issues/208 - -Closes GH-208. ([`f53e24b`](https://github.com/python-zeroconf/python-zeroconf/commit/f53e24bddb3a6cb242cace2a541ed507e823be33)) - - -## v0.24.2 (2019-12-17) - -### Unknown - -* Release version 0.24.2 ([`76bc675`](https://github.com/python-zeroconf/python-zeroconf/commit/76bc67532ad26f54c194e1e6537d2da4390f83e2)) - -* Provide and enforce type hints everywhere except for tests - -The tests' time will come too in the future, though, I think. I believe -nose has problems with running annotated tests right now so let's leave -it for later. - -DNSEntry.to_string renamed to entry_to_string because DNSRecord -subclasses DNSEntry and overrides to_string with a different signature, -so just to be explicit and obvious here I renamed it – I don't think any -client code will break because of this. - -I got rid of ServicePropertiesType in favor of generic Dict because -having to type all the properties got annoying when variance got -involved – maybe it'll be restored in the future but it seems like too -much hassle now. ([`f771587`](https://github.com/python-zeroconf/python-zeroconf/commit/f7715874c2242b95cf9815549344ea66ac107b6e)) - -* Fix get_expiration_time percent parameter annotation - -It takes integer percentage values at the moment so let's document that. ([`5986bf6`](https://github.com/python-zeroconf/python-zeroconf/commit/5986bf66e77e77f9e0b6ba43a4758ecb0da04ff6)) - -* Add support for AWDL interface on macOS - -The API is inspired by Apple's NetService.includesPeerToPeer -(see https://developer.apple.com/documentation/foundation/netservice/1414086-includespeertopeer) ([`fcafdc1`](https://github.com/python-zeroconf/python-zeroconf/commit/fcafdc1e285cc5c3c1f2c413ac9309d3426179f4)) - - -## v0.24.1 (2019-12-16) - -### Unknown - -* Release version 0.24.1 ([`53dd06c`](https://github.com/python-zeroconf/python-zeroconf/commit/53dd06c37f6205129e81f5c6b69e508a54f94d07)) - -* Bugfix: TXT record's name is never equal to Service Browser's type. - -TXT record's name is never equal to Service Browser's type. We should -check whether TXT record's name ends with Service Browser's type. -Otherwise, we never get updates of TXT records. ([`2a597ee`](https://github.com/python-zeroconf/python-zeroconf/commit/2a597ee80906a27effd442d033de10b5129e6900)) - -* Bugfix: Flush outdated cache entries when incoming record is unique. - -According to RFC 6762 10.2. Announcements to Flush Outdated Cache Entries, -when the incoming record's cache-flush bit is set (record.unique == True -in this module), "Instead of merging this new record additively into the -cache in addition to any previous records with the same name, rrtype, and -rrclass, all old records with that name, rrtype, and rrclass that were -received more than one second ago are declared invalid, and marked to -expire from the cache in one second." ([`1d39b3e`](https://github.com/python-zeroconf/python-zeroconf/commit/1d39b3edd141093f9e579ab83377fe8f5ecb357d)) - -* Change order of equality check to favor cheaper checks first - -Comparing two strings is much cheaper than isinstance, so we should try -those first - -A performance test was run on a network with 170 devices running Zeroconf. -There was a ServiceBrowser running on a separate thread while a timer ran -on the main thread that forced a thread switch every 2 seconds (to include -the effect of thread switching in the measurements). Every minute, -a Zeroconf broadcast was made on the network. - -This was ran this for an hour on a Macbook Air from 2015 (Intel Core -i7-5650U) using Ubuntu 19.10 and Python 3.7, both before this commit and -after. - -These are the results of the performance tests: -Function Before count Before time Before time per count After count After time After time per count Time reduction -DNSEntry.__eq__ 528 0.001s 1.9μs 538 0.001s 1.9μs 1.9% -DNSPointer.__eq__ 24369256 (24.3M) 134.641s 5.5μs 25989573 (26.0M) 86.405s 3.3μs 39.8% -DNSText.__eq__ 52966716 (53.0M) 190.640s 3.6μs 53604915 (53.6M) 169.104s 3.2μs 12.4% -DNSService.__eq__ 52620538 (52.6M) 171.660s 3.3μs 56557448 (56.6M) 170.222s 3.0μs 7.8% ([`815ac77`](https://github.com/python-zeroconf/python-zeroconf/commit/815ac77e9146c37afd7c5389ed45adee9f1e2e36)) - -* Dont recalculate the expiration and stale time every update - -I have a network with 170 devices running Zeroconf. Every minute -a zeroconf request for broadcast is cast out. Then we were listening for -Zeroconf devices on that network. - -To get a more realistic test, the Zeroconf ServiceBrowser is ran on -a separate thread from a main thread. On the main thread an I/O limited -call to QNetworkManager is made every 2 seconds, - -in order to include performance penalties due to thread switching. The -experiment was ran on a MacBook Air 2015 (Intel Core i7-5650U) through -Ubuntu 19.10 and Python 3.7. - -This was left running for exactly one hour, both before and after this commit. - -Before this commit, there were 132107499 (132M) calls to the -get_expiration_time function, totalling 141.647s (just over 2 minutes). - -After this commit, there were 1661203 (1.6M) calls to the -get_expiration_time function, totalling 2.068s. - -This saved about 2 minutes of processing time out of the total 60 minutes, -on average 3.88% processing power on the tested CPU. It is expected to see -similar improvements on all CPU architectures. ([`2e9699c`](https://github.com/python-zeroconf/python-zeroconf/commit/2e9699c542f691fc605e4a1c03cbf496273a9835)) - -* Significantly improve the speed of the entries function of the cache - -Tested this with Python 3.6.8, Fedora 28. This was done in a network with -a lot of discoverable devices. - -before: -Total time: 1.43086 s - -Line # Hits Time Per Hit % Time Line Contents -============================================================== - 1138 @profile - 1139 def entries(self): - 1140 """Returns a list of all entries""" - 1141 2063 3578.0 1.7 0.3 if not self.cache: - 1142 2 3.0 1.5 0.0 return [] - 1143 else: - 1144 # avoid size change during iteration by copying the cache - 1145 2061 22051.0 10.7 1.5 values = list(self.cache.values()) - 1146 2061 1405227.0 681.8 98.2 return reduce(lambda a, b: a + b, values) - -After: -Total time: 0.43725 s - -Line # Hits Time Per Hit % Time Line Contents -============================================================== - 1138 @profile - 1139 def entries(self): - 1140 """Returns a list of all entries""" - 1141 3651 10171.0 2.8 2.3 if not self.cache: - 1142 2 7.0 3.5 0.0 return [] - 1143 else: - 1144 # avoid size change during iteration by copying the cache - 1145 3649 67054.0 18.4 15.3 values = list(self.cache.values()) - 1146 3649 360018.0 98.7 82.3 return list(itertools.chain.from_iterable(values)) ([`157fc20`](https://github.com/python-zeroconf/python-zeroconf/commit/157fc2003318d785d07b362e1fd2ba3fe5d373f0)) - -* The the formatting of the IPv6 section in the readme ([`6ab7dbf`](https://github.com/python-zeroconf/python-zeroconf/commit/6ab7dbf27a2086e20f4486e693e2091d043af1db)) - - -## v0.24.0 (2019-11-19) - -### Unknown - -* Release version 0.24.0 ([`f03dc42`](https://github.com/python-zeroconf/python-zeroconf/commit/f03dc42d6234419053bda18ca6f2b90bec1b9257)) - -* Improve type hint coverage ([`c827f9f`](https://github.com/python-zeroconf/python-zeroconf/commit/c827f9fdc4c58433143ea8815029c3387b500ff5)) - -* Add py.typed marker (closes #199) - -This required changing to a proper package. ([`41b31cb`](https://github.com/python-zeroconf/python-zeroconf/commit/41b31cb338e8a8a7d1a548662db70d9014e8a352)) - -* Link to the documentation ([`3db9d82`](https://github.com/python-zeroconf/python-zeroconf/commit/3db9d82d888abe880bfdd2fb2c3fe3eddcb48ae9)) - -* Setup basic Sphinx documentation - -Closes #200 ([`1c33e5f`](https://github.com/python-zeroconf/python-zeroconf/commit/1c33e5f5b44732d446d629cc13000cff3527afef)) - -* ENOTCONN is not an error during shutdown - -When `python-zeroconf` is used in conjunction with `eventlet`, `select.select()` will return with an error code equal to `errno.ENOTCONN` instead of `errno.EBADF`. As a consequence, an exception is shown in the console during shutdown. I believe that it should not cause any harm to treat `errno.ENOTCONN` the same way as `errno.EBADF` to prevent this exception. ([`c86423a`](https://github.com/python-zeroconf/python-zeroconf/commit/c86423ab0223bab682614e18a6a09050dfc80087)) - -* Rework exposing IPv6 addresses on ServiceInfo - -* Return backward compatibility for ServiceInfo.addresses by making - it return V4 addresses only -* Add ServiceInfo.parsed_addresses for convenient access to addresses -* Raise TypeError if addresses are not provided as bytes (otherwise - an ugly assertion error is raised when sending) -* Add more IPv6 unit tests ([`98a1ce8`](https://github.com/python-zeroconf/python-zeroconf/commit/98a1ce8b99ddb03de9f6cccca49396fcf177e0d0)) - -* Finish AAAA records support - -The correct record type was missing in a few places. Also use -addresses_by_version(All) in preparation for switching addresses -to V4 by default. ([`aae7fd3`](https://github.com/python-zeroconf/python-zeroconf/commit/aae7fd3ba851d1894732c4270cef745127cc03da)) - -* Test with pypy3.6 - -Right now this is available as pypy3 in Travis CI. Running black on PyPy -needs to be disabled for now because of an issue[1] that's been patched -only recently and it's not available in Travis yet. - -[1] https://bitbucket.org/pypy/pypy/issues/2985/pypy36-osreplace-pathlike-typeerror ([`fec839a`](https://github.com/python-zeroconf/python-zeroconf/commit/fec839ae4fdcb870066fff855809583dcf7d7a17)) - -* Stop specifying precise pypy3.5 version - -This allows us to test with the latest available one. ([`c2e8bde`](https://github.com/python-zeroconf/python-zeroconf/commit/c2e8bdebc6cec128d01197d53c3402278a4b62ed)) - -* Simplify Travis CI configuration regarding Python 3.7 - -Selecting xenial manually is no longer needed. ([`5359ea0`](https://github.com/python-zeroconf/python-zeroconf/commit/5359ea0a0b4cdca0854ae97c5d11036633102c67)) - -* Test with Python 3.8 ([`15118c8`](https://github.com/python-zeroconf/python-zeroconf/commit/15118c837a148a37edd29a20294e598ecf09c3cf)) - -* Make AAAA records work (closes #52) (#191) - -This PR incorporates changes from the earlier PR #179 (thanks to Mikael Pahmp), adding tests and a few more fixes to make AAAA records work in practice. - -Note that changing addresses to container IPv6 addresses may be considered a breaking change, for example, for consumers that unconditionally apply inet_aton to them. I'm introducing a new function to be able to retries only addresses from one family. ([`5bb9531`](https://github.com/python-zeroconf/python-zeroconf/commit/5bb9531be48f6f1e119643677c36d9e714204a8b)) - -* Improve static typing coverage ([`e5323d8`](https://github.com/python-zeroconf/python-zeroconf/commit/e5323d8c9795c59019173b8d202a50a49c415039)) - -* Add additional recommended records to PTR responses (#184) - -RFC6763 indicates a server should include the SRV/TXT/A/AAAA records -when responding to a PTR record request. This optimization ensures -the client doesn't have to then query for these additional records. - -It has been observed that when multiple Windows 10 machines are monitoring -for the same service, this unoptimized response to the PTR record -request can cause extremely high CPU usage in both the DHCP Client -& Device Association service (I suspect due to all clients having to -then sending/receiving the additional queries/responses). ([`ea64265`](https://github.com/python-zeroconf/python-zeroconf/commit/ea6426547f79c32c6d5d3bcc2d0a261bf503197a)) - -* Rename IpVersion to IPVersion - -A follow up to 3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8. ([`ceb602c`](https://github.com/python-zeroconf/python-zeroconf/commit/ceb602c0d1bc1d3a269fd233b072a9b929076438)) - -* First stab at supporting listening on IPv6 interfaces - -This change adds basic support for listening on IPv6 interfaces. -Some limitations exist for non-POSIX platforms, pending fixes in -Python and in the ifaddr library. Also dual V4-V6 sockets may not -work on all BSD platforms. As a result, V4-only is used by default. - -Unfortunately, Travis does not seem to support IPv6, so the tests -are disabled on it, which also leads to coverage decrease. ([`3d5787b`](https://github.com/python-zeroconf/python-zeroconf/commit/3d5787b8c5a92304b70c04f48dc7d5cec8d9aac8)) - - -## v0.23.0 (2019-06-04) - -### Unknown - -* Release version 0.23.0 ([`7bd0436`](https://github.com/python-zeroconf/python-zeroconf/commit/7bd04363c7ff0f583a17cc2fac42f9a9c1724769)) - -* Add support for multiple addresses when publishing a service (#170) - -This is a rebased and fixed version of PR #27, which also adds compatibility shim for ServiceInfo.address and does a proper deprecation for it. - -* Present all addresses that are available. - -* Add support for publishing multiple addresses. - -* Add test for backwards compatibility. - -* Provide proper deprecation of the "address" argument and field - -* Raise deprecation warnings when address is used -* Add a compatibility property to avoid breaking existing code - (based on suggestion by Bas Stottelaar in PR #27) -* Make addresses keyword-only, so that address can be eventually - removed and replaced with it without breaking consumers -* Raise TypeError instead of an assertion on conflicting address - and addresses - -* Disable black on ServiceInfo.__init__ until black is fixed - -Due to https://github.com/python/black/issues/759 black produces -code that is invalid Python 3.5 syntax even with --target-version py35. -This patch disables reformatting for this call (it doesn't seem to be -possible per line) until it's fixed. ([`c787610`](https://github.com/python-zeroconf/python-zeroconf/commit/c7876108150cd251786db4ab52dadd1b2283d262)) - -* Makefile: be specific which files to check with black (#169) - -Otherwise black tries to check the "env" directory, which fails. ([`6b85a33`](https://github.com/python-zeroconf/python-zeroconf/commit/6b85a333de21fa36187f081c3c115c8af40d7055)) - -* Run black --check as part of CI to enforce code style ([`12477c9`](https://github.com/python-zeroconf/python-zeroconf/commit/12477c954e7f051d10152f9ab970e28fd4222b30)) - -* Refactor the CI script a bit to make adding black check easier ([`69ad22c`](https://github.com/python-zeroconf/python-zeroconf/commit/69ad22cf852a12622f78aa2f4e7cf20c2d395db2)) - -* Reformat the code using Black - -We could use some style consistency in the project and Black looks like -the best tool for the job. - -Two flake8 errors are being silenced from now on: - -* E203 whitespace before : -* W503 line break before binary operator - -Both are to satisfy Black-formatted code (and W503 is somemwhat against -the latest PEP8 recommendations regarding line breaks and binary -operators in new code). ([`beb596c`](https://github.com/python-zeroconf/python-zeroconf/commit/beb596c345b0764bdfe1a828cfa744bcc560cf32)) - -* Add support for MyListener call getting updates to service TXT records (2nd attempt) (#166) - -Add support for MyListener call getting updates to service TXT records - -At the moment, the implementation supports notification to the ServiceListener class for additions and removals of service, but for service updates to the TXT record, the client must poll the ServiceInfo class. This draft PR provides a mechanism to have a callback on the ServiceListener class be invoked when the TXT record changes. ([`d4e06bc`](https://github.com/python-zeroconf/python-zeroconf/commit/d4e06bc54098bfa7a863bcc11bb9e2035738c8f5)) - -* Remove Python 3.4 from the Python compatibility section - -I forgot to do this in 4a02d0489da80e8b9e8d012bb7451cd172c753ca. ([`e1c2b00`](https://github.com/python-zeroconf/python-zeroconf/commit/e1c2b00c772a1538a6682c45884bbe89c8efba60)) - -* Drop Python 3.4 support (it's dead now) - -See https://devguide.python.org/#status-of-python-branches ([`4a02d04`](https://github.com/python-zeroconf/python-zeroconf/commit/4a02d0489da80e8b9e8d012bb7451cd172c753ca)) - - -## v0.22.0 (2019-04-27) - -### Unknown - -* Prepare release 0.22.0 ([`db1dcf6`](https://github.com/python-zeroconf/python-zeroconf/commit/db1dcf682e453766b53773d70c0091b81a87a192)) - -* Add arguments to set TTLs via ServiceInfo ([`ecc021b`](https://github.com/python-zeroconf/python-zeroconf/commit/ecc021b7a3cec863eed5a3f71a1f28e3026c25b0)) - -* Use recommended TTLs with overrides via ServiceInfo ([`a7aedb5`](https://github.com/python-zeroconf/python-zeroconf/commit/a7aedb58649f557a5e372fc776f98457ce84eb39)) - -* ttl: modify default used to respond to _services queries ([`f25989d`](https://github.com/python-zeroconf/python-zeroconf/commit/f25989d8cdae8f77e19eba70f236dd8103b33e8f)) - -* Fix service removal packets not being sent on shutdown ([`57310e1`](https://github.com/python-zeroconf/python-zeroconf/commit/57310e185a4f924dd257edd64f866da685a786c6)) - -* Adjust query intervals to match RFC 6762 (#159) - -* Limit query backoff time to one hour as-per rfc6762 section 5.2 -* tests: monkey patch backoff limit to focus testing on TTL expiry -* tests: speed up integration test -* tests: add test of query backoff interval and limit -* Set initial query interval to 1 second as-per rfc6762 sec 5.2 -* Add comments around timing constants -* tests: fix linting errors -* tests: fix float assignment to integer var - - -Sets the repeated query backoff limit to one hour as opposed to 20 seconds, reducing unnecessary network traffic -Adds a test for the behaviour of the backoff procedure -Sets the first repeated query to happen after one second as opposed to 500ms ([`bee8abd`](https://github.com/python-zeroconf/python-zeroconf/commit/bee8abdba49e2275d203e3b0b4a3afac330ec4ea)) - -* Turn on and address mypy check_untyped_defs ([`4218d75`](https://github.com/python-zeroconf/python-zeroconf/commit/4218d757994467ee710b0cad034ea1fb6035d3ea)) - -* Turn on and address mypy warn-return-any ([`006e614`](https://github.com/python-zeroconf/python-zeroconf/commit/006e614315c12e5232e6168ce0bacf0dc056ba8a)) - -* Turn on and address mypy no-implicit-optional ([`071c6ed`](https://github.com/python-zeroconf/python-zeroconf/commit/071c6edb924b6bc9b67859dc9860cfe09cc98d07)) - -* Add reminder to enable disallow_untyped_calls for mypy ([`24bb44f`](https://github.com/python-zeroconf/python-zeroconf/commit/24bb44f858cd325d7ff2892c53dc1dd9f26ed768)) - -* Enable some more mypy warnings ([`183a846`](https://github.com/python-zeroconf/python-zeroconf/commit/183a84636a9d4fec6306d065a4f855fec95086e4)) - -* Run mypy on test_zeroconf.py too - -This will reveal issues with current type hints as demonstrated by a -commit/issue to be submitted later, as well as prevent some others -from cropping up meanwhile. ([`74391d5`](https://github.com/python-zeroconf/python-zeroconf/commit/74391d5c124bf6f899059db93bbf7e99b96d8aad)) - -* Move mypy config to setup.cfg - -Removes need for a separate file, better to have more in one place. ([`2973931`](https://github.com/python-zeroconf/python-zeroconf/commit/29739319ccf71f48c06bc1b74cd193f17fb6b272)) - -* Don't bother with a universal wheel as we're Python >= 3 only ([`9c0f1ab`](https://github.com/python-zeroconf/python-zeroconf/commit/9c0f1ab03b90f87ff1d58278a0b9b77c16195185)) - -* Add unit tests for default ServiceInfo properties. ([`a12c3b2`](https://github.com/python-zeroconf/python-zeroconf/commit/a12c3b2a3b4300849e0a4dcdd4df5386286b88d3)) - -* Modify ServiceInfo's __init__ properties' default value. - -This commit modifies the default value of the argument properties of -ServiceInfo’s __init__() to byte array (properties=b’’). This enables -to instantiate it without setting the properties argument. As it is, -and because properties is not mandatory, if a user does not specify -the argument, an exception (AssertionError) is thrown: - -Traceback (most recent call last): - File "src/zeroconf-test.py", line 72, in - zeroconf.register_service(service) - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 1864, in register_service - self.send(out) - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 2091, in send - packet = out.packet() - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 1026, in packet - overrun_answers += self.write_record(answer, time_) - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 998, in write_record - record.write(self) - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 579, in write - out.write_string(self.text) - File "/home/jmpcm/zeroconf-test/src/zeroconf.py", line 903, in write_string - assert isinstance(value, bytes) -AssertionError - -The argument can be either a dictionary or a byte array. The function -_set_properties() will always create a byte array with the user's -properties. Changing the default value to a byte array, avoids the -conversion to byte array and avoids the exception. ([`9321007`](https://github.com/python-zeroconf/python-zeroconf/commit/93210079259bd0973e3b54a90dff971e14abf595)) - -* Fix some spelling errors ([`88fb0e3`](https://github.com/python-zeroconf/python-zeroconf/commit/88fb0e34f902498f6ceb583ce6fa9346745a14ca)) - -* Require flake8 >= 3.6.0, drop pycodestyle restriction - -Fixes current build breakage related to flake8 dependencies. - -The breakage: - -$ make flake8 -flake8 --max-line-length=110 examples *.py -Traceback (most recent call last): - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/pkg_resources/__init__.py", line 2329, in resolve - return functools.reduce(getattr, self.attrs, module) -AttributeError: module 'pycodestyle' has no attribute 'break_after_binary_operator' -During handling of the above exception, another exception occurred: -Traceback (most recent call last): - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 182, in load_plugin - self._load(verify_requirements) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 154, in _load - self._plugin = resolve() - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/pkg_resources/__init__.py", line 2331, in resolve - raise ImportError(str(exc)) -ImportError: module 'pycodestyle' has no attribute 'break_after_binary_operator' -During handling of the above exception, another exception occurred: -Traceback (most recent call last): - File "/home/travis/virtualenv/python3.5.6/bin/flake8", line 11, in - sys.exit(main()) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/cli.py", line 16, in main - app.run(argv) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 412, in run - self._run(argv) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 399, in _run - self.initialize(argv) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 381, in initialize - self.find_plugins() - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/main/application.py", line 197, in find_plugins - self.check_plugins.load_plugins() - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 434, in load_plugins - plugins = list(self.manager.map(load_plugin)) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 319, in map - yield func(self.plugins[name], *args, **kwargs) - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 432, in load_plugin - return plugin.load_plugin() - File "/home/travis/virtualenv/python3.5.6/lib/python3.5/site-packages/flake8/plugins/manager.py", line 189, in load_plugin - raise failed_to_load -flake8.exceptions.FailedToLoadPlugin: Flake8 failed to load plugin "pycodestyle.break_after_binary_operator" due to module 'pycodestyle' has no attribute 'break_after_binary_operator'. ([`73b3620`](https://github.com/python-zeroconf/python-zeroconf/commit/73b3620908cb5e2f54231692c17f6bbb8a42d09d)) - -* Drop flake8-blind-except - -Obsoleted by pycodestyle 2.1's E722. ([`e3b7e40`](https://github.com/python-zeroconf/python-zeroconf/commit/e3b7e40af52d05264794e2e4d37dfdb1c5d3814a)) - -* Test with PyPy 3.5 5.10.1 ([`51a6f70`](https://github.com/python-zeroconf/python-zeroconf/commit/51a6f7081bd5590ca5ea5418b39172714b7ef1fe)) - -* Fix a changelog typo ([`e08db28`](https://github.com/python-zeroconf/python-zeroconf/commit/e08db282edd8459e35d17ae4e7278106056a0c94)) - - -## v0.21.3 (2018-09-21) - -### Unknown - -* Prepare release 0.21.3 ([`059530d`](https://github.com/python-zeroconf/python-zeroconf/commit/059530d075fe1575ebbab535be67ac7d5ae7caed)) - -* Actually allow underscores in incoming service names - -This was meant to be released earlier, but I failed to merge part of my -patch. - -Fixes: ff4a262adc69 ("Allow underscores in incoming service names") -Closes #102 ([`ae3bd51`](https://github.com/python-zeroconf/python-zeroconf/commit/ae3bd517d84aae631db1cc294caf22541a7f4bd5)) - - -## v0.21.2 (2018-09-20) - -### Unknown - -* Prepare release 0.21.2 ([`af33c83`](https://github.com/python-zeroconf/python-zeroconf/commit/af33c83e72d6fa4171342f78d15b2f28038f1318)) - -* Fix typing-related TypeError - -Older typing versions don't allow what we did[1]. We don't really need -to be that precise here anyway. - -The error: - - $ python - Python 3.5.2 (default, Nov 23 2017, 16:37:01) - [GCC 5.4.0 20160609] on linux - Type "help", "copyright", "credits" or "license" for more information. - >>> import zeroconf - Traceback (most recent call last): - File "", line 1, in - File "/scraper/venv/lib/python3.5/site-packages/zeroconf.py", line 320, in - OptionalExcInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]] - File "/usr/lib/python3.5/typing.py", line 649, in __getitem__ - return Union[arg, type(None)] - File "/usr/lib/python3.5/typing.py", line 552, in __getitem__ - dict(self.__dict__), parameters, _root=True) - File "/usr/lib/python3.5/typing.py", line 512, in __new__ - for t2 in all_params - {t1} if not isinstance(t2, TypeVar)): - File "/usr/lib/python3.5/typing.py", line 512, in - for t2 in all_params - {t1} if not isinstance(t2, TypeVar)): - File "/usr/lib/python3.5/typing.py", line 1077, in __subclasscheck__ - if super().__subclasscheck__(cls): - File "/usr/lib/python3.5/abc.py", line 225, in __subclasscheck__ - for scls in cls.__subclasses__(): - TypeError: descriptor '__subclasses__' of 'type' object needs an argument - -Closes #141 -Fixes: 1f33c4f8a805 ("Introduce some static type analysis to the codebase") - -[1] https://github.com/python/typing/issues/266 ([`627c22e`](https://github.com/python-zeroconf/python-zeroconf/commit/627c22e19166c123244567410adc390ed368eca7)) - - -## v0.21.1 (2018-09-17) - -### Unknown - -* Prepare release 0.21.1 ([`1684a46`](https://github.com/python-zeroconf/python-zeroconf/commit/1684a46d57a437fc8cc7b5887d51440424c6ded5)) - -* Bringing back compatibility with python 3.4 (#140) - -The latest release of zeroconf in PyPI (0.21.0) breaks compatibility with python 3.4 due to an unstated dependency on the typing package. ([`919191c`](https://github.com/python-zeroconf/python-zeroconf/commit/919191ca266d8d589ad33cc6dd2c197f75092634)) - - -## v0.21.0 (2018-09-16) - -### Unknown - -* Prepare release 0.21.0 ([`b03cee3`](https://github.com/python-zeroconf/python-zeroconf/commit/b03cee348973469e9ebfce6e9b0e0a367c146401)) - -* Allow underscores in incoming service names - -There are real world cases of services broadcasting names with -underscores in them so tough luck, let's accept those to be compatible. -Registering service names with underscores in them continues to be -disallowed. - -Closes https://github.com/jstasiak/python-zeroconf/issues/102 ([`ff4a262`](https://github.com/python-zeroconf/python-zeroconf/commit/ff4a262adc6926905c71e2952b3159b84a974d02)) - -* Don't mention unsupported Python versions ([`208ec1b`](https://github.com/python-zeroconf/python-zeroconf/commit/208ec1ba58a6ebf7160a760feffe62cf366137e5)) - -* using ifaddr instead of netifaces as ifaddr is a pure python lib ([`7c0500e`](https://github.com/python-zeroconf/python-zeroconf/commit/7c0500ee19869ce0e85e58a26b8fdb0868e0b142)) - -* Show that we actually support Python 3.7 - -We can't just add Python 3.7 like earlier versions because Travis -doesn't support it at the moment[1]. - -[1] https://github.com/travis-ci/travis-ci/issues/9815 ([`418b4b8`](https://github.com/python-zeroconf/python-zeroconf/commit/418b4b814e6483a20a5cac2178a2cd815d5b91c0)) - -* Introduce some static type analysis to the codebase - -The main purpose of this change is to make the code easier to read and -explore. Preventing some classes of bugs is a bonus. - -On top of just adding type hints and enabling mypy to verify them the -following changes were needed: -* casts in places where we know what we're doing but mypy can't know it -* RecordUpdateListener interfaces extracted out of ServiceBrowser and - ServiceInfo classes so that we have a common name we can use in places - where we only need an instance of one of those classes to call to call - update_record() on it. This way we can keep mypy happy -* assert isinstance(...) blocks to provide hints for mypy as to what - concrete types we're dealing with -* some local type mixing removed (previously we'd first assign a value - of one type to a variable and then overwrite with another type) -* explicit "return None" in case of function that returns optionally - - mypy enforces explicit return in this case ([`1f33c4f`](https://github.com/python-zeroconf/python-zeroconf/commit/1f33c4f8a8050cdfb051c0da7ebe80a9ff24cf25)) - -* Fix a logging call - -The format string expects three parameters, one of them was accidentally -passed to the log_warning_once() method instead. - -Fixes: aa1f48433cbd ("Improve test coverage, and fix issues found") ([`23fdcce`](https://github.com/python-zeroconf/python-zeroconf/commit/23fdcce35fa020d09267e6fa57cf21cfb744a2c4)) - -* Fix UTF-8 multibyte name compression ([`e11700f`](https://github.com/python-zeroconf/python-zeroconf/commit/e11700ff9ea9eb429c701dfb73c4cf2c45994015)) - -* Remove some legacy cruft - -The latest versions of flake8 and flake8-import-order can be used just -fine now (they've been ok for some time). - -Since with google style flake8-import-order would generate more issues -than with the cryptography style I decided to switch and fix one thing -it complained about. - -We switch to pycodestyle instead of pinned pep8 version as that pep8 -version can't be installed with latest flake8 and the name of the -package has been changed to pycodestyle. We still pin the version though -as there's a bad interaction between the latest pycodestyle and the -latest flake8. ([`6fe8132`](https://github.com/python-zeroconf/python-zeroconf/commit/6fe813212f46576cf305c17ee815536a83128fce)) - -* Fix UnboundLocalError for count after loop - -This code throws an `UnboundLocalError` as `count` doesn't exist in the `else` branch of the for loop. ([`42c8662`](https://github.com/python-zeroconf/python-zeroconf/commit/42c866298725a8e9667bf1230be845e856cb382a)) - -* examples: Add an example of resolving a known service by service name - -To use: -* `avahi-publish-service -s 'My Service Name' _test._tcp 0` -* `./examples/resolver.py` should print a `ServiceInfo` -* Kill the `avahi-publish-service` process -* `./examples/resolver.py` should print `None` - -Signed-off-by: Simon McVittie ([`703d971`](https://github.com/python-zeroconf/python-zeroconf/commit/703d97150de1c74b7c1a62b59c1ff7081dec8256)) - -* Handle Interface Quirck to make it work on WSL (Windows Service for Linux) ([`374f45b`](https://github.com/python-zeroconf/python-zeroconf/commit/374f45b783caf35b26f464130fbd1ff62591af2e)) - -* Make some variables PEP 8-compatible - -Previously pep8-naming would complain about those: - -test_zeroconf.py:221:10: N806 variable 'numQuestions' in function should be lowercase - (numQuestions, numAnswers, numAuthorities, ([`49fc106`](https://github.com/python-zeroconf/python-zeroconf/commit/49fc1067245b2d3a7bcc1e7611f36ba8d9a36598)) - -* Fix flake8 (#131) - -* flake8 and therefore Travis should be happy now - -* attempt to fix flake8 - -* happy flake8 ([`53bc65a`](https://github.com/python-zeroconf/python-zeroconf/commit/53bc65af14ed979a5234bfa03c1295a2b27f6e40)) - -* implementing unicast support (#124) - -* implementing unicast support - -* use multicast=False for outgoing dns requests in unicast mode ([`826c961`](https://github.com/python-zeroconf/python-zeroconf/commit/826c9619797e4cf1f2c39b95ed1c93faed7eee2a)) - -* Remove unwanted whitespace ([`d0d1cfb`](https://github.com/python-zeroconf/python-zeroconf/commit/d0d1cfbb31f0ea6bd08b0c8ffa97ba3d7604bccc)) - -* Fix TTL handling for published service, align default TTL with RFC6762 (#113) - -Honor TTL passed in service registration -Set default TTL to 120 s as recommended by RFC6762 ([`14e3ad5`](https://github.com/python-zeroconf/python-zeroconf/commit/14e3ad5f15f5a0f5235ad7dbb22924b4b5ae1c77)) - -* add import error for Python <= 3.3 (#123) ([`fe62ba3`](https://github.com/python-zeroconf/python-zeroconf/commit/fe62ba31a8ab05a948ed6036dc319b1a1fa14e66)) - - -## v0.20.0 (2018-02-21) - -### Unknown - -* Release version 0.20.0 ([`0622570`](https://github.com/python-zeroconf/python-zeroconf/commit/0622570645116b0c45ee03d38b7b308be2026bd4)) - -* Add some missing release information ([`5978bdb`](https://github.com/python-zeroconf/python-zeroconf/commit/5978bdbdab017d06ea496ea6d7c66c672751b255)) - -* Drop support for Python 2 and 3.3 - -This simplifies the code slightly, reduces the number of dependencies -and otherwise speeds up the CI process. If someone *really* needs to use -really old Python they have the option of using older versions of the -package. ([`f22f421`](https://github.com/python-zeroconf/python-zeroconf/commit/f22f421e4e6bf1ca7671b1eb540ba09fbf1e04b1)) - -* Add license and readme file to source tarball (#108) - -Closes #97 ([`6ad04a5`](https://github.com/python-zeroconf/python-zeroconf/commit/6ad04a5d7f6d63c1f48b5948b6ade0e56cafe258)) - -* Allow the usage of newer netifaces in development - -We're being consistent with c5e1f65c19b2f63a09b6517f322d600911fa1e13 -here. ([`7123f8e`](https://github.com/python-zeroconf/python-zeroconf/commit/7123f8ed7dfd9277245748271d8870f18299b035)) - -* Correct broken __eq__ in child classes to DNSRecord ([`4d6dd73`](https://github.com/python-zeroconf/python-zeroconf/commit/4d6dd73a8313b81bbfef8b074d6fe4878bce4f74)) - -* Refresh ServiceBrowser entries already when 'stale' -Updated integration testcase to test for this. ([`37c5211`](https://github.com/python-zeroconf/python-zeroconf/commit/37c5211980548ab701bba725feeb5395ed1af0a7)) - -* Add new records first in cache entry instead of last (#110) - -* Add new records first in cache entry instead of last - -* Added DNSCache unit test ([`8101b55`](https://github.com/python-zeroconf/python-zeroconf/commit/8101b557199c4d3d001c75a717eafa4d5544142f)) - - -## v0.19.1 (2017-06-13) - -### Unknown - -* Use more recent PyPy3 on Travis CI - -The default PyPy3 is really old (implements Python 3.2) and some -packages won't cooperate with it anymore. ([`d0e4712`](https://github.com/python-zeroconf/python-zeroconf/commit/d0e4712eaa696ff13470b719cb6842260a3ada11)) - -* Release version 0.19.1 ([`1541191`](https://github.com/python-zeroconf/python-zeroconf/commit/1541191090a92ef23b8e3747933c95f7233aa2de)) - -* Allow newer netifaces releases - -The bug that was concerning us[1] is fixed now. - -[1] https://bitbucket.org/al45tair/netifaces/issues/39/netmask-is-always-255255255255 ([`c5e1f65`](https://github.com/python-zeroconf/python-zeroconf/commit/c5e1f65c19b2f63a09b6517f322d600911fa1e13)) - - -## v0.19.0 (2017-03-21) - -### Unknown - -* Release version 0.19.0 ([`ecadb8c`](https://github.com/python-zeroconf/python-zeroconf/commit/ecadb8c30cd8e75da5b6d3e0e93d024f013dbfa2)) - -* Fix a whitespace issue flake8 doesn't like ([`87aa4e5`](https://github.com/python-zeroconf/python-zeroconf/commit/87aa4e587221e982902233ed2c8990ed27a2290f)) - -* Remove outdated example ([`d8686b5`](https://github.com/python-zeroconf/python-zeroconf/commit/d8686b5642d66b2c9ecc6f40b92e1a1a28279f79)) - -* Remove outdated comment ([`5aa6e85`](https://github.com/python-zeroconf/python-zeroconf/commit/5aa6e8546438d76b3fba5e91f9e4d4e3a3901757)) - -* Work around netifaces Windows netmask bug ([`6231d6d`](https://github.com/python-zeroconf/python-zeroconf/commit/6231d6d48d89240d95de9644570baf1b07ab04b0)) - - -## v0.18.0 (2017-02-03) - -### Unknown - -* Release version 0.18.0 ([`48b1949`](https://github.com/python-zeroconf/python-zeroconf/commit/48b19498724825237d3002ee7681b6296c625b12)) - -* Add a missing changelog entry ([`5343510`](https://github.com/python-zeroconf/python-zeroconf/commit/53435104d5fb29847ac561f58e16cb48dd97b9f8)) - -* Handle select errors when closing Zeroconf - -Based on a pull request by someposer[1] (code adapted to work on -Python 3). - -Fixes two pychromecast issues[2][3]. - -[1] https://github.com/jstasiak/python-zeroconf/pull/88 -[2] https://github.com/balloob/pychromecast/issues/59 -[3] https://github.com/balloob/pychromecast/issues/120 ([`6e229f2`](https://github.com/python-zeroconf/python-zeroconf/commit/6e229f2714c8aff6555dfee2bdff34bda980a0c3)) - -* Explicitly support Python 3.6 ([`0a5ea31`](https://github.com/python-zeroconf/python-zeroconf/commit/0a5ea31543941033bcb4b2cb76fa7e125cb33550)) - -* Pin flake8 because flake8-import-order is pinned ([`9f0d8fe`](https://github.com/python-zeroconf/python-zeroconf/commit/9f0d8fe87dedece1365149911ce9587482fe1501)) - -* Drop Python 2.6 support, no excuse to use 2.6 these days ([`56ea542`](https://github.com/python-zeroconf/python-zeroconf/commit/56ea54245eeab9d544d96c38d136f9f47eedcda4)) - - -## v0.17.7 (2017-02-01) - -### Unknown - -* Prepare the 0.17.7 release ([`376e011`](https://github.com/python-zeroconf/python-zeroconf/commit/376e011ad60c051f27632c77e6d50b64cf1defec)) - -* Merge pull request #77 from stephenrauch/fix-instance-name-with-dot - -Allow dots in service instance name ([`9035c6a`](https://github.com/python-zeroconf/python-zeroconf/commit/9035c6a246b6856b5087b1bba9a9f3ce5873fcda)) - -* Allow dots in service instance name ([`e46af83`](https://github.com/python-zeroconf/python-zeroconf/commit/e46af83d35b4430d4577481b371d569797427858)) - -* Merge pull request #75 from stephenrauch/Fix-name-change - -Fix for #29 ([`136dce9`](https://github.com/python-zeroconf/python-zeroconf/commit/136dce985fd66c81159d48b5f40e44349d1070ef)) - -* Fix/Implement duplicate name change (Issue 29) ([`788a48f`](https://github.com/python-zeroconf/python-zeroconf/commit/788a48f78466e048bdfc3028618bc4eaf807ef5b)) - -* some docs, cleanup and a couple of small test cases ([`b629ffb`](https://github.com/python-zeroconf/python-zeroconf/commit/b629ffb9c860a30366fa83b71487b546d6edd15b)) - -* Merge pull request #73 from stephenrauch/simplify-and-fix-pr-70 - -Simplify and fix PR 70 ([`6b67c0d`](https://github.com/python-zeroconf/python-zeroconf/commit/6b67c0d562866e63b81d1ec1c7f540c56244ade1)) - -* Simplify and fix PR 70 ([`2006cdd`](https://github.com/python-zeroconf/python-zeroconf/commit/2006cddf99377f43b528fbafea7d98be9d6282f0)) - -* Merge pull request #72 from stephenrauch/Catch-and-log-sendto-exceptions - -Catch and log sendto() exceptions ([`c3f563f`](https://github.com/python-zeroconf/python-zeroconf/commit/c3f563f6d108d46732a380b7912f8f5c23d5e548)) - -* Catch and log sendto() exceptions ([`0924310`](https://github.com/python-zeroconf/python-zeroconf/commit/0924310415b79f0fa2523494d8a60803ec295e09)) - -* Merge pull request #71 from stephenrauch/improved-test-coverage - -Improve test coverage, and fix issues found ([`254c207`](https://github.com/python-zeroconf/python-zeroconf/commit/254c2077f727d5e130aab2aaec111d58c134bd79)) - -* Improve test coverage, and fix issues found ([`aa1f484`](https://github.com/python-zeroconf/python-zeroconf/commit/aa1f48433cbd4dbf52565ec0c2635e5d52a37086)) - -* Merge pull request #70 from stephenrauch/Limit-size-of-packet - -Limit the size of the packet that can be built ([`208e221`](https://github.com/python-zeroconf/python-zeroconf/commit/208e2219a1268e637e3cf02e1838cb94a6de2f31)) - -* Limit the size of the packet that can be built ([`8355c85`](https://github.com/python-zeroconf/python-zeroconf/commit/8355c8556929fcdb777705c97fc99de6012367b4)) - -* Merge pull request #69 from stephenrauch/name-compression - -Help for oversized packets ([`5d9f40d`](https://github.com/python-zeroconf/python-zeroconf/commit/5d9f40de1a8549633cb5592fafc34d34df172965)) - -* Implement Name Compression ([`59877eb`](https://github.com/python-zeroconf/python-zeroconf/commit/59877ebb1b20ccd2747a0601e30329162ddcba4c)) - -* Drop oversized packets in send() ([`035605a`](https://github.com/python-zeroconf/python-zeroconf/commit/035605ab000fc8a8af94b4b9e1be9b81880b6bca)) - -* Add exception handler for oversized packets ([`af19c12`](https://github.com/python-zeroconf/python-zeroconf/commit/af19c12ec2286ee49e789a11599551dc43391383)) - -* Add QuietLogger mixin ([`0b77872`](https://github.com/python-zeroconf/python-zeroconf/commit/0b77872f7bb06ba6949c69bbfb70e8ae21f8ff9b)) - -* Improve service name validation error messages ([`fad66ca`](https://github.com/python-zeroconf/python-zeroconf/commit/fad66ca696530d39d8d5ae598e1724077eba8a5e)) - -* Merge pull request #68 from stephenrauch/Handle-dnsincoming-exceptions - -Handle DNSIncoming exceptions ([`6c0a32d`](https://github.com/python-zeroconf/python-zeroconf/commit/6c0a32d6e4bd7be0b7573b95a5325b19dfd509d2)) - -* Make all test cases localhost only ([`080d0c0`](https://github.com/python-zeroconf/python-zeroconf/commit/080d0c09f1e58d4f8c430dac513948e5919e3f3b)) - -* Handle DNS Incoming Exception - -This fixes a regression from removal of some overly broad exception -handling in 0.17.6. This change adds an explicit handler for -DNSIncoming(). Will also log at warn level the first time it sees a -particular parsing exception. ([`061a2aa`](https://github.com/python-zeroconf/python-zeroconf/commit/061a2aa3c6e8a7c954a313c8a7d396f26f544c2b)) - - -## v0.17.6 (2016-07-08) - -### Testing - -* test: added test for DNS-SD subtype discovery ([`914241b`](https://github.com/python-zeroconf/python-zeroconf/commit/914241b92c3097669e1e8c1a380f6c2f23a14cf8)) - -### Unknown - -* Fix readme to valid reStructuredText, ([`94570b7`](https://github.com/python-zeroconf/python-zeroconf/commit/94570b730aaab606db820b9c4d48b1c313fdaa98)) - -* Prepare release 0.17.6 ([`e168a6f`](https://github.com/python-zeroconf/python-zeroconf/commit/e168a6fa5486d92114fb02d4c40b36f8298a022f)) - -* Merge pull request #61 from stephenrauch/add-python3.5 - -Add python 3.5 to Travis ([`617d9fd`](https://github.com/python-zeroconf/python-zeroconf/commit/617d9fd0db5bef350eaebd13cfcc73803900ad24)) - -* Add python 3.5 to Travis ([`6198e89`](https://github.com/python-zeroconf/python-zeroconf/commit/6198e8909b968430ddac9261f4dd9c508d96db65)) - -* Merge pull request #60 from stephenrauch/delay_ServiceBrowser_connect - -Delay connecting ServiceBrowser() until it is running ([`56d9ac1`](https://github.com/python-zeroconf/python-zeroconf/commit/56d9ac13381a3ae205cb2b9339981a50f0a2eb62)) - -* Delay connecting ServiceBrowser() until it is running ([`6d1370c`](https://github.com/python-zeroconf/python-zeroconf/commit/6d1370cc2aa6d2c125aa924342e224b6b92ef8d9)) - -* Merge pull request #57 from herczy/master - -resolve issue #56: service browser initialization race ([`0225a18`](https://github.com/python-zeroconf/python-zeroconf/commit/0225a18957a26855720d7ab002f3983cb9d76e0e)) - -* resolve issue #56: service browser initialization race ([`1567016`](https://github.com/python-zeroconf/python-zeroconf/commit/15670161c597bc035c0e9411d0bb830b9520589f)) - -* Merge pull request #58 from strahlex/subtype-test - -added test for DNS-SD subtype discovery ([`4a569fe`](https://github.com/python-zeroconf/python-zeroconf/commit/4a569fe389d2fb5fd4b4f294ae9ebc0e38164e4a)) - -* Merge pull request #53 from stephenrauch/validate_service_names - -Validate service names ([`76a5e99`](https://github.com/python-zeroconf/python-zeroconf/commit/76a5e99f2e772a9462d0f4b3ab4c80f1b0a3b542)) - -* Service Name Validation - -This change validates service, instance and subtype names against -rfc6763. - -Also adds test code for subtypes and provides a fix for issue 37. ([`88fa059`](https://github.com/python-zeroconf/python-zeroconf/commit/88fa0595cd880b6d82ac8580512461e64eb32d6b)) - -* Test Case and fixes for DNSHInfo (#49) - -* Fix ability for a cache lookup to match properly - -When querying for a service type, the response is processed. During the -processing, an info lookup is performed. If the info is not found in -the cache, then a query is sent. Trouble is that the info requested is -present in the same packet that triggered the lookup, and a query is not -necessary. But two problems caused the cache lookup to fail. - -1) The info was not yet in the cache. The call back was fired before -all answers in the packet were cached. - -2) The test for a cache hit did not work, because the cache hit test -uses a DNSEntry as the comparison object. But some of the objects in -the cache are descendents of DNSEntry and have their own __eq__() -defined which accesses fields only present on the descendent. Thus the -test can NEVER work since the descendent's __eq__() will be used. - -Also continuing the theme of some other recent pull requests, add three -_GLOBAL_DONE tests to avoid doing work after the attempted stop, and -thus avoid generating (harmless, but annoying) exceptions during -shutdown - -* Remove unnecessary packet send in ServiceInfo.request() - -When performing an info query via request(), a listener is started, and -a packet is formed. As the packet is formed, known answers are taken -from the cache and placed into the packet. Then the packet is sent. -The packet is self received (via multicast loopback, I assume). At that -point the listener is fired and the answers in the packet are propagated -back to the object that started the request. This is a really long way -around the barn. - -The PR queries the cache directly in request() and then calls -update_record(). If all of the information is in the cache, then no -packet is formed or sent or received. This approach was taken because, -for whatever reason, the reception of the packets on windows via the -loopback was proving to be unreliable. The method has the side benefit -of being a whole lot faster. - -This PR also incorporates the joins() from PR #30. In addition it moves -the two joins() in close() to their own thread because they can take -quite a while to execute. - -* Fix locking race condition in Engine.run() - -This fixes a race condition in which the receive engine was waiting -against its condition variable under a different lock than the one it -used to determine if it needed to wait. This was causing the code to -sometimes take 5 seconds to do anything useful. - -When fixing the race condition, decided to also fix the other -correctness issues in the loop which was likely causing the errors that -led to the inclusion of the 'except Exception' catch all. This in turn -allowed the use of EBADF error due to closing the socket during exit to -be used to get out of the select in a timely manner. - -Finally, this allowed reorganizing the shutdown code to shutdown from -the front to the back. That is to say, shutdown the recv socket first, -which then allows a clean join with the engine thread. After the engine -thread exits most everything else is inert as all callbacks have been -unwound. - -* Remove a now invalid test case - -With the restructure of shutdown, Listener() now needs to throw EBADF on -a closed socket to allow a timely and graceful shutdown. - -* Shutdown the service listeners in an organized fashion - -Also adds names to the various threads to make debugging easier. - -* Improve test coverage - -Add more needed shutdown cleanup found via additional test coverage. - -Force timeout calculation from milli to seconds to use floating point. - -* init ServiceInfo._properties - -* Add query support and test case for _services._dns-sd._udp.local. - -* pep8 cleanup - -* Add testcase and fixes for HInfo Record Generation - -The DNSHInfo packet generation code was broken. There was no test case for that -functionality, and adding a test case showed four issues. Two of which were -relative to PY3 string, one of which was a typoed reference to an attribute, -and finally the two fields present in the HInfo record were using the wrong -encoding, which is what necessitated the change from write_string() to -write_character_string(). ([`6b39c70`](https://github.com/python-zeroconf/python-zeroconf/commit/6b39c70fa1ed7cfac89e02e2b3764a9038b87267)) - -* Merge pull request #48 from stephenrauch/Find-Service-Types - -Find service types ([`1dfc40f`](https://github.com/python-zeroconf/python-zeroconf/commit/1dfc40f4da145a55d60a952df90301ee0e5d65c4)) - -* Add query support and test case for _services._dns-sd._udp.local. ([`cfbb157`](https://github.com/python-zeroconf/python-zeroconf/commit/cfbb1572e44c4d8af1b50cb62abc0d426fc8e3ea)) - -* Merge pull request #45 from stephenrauch/master - -Multiple fixes to speed up querys and remove exceptions at shutdown ([`183cd81`](https://github.com/python-zeroconf/python-zeroconf/commit/183cd81d9274bf28c642314df2f9e32f1f60020b)) - -* init ServiceInfo._properties ([`d909942`](https://github.com/python-zeroconf/python-zeroconf/commit/d909942e2c9479819e9113ffb3a354b1d99d6814)) - -* Improve test coverage - -Add more needed shutdown cleanup found via additional test coverage. - -Force timeout calculation from milli to seconds to use floating point. ([`75232cc`](https://github.com/python-zeroconf/python-zeroconf/commit/75232ccf28a820ee723db072951078eba31145a5)) - -* Shutdown the service listeners in an organized fashion - -Also adds names to the various threads to make debugging easier. ([`ad3c248`](https://github.com/python-zeroconf/python-zeroconf/commit/ad3c248e4b67d5d2e9a4448a56b4e4648284ecd4)) - -* Remove a now invalid test case - -With the restructure of shutdown, Listener() now needs to throw EBADF on -a closed socket to allow a timely and graceful shutdown. ([`7bbee59`](https://github.com/python-zeroconf/python-zeroconf/commit/7bbee590e553a1ff0e4dde3b1fdcf614b7e1ecd5)) - -* Fix locking race condition in Engine.run() - -This fixes a race condition in which the receive engine was waiting -against its condition variable under a different lock than the one it -used to determine if it needed to wait. This was causing the code to -sometimes take 5 seconds to do anything useful. - -When fixing the race condition, decided to also fix the other -correctness issues in the loop which was likely causing the errors that -led to the inclusion of the 'except Exception' catch all. This in turn -allowed the use of EBADF error due to closing the socket during exit to -be used to get out of the select in a timely manner. - -Finally, this allowed reorganizing the shutdown code to shutdown from -the front to the back. That is to say, shutdown the recv socket first, -which then allows a clean join with the engine thread. After the engine -thread exits most everything else is inert as all callbacks have been -unwound. ([`8a110f5`](https://github.com/python-zeroconf/python-zeroconf/commit/8a110f58b02825100f5bdb56c119495ae42ae54c)) - -* Remove unnecessary packet send in ServiceInfo.request() - -When performing an info query via request(), a listener is started, and -a packet is formed. As the packet is formed, known answers are taken -from the cache and placed into the packet. Then the packet is sent. -The packet is self received (via multicast loopback, I assume). At that -point the listener is fired and the answers in the packet are propagated -back to the object that started the request. This is a really long way -around the barn. - -The PR queries the cache directly in request() and then calls -update_record(). If all of the information is in the cache, then no -packet is formed or sent or received. This approach was taken because, -for whatever reason, the reception of the packets on windows via the -loopback was proving to be unreliable. The method has the side benefit -of being a whole lot faster. - -This PR also incorporates the joins() from PR #30. In addition it moves -the two joins() in close() to their own thread because they can take -quite a while to execute. ([`c49145c`](https://github.com/python-zeroconf/python-zeroconf/commit/c49145c35de09b2631d8a2b4751d787a6b4dc904)) - -* Fix ability for a cache lookup to match properly - -When querying for a service type, the response is processed. During the -processing, an info lookup is performed. If the info is not found in -the cache, then a query is sent. Trouble is that the info requested is -present in the same packet that triggered the lookup, and a query is not -necessary. But two problems caused the cache lookup to fail. - -1) The info was not yet in the cache. The call back was fired before -all answers in the packet were cached. - -2) The test for a cache hit did not work, because the cache hit test -uses a DNSEntry as the comparison object. But some of the objects in -the cache are descendents of DNSEntry and have their own __eq__() -defined which accesses fields only present on the descendent. Thus the -test can NEVER work since the descendent's __eq__() will be used. - -Also continuing the theme of some other recent pull requests, add three -_GLOBAL_DONE tests to avoid doing work after the attempted stop, and -thus avoid generating (harmless, but annoying) exceptions during -shutdown ([`d8562fd`](https://github.com/python-zeroconf/python-zeroconf/commit/d8562fd3546d6cd27b1ba9e95105ea534649a43e)) - - -## v0.17.5 (2016-03-14) - -### Unknown - -* Prepare release 0.17.5 ([`f33b8f9`](https://github.com/python-zeroconf/python-zeroconf/commit/f33b8f9c182245b14b9b73a86aefedcee4520eb5)) - -* resolve issue #38: size change during iteration ([`fd9d531`](https://github.com/python-zeroconf/python-zeroconf/commit/fd9d531f294e7fa5b9b934f192b061f56eaf1d37)) - -* Installation on system with ASCII encoding - -The default open function in python2 made a best effort to open text files of any encoding. -After 3.0 the encoding has to be set correctly and it defaults to the user preferences. ([`6007537`](https://github.com/python-zeroconf/python-zeroconf/commit/60075379d57664f94fa41a96dea7c7c64489ef3d)) - -* Revert "Switch from netifaces to psutil" - -psutil doesn't seem to work on pypy3: - - Traceback (most recent call last): - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/failure.py", line 39, in runTest - raise self.exc_val.with_traceback(self.tb) - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/loader.py", line 414, in loadTestsFromName - addr.filename, addr.module) - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/importer.py", line 47, in importFromPath - return self.importFromDir(dir_path, fqname) - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/nose/importer.py", line 94, in importFromDir - mod = load_module(part_fqname, fh, filename, desc) - File "/home/travis/build/jstasiak/python-zeroconf/test_zeroconf.py", line 17, in - import zeroconf as r - File "/home/travis/build/jstasiak/python-zeroconf/zeroconf.py", line 35, in - import psutil - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/__init__.py", line 62, in - from . import _pslinux as _psplatform - File "/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_pslinux.py", line 23, in - from . import _psutil_linux as cext - ImportError: unable to load extension module - '/home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_psutil_linux.pypy3-24.so': - /home/travis/virtualenv/pypy3-2.4.0/site-packages/psutil/_psutil_linux.pypy3-24.so: undefined symbol: PyModule_GetState - -Additionally netifaces turns out to be possible to install on Python 3, -therefore making it necessary to investigate the original issue. - -This reverts commit dd907f2eed3768a3c1e3889af84b5dbeb700a1e7. ([`6349d19`](https://github.com/python-zeroconf/python-zeroconf/commit/6349d197b442209331a0ff8676541967f7142991)) - -* fix issue #23 race-condition on ServiceBrowser startup ([`30bd44f`](https://github.com/python-zeroconf/python-zeroconf/commit/30bd44f04f94a9b26622a7213dd9950ae57df21c)) - -* Switch from netifaces to psutil - -netifaces installation on Python 3.x is broken and there doesn't seem to -be any plan to release a working version on PyPI, instead of using its -fork I decided to use another package providing the required -information. - -This closes https://github.com/jstasiak/python-zeroconf/issues/31 - -[1] https://bitbucket.org/al45tair/netifaces/issues/13/0104-install-is-broken-on-python-3x ([`dd907f2`](https://github.com/python-zeroconf/python-zeroconf/commit/dd907f2eed3768a3c1e3889af84b5dbeb700a1e7)) - -* Fix multicast TTL and LOOP options on OpenBSD - -IP_MULTICAST_TTL and IP_MULTICAST_LOOP socket options on OpenBSD don't -accept int, only unsigned char. Otherwise you will get an error: -[Errno 22] Invalid argument. ([`0f46a06`](https://github.com/python-zeroconf/python-zeroconf/commit/0f46a0609931e6dc299c0473312e434e84abe7b0)) - - -## v0.17.4 (2015-09-22) - -### Unknown - -* Prepare release 0.17.4 ([`0b9093d`](https://github.com/python-zeroconf/python-zeroconf/commit/0b9093de863928d7f13092aaf2be1f0a33f4ead2)) - -* Support kernel versions <3.9 - -added catch of OSError -added catch of socket.error for python2 ([`023426e`](https://github.com/python-zeroconf/python-zeroconf/commit/023426e0f8982640f46bca3dfcd3abeee2cb832f)) - -* Make it explicit who says what in the readme ([`ddb1048`](https://github.com/python-zeroconf/python-zeroconf/commit/ddb10485ef17aec3f37ef70dcb37af167271bfe1)) - - -## v0.17.3 (2015-08-19) - -### Unknown - -* Make the package's status explicit ([`f29c0f4`](https://github.com/python-zeroconf/python-zeroconf/commit/f29c0f475be76f70ecbb1586deb4618180dd1969)) - -* Prepare release 0.17.3 ([`9c3a81a`](https://github.com/python-zeroconf/python-zeroconf/commit/9c3a81af84c3450459795e5fc5142300f9680804)) - -* Add a DNSText __repr__ test - -The test helps making sure the situation fixed by -e8299c0527c965f83c1326b18e484652a9eb829c doesn't happen again. ([`c7567d6`](https://github.com/python-zeroconf/python-zeroconf/commit/c7567d6b065d7460e2022b8cde5dd0b52a3828a7)) - -* Fix DNSText repr Python 3 issue - -Prevents following exception: -``` - File "/Users/paulus/dev/python/netdisco/lib/python3.4/site-packages/zeroconf.py", line 412, in __repr__ - return self.to_string(self.text[:7] + "...") -TypeError: can't concat bytes to str -``` ([`e8299c0`](https://github.com/python-zeroconf/python-zeroconf/commit/e8299c0527c965f83c1326b18e484652a9eb829c)) - - -## v0.17.2 (2015-07-12) - -### Unknown - -* Release version 0.17.2 ([`d1ee5ce`](https://github.com/python-zeroconf/python-zeroconf/commit/d1ee5ce7558060ea8d92f804172f67f960f814bb)) - -* Fix a typo, meant strictly lesser than 0.6 :< ([`dadbbfc`](https://github.com/python-zeroconf/python-zeroconf/commit/dadbbfc9e1787561981807d3e008433a107c1e5e)) - -* Restrict flake8-import-order version - -There seems to be a bug in 0.6.x, see -https://github.com/public/flake8-import-order/issues/42 ([`4435a2a`](https://github.com/python-zeroconf/python-zeroconf/commit/4435a2a4ae1c0b0877785f1a5047f65bb80a14bd)) - -* Use enum-compat instead of enum34 directly - -This is in order for the package's installation to work on Python 3.4+, -solves the same issue as -https://github.com/jstasiak/python-zeroconf/pull/22. ([`ba89455`](https://github.com/python-zeroconf/python-zeroconf/commit/ba894559f43fa6955989b92533c06fd8e8b92c74)) - - -## v0.17.1 (2015-04-10) - -### Unknown - -* Restrict pep8 version as something depends on it ([`4dbd04b`](https://github.com/python-zeroconf/python-zeroconf/commit/4dbd04b807813384108ff8e4cb5291c2560eed6b)) - -* Bump version to 0.17.1 ([`0b8936b`](https://github.com/python-zeroconf/python-zeroconf/commit/0b8936b94011c0783c7d0469b9ebae76cd4d1976)) - -* Fix some typos in the readme ([`7c64ebf`](https://github.com/python-zeroconf/python-zeroconf/commit/7c64ebf6129fb6c0c533a1fed618c9d5926d5100)) - -* Update README.rst ([`44fa62a`](https://github.com/python-zeroconf/python-zeroconf/commit/44fa62a738335781ecdd789ad636f82e6542ecd2)) - -* Update README.rst ([`a22484a`](https://github.com/python-zeroconf/python-zeroconf/commit/a22484af90c7c4cbdee849d2b75efab2772c3592)) - -* Getting an EADDRNOTAVAIL error when adding an address to the multicast group on windows. ([`93d34f9`](https://github.com/python-zeroconf/python-zeroconf/commit/93d34f925cd8913ff6836f9393cdce15679e4794)) - - -## v0.17.0 (2015-04-10) - -### Unknown - -* Do 0.17.0 release ([`a6d75b3`](https://github.com/python-zeroconf/python-zeroconf/commit/a6d75b3d63a0c13c63473910b832e6db12635e79)) - -* Advertise pypy3 support ([`4783611`](https://github.com/python-zeroconf/python-zeroconf/commit/4783611de72ac11bdbfea9e4324e58746a91e70a)) - -* Handle recent flake8 change ([`0009b5e`](https://github.com/python-zeroconf/python-zeroconf/commit/0009b5ea2bca77f395eb2bacc69d0dcfa5dd37dc)) - -* Describe recent changes ([`5c32a27`](https://github.com/python-zeroconf/python-zeroconf/commit/5c32a27a6ae0cccf7af25961cd98560a5173b065)) - -* Add pypy3 build ([`a298785`](https://github.com/python-zeroconf/python-zeroconf/commit/a298785cf63d26b184495f972c619d31515a1468)) - -* Restore old listener interface (and example) for now ([`c748294`](https://github.com/python-zeroconf/python-zeroconf/commit/c748294fdc6f3bf527f62d4c0cb76ace32890128)) - -* Fix test breakage ([`b5fb3e8`](https://github.com/python-zeroconf/python-zeroconf/commit/b5fb3e86a688f6161c1292ccdffeec9f455c1fbd)) - -* Prepare for new release ([`275a22b`](https://github.com/python-zeroconf/python-zeroconf/commit/275a22b997331d499526293b98faff11ca6edea5)) - -* Move self test example out of main module ([`ac5a63e`](https://github.com/python-zeroconf/python-zeroconf/commit/ac5a63ece96fbf9d64e41e7a4867cc1d8b2f6b96)) - -* Fix using binary strings as property values - -Previously it'd fall trough and set the value to False ([`b443027`](https://github.com/python-zeroconf/python-zeroconf/commit/b4430274ba8355ceaadc2d89a84752f1ac1485e7)) - -* Reformat a bit ([`2190818`](https://github.com/python-zeroconf/python-zeroconf/commit/219081860d28e49b1ae71a78e1a0da459689ab9c)) - -* Make examples' output quiet by default ([`08e0dc2`](https://github.com/python-zeroconf/python-zeroconf/commit/08e0dc2c7c1551ffa9a1e7297112b0f46b7ccc4e)) - -* Change ServiceBrowser interface experimentally ([`d162e54`](https://github.com/python-zeroconf/python-zeroconf/commit/d162e54c6aad175505028aa7beb8a1a0cb7a231d)) - -* Handle exceptions better ([`7cad7a4`](https://github.com/python-zeroconf/python-zeroconf/commit/7cad7a43179e3f547796b125e3ed8169ef3f4157)) - -* Add some debug logging ([`451c072`](https://github.com/python-zeroconf/python-zeroconf/commit/451c0729e2490ac6283010ddcbbcc723d86e6765)) - -* Make the code nicer - -This includes: - -* rearranging code to make it more readable -* catching KeyError instead of all exceptions and making it obvious what - can possibly raise there -* renaming things ([`df88670`](https://github.com/python-zeroconf/python-zeroconf/commit/df88670963e8c3a1f11a6af026b484ff4343d271)) - -* Remove redundant parentheses ([`3775c47`](https://github.com/python-zeroconf/python-zeroconf/commit/3775c47d8cf3c941603fa393265b86d05f61b915)) - -* Make examples nicer and make them show all logs ([`193ee64`](https://github.com/python-zeroconf/python-zeroconf/commit/193ee64d6212ff9a814b76b13f9ef46676025dc3)) - -* Remove duplicates from all interfaces list - -It has been mentioned in GH #12 that the list of all machine's network -interfaces can contain duplicates; it shouldn't break anything but -there's no need to open multiple sockets in such case. ([`af5e363`](https://github.com/python-zeroconf/python-zeroconf/commit/af5e363e7fcb392081dc98915defd93c5002c3fc)) - -* Don't fail when the netmask is unknown ([`463428f`](https://github.com/python-zeroconf/python-zeroconf/commit/463428ff8550a4f0e12b60e6f6a35efedca31271)) - -* Skip host only network interfaces - -On Ubuntu Linux treating such interface (network mask 255.255.255.255) -would result in: - -* EADDRINUSE "Address already in use" when trying to add multicast group - membership using IP_ADD_MEMBERSHIP -* success when setting the interface as outgoing multicast interface - using IP_MULTICAST_IF -* EINVAL "Invalid argument" when trying to send multicast datagram using - socket with that interface set as the multicast outgoing interface ([`b5e9e94`](https://github.com/python-zeroconf/python-zeroconf/commit/b5e9e944e6f3c990862b3b03831bb988579ed340)) - -* Configure logging during the tests ([`0208228`](https://github.com/python-zeroconf/python-zeroconf/commit/0208228d8c760f3672954f5434c2ea54d7fd4196)) - -* Use all network interfaces by default ([`193cf47`](https://github.com/python-zeroconf/python-zeroconf/commit/193cf47a1144afc9158f0075a886c1f754d96f18)) - -* Ignore EADDRINUSE when appropriate - -On some systems it's necessary to do so ([`0f7c64f`](https://github.com/python-zeroconf/python-zeroconf/commit/0f7c64f8cdacae34c227edd5da4f445ece12da89)) - -* Export Error and InterfaceChoice ([`500a76b`](https://github.com/python-zeroconf/python-zeroconf/commit/500a76bb1332fe34b45e681c767baddfbece4916)) - -* Fix ServiceInfo repr and text on Python 3 - -Closes #1 ([`f3fd4cd`](https://github.com/python-zeroconf/python-zeroconf/commit/f3fd4cd69e9707221d8bd5ee6b3bb86b0985f604)) - -* Add preliminary support for mulitple net interfaces ([`442a599`](https://github.com/python-zeroconf/python-zeroconf/commit/442a59967f7b0f2d5c2ef512874ad2ab13dedae4)) - -* Rationalize error handling when sending data ([`a0ee3d6`](https://github.com/python-zeroconf/python-zeroconf/commit/a0ee3d62db7b5350a21091e37824e187ebf99348)) - -* Make Zeroconf.socket private ([`78449ef`](https://github.com/python-zeroconf/python-zeroconf/commit/78449ef1e07dc68b63bb68038cb66f22e083fdfe)) - -* Refactor Condition usage to use context manager interface ([`8d32fa4`](https://github.com/python-zeroconf/python-zeroconf/commit/8d32fa4b12e1b52d72a7ba9588437c4c787e0ffd)) - -* Use six for Python 2/3 compatibility ([`f0c3979`](https://github.com/python-zeroconf/python-zeroconf/commit/f0c39797869175cf88d76c75d39835abb2052f88)) - -* Use six for Python 2/3 compatibility ([`54ed4b7`](https://github.com/python-zeroconf/python-zeroconf/commit/54ed4b79bb8de9523b5a5b74a79b01c8aa2291a7)) - -* Refactor version detection in the setup script - -This doesn't depend on zeroconf module being importable when setup is -ran ([`1c2205d`](https://github.com/python-zeroconf/python-zeroconf/commit/1c2205d5c9b364a825d51acd03add4de91cb645a)) - -* Drop "zero dependencies" feature ([`d8c1ec8`](https://github.com/python-zeroconf/python-zeroconf/commit/d8c1ec8ee13191e8ec4412770994f0676ace442c)) - -* Stop dropping multicast group membership - -It'll be taken care of by socket being closed ([`f6425d1`](https://github.com/python-zeroconf/python-zeroconf/commit/f6425d1d727edfa124264bcabeffd77397809965)) - -* Remove dead code ([`88f5a51`](https://github.com/python-zeroconf/python-zeroconf/commit/88f5a5193ba2ab0eefc99481ccc6a1b911d8dbea)) - -* Stop using Zeroconf.group attribute ([`903cb78`](https://github.com/python-zeroconf/python-zeroconf/commit/903cb78d3ff7bc8762bf23910562b8f5042c2f85)) - -* Remove some unused methods ([`80e8e10`](https://github.com/python-zeroconf/python-zeroconf/commit/80e8e1008bc28c8ab9ca966b89109146112d0edd)) - -* Refactor exception handling here ([`4b8f68b`](https://github.com/python-zeroconf/python-zeroconf/commit/4b8f68b39230bb9cc3c202395b58cc822b8fe862)) - -* Update README.rst ([`8f18609`](https://github.com/python-zeroconf/python-zeroconf/commit/8f1860956ee9c86b7ba095fc1293919933e1c0ad)) - -* Release as 0.16.0 ([`4e54b67`](https://github.com/python-zeroconf/python-zeroconf/commit/4e54b6738a490dcc7d2f9e7e1040c5da53727155)) - -* Tune logging ([`05c3c02`](https://github.com/python-zeroconf/python-zeroconf/commit/05c3c02044d2b4bff946e00803d0ddb2619f0927)) - -* Migrate from clazz to class_ ([`4a67e12`](https://github.com/python-zeroconf/python-zeroconf/commit/4a67e124cd8f8c4d19f8c6c4a455d075bb948362)) - -* Migrate more camel case names to snake case ([`92e4713`](https://github.com/python-zeroconf/python-zeroconf/commit/92e47132dc761a9a722caec261ae53de1785838f)) - -* Switch to snake case and clean up import order - -Closes #2 ([`5429748`](https://github.com/python-zeroconf/python-zeroconf/commit/5429748190950a5daf7e9cf91de824dfbd06ee7a)) - -* Rationalize exception handling a bit and setup logging ([`ada563c`](https://github.com/python-zeroconf/python-zeroconf/commit/ada563c5a1f6d7c54f2ae5c495503079c395438f)) - -* Update README.rst ([`47ff62b`](https://github.com/python-zeroconf/python-zeroconf/commit/47ff62bae1fd69ffd953c82bd480e4770bfee97b)) - -* Update README.rst ([`b290965`](https://github.com/python-zeroconf/python-zeroconf/commit/b290965ecd589ca4feb1f88a4232d1ec2725dc44)) - -* Create universal wheels ([`bf97c14`](https://github.com/python-zeroconf/python-zeroconf/commit/bf97c1459a9d91d6aa88d7bf34c5f8b4cd3cedc5)) +## v0.17.0 (2015-04-10) ## v0.15.1 (2014-07-10) - -### Unknown - -* Bump version to 0.15.1 ([`9e81863`](https://github.com/python-zeroconf/python-zeroconf/commit/9e81863de37e2ab972d5a76a1dc2d5c517f83cc6)) - -* Update README.rst ([`161743e`](https://github.com/python-zeroconf/python-zeroconf/commit/161743ea387c961d3554488239f93df4b39be18c)) - -* Add coverage badge to the readme ([`8502a7e`](https://github.com/python-zeroconf/python-zeroconf/commit/8502a7e1c9770a42e44b4f1beb34c887212e7d48)) - -* Send coverage to coveralls ([`1d90a9f`](https://github.com/python-zeroconf/python-zeroconf/commit/1d90a9f91f87753a1ea649ce5da1bc6a7da4013d)) - -* Fix socket.error handling - -This closes #4 ([`475e80b`](https://github.com/python-zeroconf/python-zeroconf/commit/475e80b90e96364a183c63f09fa3858f34aa3646)) - -* Add test_coverage make target ([`89531e6`](https://github.com/python-zeroconf/python-zeroconf/commit/89531e641f15b24a60f9fb2e9f71a7aa8450363a)) - -* Add PyPI version badge to the readme ([`4c852d4`](https://github.com/python-zeroconf/python-zeroconf/commit/4c852d424d07925ae01c24a51ffc36ecae49b48d)) - -* Refactor integration test to use events ([`922eab0`](https://github.com/python-zeroconf/python-zeroconf/commit/922eab05596b72d141d459e83146a4cdb6c84389)) - -* Fix readme formatting ([`7b23734`](https://github.com/python-zeroconf/python-zeroconf/commit/7b23734356f85ccaa6ca66ffaeea8484a2d45d3d)) - -* Update README.rst ([`83fd618`](https://github.com/python-zeroconf/python-zeroconf/commit/83fd618328aff29892c71f9ba5b9ff983fe4a202)) - -* Refactor browser example ([`8328aed`](https://github.com/python-zeroconf/python-zeroconf/commit/8328aed1444781b6fac854eb722ae0fef14a3cc4)) - -* Update README.rst ([`49af263`](https://github.com/python-zeroconf/python-zeroconf/commit/49af26350390484bc6f4b66dab4f6b004040cd4a)) - -* Bump version to 0.15 ([`77bcadd`](https://github.com/python-zeroconf/python-zeroconf/commit/77bcaddbd1964fb0b494e98ec3ae6d66ea42c509)) - -* Add myself to authors ([`b9f886b`](https://github.com/python-zeroconf/python-zeroconf/commit/b9f886bf2815c86c7004e123146293c48ea68f1e)) - -* Reuse one Zeroconf instance in browser example ([`1ee00b3`](https://github.com/python-zeroconf/python-zeroconf/commit/1ee00b318eab386b709351ffae81c8293f4e6d4d)) - -* Update README.rst ([`fba4215`](https://github.com/python-zeroconf/python-zeroconf/commit/fba4215be1804a13e454e609ed6df2cf98e149f2)) - -* Update README.rst ([`c7bfe63`](https://github.com/python-zeroconf/python-zeroconf/commit/c7bfe63f9a7eff9a1ede0ac63a329a316d3192ab)) - -* Rename examples ([`3502198`](https://github.com/python-zeroconf/python-zeroconf/commit/3502198768062b49564121b48a792ce5e7b7b288)) - -* Refactor examples ([`2ce95f5`](https://github.com/python-zeroconf/python-zeroconf/commit/2ce95f52e7a02c7f1113ba7ebee3c89babb9a26e)) - -* Update README.rst ([`6a7cd31`](https://github.com/python-zeroconf/python-zeroconf/commit/6a7cd3197ee6ae5690b29b6543fc86d1b1a420d8)) - -* Advertise Python 3 support ([`d330918`](https://github.com/python-zeroconf/python-zeroconf/commit/d330918970d719d6b26a3f81e83dbb8b8adac0a4)) - -* Update README.rst ([`6aae20e`](https://github.com/python-zeroconf/python-zeroconf/commit/6aae20e1c1bef8413573139d62d3d2b889fe8776)) - -* Move examples to examples directory ([`c83891c`](https://github.com/python-zeroconf/python-zeroconf/commit/c83891c9dd2f20e8dee44f1b412a536d20cbcbe3)) - -* Fix regression introduced with Python 3 compat ([`0a0f7e0`](https://github.com/python-zeroconf/python-zeroconf/commit/0a0f7e0e72d7f9ed08231d94b66ff44bcff60151)) - -* Mark threads as daemonic (at least for now) ([`b8cfc79`](https://github.com/python-zeroconf/python-zeroconf/commit/b8cfc7996941afded5c9c7e7903378279590b20f)) - -* Update README.rst ([`cd7ca98`](https://github.com/python-zeroconf/python-zeroconf/commit/cd7ca98010044eb965bc988c23a8be59e09eb69a)) - -* Add Python 3 support ([`9a99aa7`](https://github.com/python-zeroconf/python-zeroconf/commit/9a99aa727f4e041a726aed3736c0a8ab625c4cb6)) - -* Update README.rst ([`09a1f4f`](https://github.com/python-zeroconf/python-zeroconf/commit/09a1f4f9d76f64cc8c85f0525e05bdac53de210c)) - -* Update README.rst ([`6feec34`](https://github.com/python-zeroconf/python-zeroconf/commit/6feec3459d2561f00402d627ea91a8a4981ad309)) - -* Tune package description ([`b819174`](https://github.com/python-zeroconf/python-zeroconf/commit/b8191741d4ef8e347f6dd138fa48da5aec9b6549)) - -* Gitignore build/ ([`0ef1b0d`](https://github.com/python-zeroconf/python-zeroconf/commit/0ef1b0d3481b68a752efe822ff4e9ce8356bcffa)) - -* Add setup.py ([`916bd38`](https://github.com/python-zeroconf/python-zeroconf/commit/916bd38ddb48a959c597ae1763193b4c2c74334f)) - -* Update README.rst ([`35eced3`](https://github.com/python-zeroconf/python-zeroconf/commit/35eced310fbe1782fd87eb33e7f4befcb0a78499)) - -* Run actual tests on Travis ([`f8cea82`](https://github.com/python-zeroconf/python-zeroconf/commit/f8cea82177cea3577d2b4f70fec32e85229abdce)) - -* Advertise Python 2.6 and PyPy support ([`43b182c`](https://github.com/python-zeroconf/python-zeroconf/commit/43b182cce40bcb21eb1e052a0bc42bf367a963ca)) - -* Move readme to README.rst ([`fd3401e`](https://github.com/python-zeroconf/python-zeroconf/commit/fd3401efb55ae91324d12ba80affd2f3b3ebcf5e)) - -* Move readme to README.rst ([`353b700`](https://github.com/python-zeroconf/python-zeroconf/commit/353b700df79b49c49db62e0a6e6eb0eae3ccb444)) - -* Stop catching BaseExceptions ([`41a013c`](https://github.com/python-zeroconf/python-zeroconf/commit/41a013c8a051b3f80018f37d4f254263cc890a68)) - -* Set up Travis build ([`a2a6125`](https://github.com/python-zeroconf/python-zeroconf/commit/a2a6125dd03d9a810dac72163d545e413387217b)) - -* PEP8ize and clean up ([`e2964ed`](https://github.com/python-zeroconf/python-zeroconf/commit/e2964ed48263e72159e95cb0691af0dcb9ba498b)) - -* Updated for 0.14. ([`83aa0f3`](https://github.com/python-zeroconf/python-zeroconf/commit/83aa0f3803cdf79470f4a754c7b9ab616544eea1)) - -* Although SOL_IP is considered more correct here, it's undefined on some -systems, where IPPROTO_IP is available. (Both equate to 0.) Reported by -Mike Erdely. ([`443aca8`](https://github.com/python-zeroconf/python-zeroconf/commit/443aca867d694432d466d20bdf7c49ebc7a4e684)) - -* Obsolete comment. ([`eee7196`](https://github.com/python-zeroconf/python-zeroconf/commit/eee7196626773eae2dc0dc1a68de03a99d778139)) - -* Really these should be network order. ([`5e10a20`](https://github.com/python-zeroconf/python-zeroconf/commit/5e10a20a9cb6bbc09356cbf957f3f7fa3e169ff2)) - -* Docstrings for examples; shorter timeout; struct.unpack() vs. ord(). ([`0884d6a`](https://github.com/python-zeroconf/python-zeroconf/commit/0884d6a56afc6fb559b6c90a923762393187e50a)) - -* Make examples executable. ([`5e5e78e`](https://github.com/python-zeroconf/python-zeroconf/commit/5e5e78e27240e7e03d1c8aa96ee0e1f7877d0d5d)) - -* Unneeded. ([`2ac738f`](https://github.com/python-zeroconf/python-zeroconf/commit/2ac738f84bbcf29d03bad289cb243182ecdf48d6)) - -* getText() is redundant with getProperties(). ([`a115187`](https://github.com/python-zeroconf/python-zeroconf/commit/a11518726321b15059be255b6329cba591887197)) - -* Allow graceful exit from announcement test. ([`0f3b413`](https://github.com/python-zeroconf/python-zeroconf/commit/0f3b413b269f8b95b6f8073ba39d11f156ae632c)) - -* More readable display in browser; automatically quit after giving ten -seconds to respond. ([`eee4530`](https://github.com/python-zeroconf/python-zeroconf/commit/eee4530d7b8216338634282f3097cb96932aa28e)) - -* New names, numbers. ([`2a000c5`](https://github.com/python-zeroconf/python-zeroconf/commit/2a000c589302147129eed990c842b38ac61f7514)) - -* Updated FSF address. ([`4e39602`](https://github.com/python-zeroconf/python-zeroconf/commit/4e396025ed666775973d54a50b69e8f635e28658)) - -* De-DOSification. ([`1dc3436`](https://github.com/python-zeroconf/python-zeroconf/commit/1dc3436e6357b66d0bb53f9b285f123b164984da)) - -* Lowercase imports. ([`e292868`](https://github.com/python-zeroconf/python-zeroconf/commit/e292868f9c7e817cb04dfce2d545f45db4041e5e)) - -* The great lowercasing. ([`5541813`](https://github.com/python-zeroconf/python-zeroconf/commit/5541813fbb8e1d7b233d09ee2d20ac0ca322a9f2)) - -* Renamed tests. ([`4bb88b0`](https://github.com/python-zeroconf/python-zeroconf/commit/4bb88b0952833b84c15c85190c0a9cac01922cbe)) - -* Replaced unwrapped "lgpl.txt" with traditional "COPYING". ([`ad6b1ec`](https://github.com/python-zeroconf/python-zeroconf/commit/ad6b1ecf9fa71a5ec14f7a08fc3d6a689a19e6d2)) - -* Don't need range() here. ([`b36e7d5`](https://github.com/python-zeroconf/python-zeroconf/commit/b36e7d5dd5922b1739911878b29aba921ec9ecb6)) - -* testNumbersAnswers() was identical to testNumbersQuestions(). -(Presumably it was intended to test addAnswer() instead...) ([`416054d`](https://github.com/python-zeroconf/python-zeroconf/commit/416054d407013af8678928b949d6579df4044d46)) - -* Extraneous spaces. ([`f6615a9`](https://github.com/python-zeroconf/python-zeroconf/commit/f6615a9d7632f3510d2f0a36cab155ac753141ab)) - -* Moved history to README; updated version number, etc. ([`015bae2`](https://github.com/python-zeroconf/python-zeroconf/commit/015bae258b5ce73a2a12361e4c9295107126963c)) - -* Meaningless. ([`6147a6e`](https://github.com/python-zeroconf/python-zeroconf/commit/6147a6ed20222851ba4438dd65366f907b4c189f)) - -* Also unexceptional. ([`c36e3af`](https://github.com/python-zeroconf/python-zeroconf/commit/c36e3af2f6e0ea857f383f9b014f50b65fca641c)) - -* If name isn't in self.names, it's unexceptional. (And yes, I actually -tested, and this is faster.) ([`f772d4e`](https://github.com/python-zeroconf/python-zeroconf/commit/f772d4e5e208431378bf01d75eddc7df5119dff7)) - -* Excess spaces; don't use "len" as a label. After eblot. ([`df986ee`](https://github.com/python-zeroconf/python-zeroconf/commit/df986eed46e3ec7dadc6604d0b26e4fcf0b6291a)) - -* Outdated docs. ([`21d7c95`](https://github.com/python-zeroconf/python-zeroconf/commit/21d7c950f50827bc8ac6dd18fb0577c11b5cefac)) - -* Untab the test programs. ([`c13e4fa`](https://github.com/python-zeroconf/python-zeroconf/commit/c13e4fab3b0b95674fbc93cd2ac30fd2ba462a24)) - -* Remove the comment about the test programs. ([`8adab79`](https://github.com/python-zeroconf/python-zeroconf/commit/8adab79a64a73e76841b37e53e55fe8aad8eb580)) - -* Allow for the failure of getServiceInfo(). Not sure why it's happening, -though. ([`0a05f42`](https://github.com/python-zeroconf/python-zeroconf/commit/0a05f423ad591454a25c515d811556d10e5fc99f)) - -* Don't test for NonLocalNameException, since I killed it. ([`d89ddfc`](https://github.com/python-zeroconf/python-zeroconf/commit/d89ddfcecc7b336aa59a4ff784cb8b810772d24f)) - -* Describe this fork. ([`656f959`](https://github.com/python-zeroconf/python-zeroconf/commit/656f959c26310629953cc661ffad681194295131)) - -* Write only a byte. ([`d346107`](https://github.com/python-zeroconf/python-zeroconf/commit/d34610768812906ff07974c1314f6073b431d96e)) - -* Although beacons _should_ fit within single packets, maybe we should allow for the possibility that they won't? (Or, does this even make sense with sendto()?) ([`ac91642`](https://github.com/python-zeroconf/python-zeroconf/commit/ac91642b0ea90a3c84b605e19d562b897e2cd1fd)) - -* Update the version to indicate a fork. ([`a81f3ab`](https://github.com/python-zeroconf/python-zeroconf/commit/a81f3ababc585acca4bacc51a832703286ec5cfb)) - -* HHHHHH -> 6H ([`9a94953`](https://github.com/python-zeroconf/python-zeroconf/commit/9a949532484a55e52f1d2f14eb27277a5133ce29)) - -* In Zeroconf, use the same method of determining the default IP as elsewhere, instead of the unreliable gethostbyname(gethostname()) method (but fall back to that). ([`f6d4731`](https://github.com/python-zeroconf/python-zeroconf/commit/f6d47316a47d9d04539f1a4215dd7eec06c33d4c)) - -* More again. ([`2420505`](https://github.com/python-zeroconf/python-zeroconf/commit/24205054309e110238fc5a986cdc27b17c44abef)) - -* More. ([`b8baed3`](https://github.com/python-zeroconf/python-zeroconf/commit/b8baed3a2876c126cac65a7d95bb88661b31483c)) - -* Minor style things for Zeroconf (use True/False instead of 1/0, etc.). ([`173350e`](https://github.com/python-zeroconf/python-zeroconf/commit/173350e415e66c9629d553f820677453bdbe5724)) - -* Clearer. ([`3e718b5`](https://github.com/python-zeroconf/python-zeroconf/commit/3e718b55becd883324bf40eda700431b302a0da8)) - -* 80-column fixes for Zeroconf. ([`e5d930b`](https://github.com/python-zeroconf/python-zeroconf/commit/e5d930bb681f5544827fc0c9f37daa778dec5930)) - -* Minor simplification of the pack/unpack routines in Zeroconf. ([`e814dd1`](https://github.com/python-zeroconf/python-zeroconf/commit/e814dd1e6848d8c7ec03660d347ea4a34390c37d)) - -* Skip unknown resource records in Zeroconf -- https://bugs.launchpad.net/pyzeroconf/+bug/498411 ([`488de88`](https://github.com/python-zeroconf/python-zeroconf/commit/488de8826ddd58646358900d057a4a1632492948)) - -* Some people are reporting bogus data coming back from Zeroconf scans, causing exceptions. ([`fe77e37`](https://github.com/python-zeroconf/python-zeroconf/commit/fe77e371cc68ea211508908e6180867c420ca042)) - -* Don't need the string module here. ([`f76529c`](https://github.com/python-zeroconf/python-zeroconf/commit/f76529c685868dcdb62b6477f15ecb1122310cc5)) - -* Suppress EBADF errors in Zeroconf.py. ([`4c8aac9`](https://github.com/python-zeroconf/python-zeroconf/commit/4c8aac95613df62d001bd7192ec75247a2bb9b9d)) - -* This doesn't seem to be necessary, and it's generating a lot of exceptions... ([`f80df7b`](https://github.com/python-zeroconf/python-zeroconf/commit/f80df7b0f8b9124970e109c51f7a49b7bd75906c)) - -* Untab Zeroconf. ([`892a4f0`](https://github.com/python-zeroconf/python-zeroconf/commit/892a4f095c23379a6cf5a0ef31521f9f90cb5276)) - -* has_key() is deprecated. ([`f998e39`](https://github.com/python-zeroconf/python-zeroconf/commit/f998e39cbb8d2c5556c10203957ff6a9ab2f546d)) - -* The initial version I committed to HME for Python back in 2008. This is -a step back in some respects (re-inserting tabs that will be undone a -couple patches hence), so that I can apply the patches going forward. ([`d952a9c`](https://github.com/python-zeroconf/python-zeroconf/commit/d952a9c117ae539cf4778d76618fe813b10a9a34)) - -* Remove the executable bit. ([`f0d095d`](https://github.com/python-zeroconf/python-zeroconf/commit/f0d095d0f1c2767be6da47f885f5ed019e9fa363)) - -* Removed pyc file ([`38d0a18`](https://github.com/python-zeroconf/python-zeroconf/commit/38d0a184c13772dae3c14d3c46a30c68497c54db)) - -* First commit ([`c3a39f8`](https://github.com/python-zeroconf/python-zeroconf/commit/c3a39f874a5c10e91ee2315271f13ae74ee381fd)) diff --git a/pyproject.toml b/pyproject.toml index 9ea7f9cfd..931cfe058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.140.1" +version = "0.141.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 22434e47e..b3361a196 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -83,7 +83,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.140.1" +__version__ = "0.141.0" __license__ = "LGPL" From 74f971252060eb8a621d82015c230b2a50adc801 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 07:11:45 -1000 Subject: [PATCH 1182/1433] chore(pre-commit.ci): pre-commit autoupdate (#1497) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87c380830..246493ed7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.1.0 + rev: v4.1.1 hooks: - id: commitizen stages: [commit-msg] @@ -39,13 +39,13 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.2 + rev: v0.9.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.0 hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 From 56634466a73445e9a2e3c09163049c87732cf930 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 27 Jan 2025 18:14:09 +0100 Subject: [PATCH 1183/1433] chore: add badge for rtd build status (#1495) --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index 297d80804..c27833f80 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,10 @@ python-zeroconf :target: https://codspeed.io/python-zeroconf/python-zeroconf :alt: Codspeed.io status for python-zeroconf +.. image:: https://readthedocs.org/projects/python-zeroconf/badge/?version=latest + :target: https://python-zeroconf.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + `Documentation `_. This is fork of pyzeroconf, Multicast DNS Service Discovery for Python, From 7eb6141a428510029b3a7ed0e1a4e4ba2c22ca7b Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 27 Jan 2025 18:14:35 +0100 Subject: [PATCH 1184/1433] chore(docs): add readthedocs config (#1496) --- .readthedocs.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..675f11ec2 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + jobs: + post_install: + # https://docs.readthedocs.com/platform/stable/build-customization.html#install-dependencies-with-poetry + - pip install poetry + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs + +sphinx: + configuration: docs/conf.py From d8e7057f73de267a9263e8a15249c65ff07afb5d Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 27 Jan 2025 18:15:13 +0100 Subject: [PATCH 1185/1433] chore(docs): refactor docs config and dependencies (#1493) --- docs/Makefile | 183 +---------- docs/_ext/zeroconfautodocfix.py | 19 ++ docs/conf.py | 267 +++------------- poetry.lock | 531 +++++++++++++++++++++++++++++++- pyproject.toml | 4 + 5 files changed, 607 insertions(+), 397 deletions(-) create mode 100644 docs/_ext/zeroconfautodocfix.py diff --git a/docs/Makefile b/docs/Makefile index a8d581c27..d4bb2cbb9 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,177 +1,20 @@ -# Makefile for Sphinx documentation +# Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . BUILDDIR = _build -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/zeroconf.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/zeroconf.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/zeroconf" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/zeroconf" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." +.PHONY: help Makefile -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_ext/zeroconfautodocfix.py b/docs/_ext/zeroconfautodocfix.py new file mode 100644 index 000000000..8163a9c6c --- /dev/null +++ b/docs/_ext/zeroconfautodocfix.py @@ -0,0 +1,19 @@ +""" +Must be included after 'sphinx.ext.autodoc'. Fixes unwanted 'alias of' behavior. +""" + +# pylint: disable=import-error +from sphinx.application import Sphinx + + +def skip_member(app, what, name, obj, skip: bool, options) -> bool: # type: ignore[no-untyped-def] + return ( + skip + or getattr(obj, "__doc__", None) is None + or getattr(obj, "__private__", False) is True + or getattr(getattr(obj, "__func__", None), "__private__", False) is True + ) + + +def setup(app: Sphinx) -> None: + app.connect("autodoc-skip-member", skip_member) diff --git a/docs/conf.py b/docs/conf.py index 647742e65..c3bce6715 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,247 +1,66 @@ +# Configuration file for the Sphinx documentation builder. # -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -from typing import Any - -import zeroconf - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx"] +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import sys +from collections.abc import Sequence +from pathlib import Path -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix of source filenames. -source_suffix = ".rst" +# If your extensions are in another directory, add it here. If the directory +# is relative to the documentation root, use Path.absolute to make it absolute. +sys.path.append(str(Path(__file__).parent / "_ext")) +sys.path.insert(0, str(Path(__file__).parent.parent)) -# The encoding of source files. -# source_encoding = 'utf-8-sig' +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# The master toctree document. -master_doc = "index" - -# General information about the project. project = "python-zeroconf" -copyright = "python-zeroconf authors" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = zeroconf.__version__ -# The full version, including alpha/beta/rc tags. -release = version - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ["_build"] +project_copyright = "python-zeroconf authors" +author = "python-zeroconf authors" -# The reST default role (used for this markup: `text`) to use for all documents. -# default_role = None +try: + import zeroconf -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True + # The short X.Y version. + version = zeroconf.__version__ + # The full version, including alpha/beta/rc tags. + release = version +except ImportError: + version = "" + release = "" -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False +extensions = [ + "sphinx.ext.autodoc", + "zeroconfautodocfix", # Must be after "sphinx.ext.autodoc" + "sphinx.ext.intersphinx", +] -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = "default" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - # Custom sidebar templates, maps document names to template names. -html_sidebars = { +html_sidebars: dict[str, Sequence[str]] = { "index": ("sidebar.html", "sourcelink.html", "searchbox.html"), "**": ("localtoc.html", "relations.html", "sourcelink.html", "searchbox.html"), } -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False +# -- Options for HTML help output -------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-help-output -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Output file base name for HTML help builder. htmlhelp_basename = "zeroconfdoc" +# -- Options for intersphinx extension --------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements: dict[str, Any] = {} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -# latex_documents = [] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -# man_pages = [] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -# texinfo_documents = [] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"http://docs.python.org/": None} - - -def setup(app): # type: ignore[no-untyped-def] - app.connect("autodoc-skip-member", skip_member) - - -def skip_member(app, what, name, obj, skip, options): # type: ignore[no-untyped-def] - return ( - skip - or getattr(obj, "__doc__", None) is None - or getattr(obj, "__private__", False) is True - or getattr(getattr(obj, "__func__", None), "__private__", False) is True - ) +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} diff --git a/poetry.lock b/poetry.lock index bf39f7925..14c79f618 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,16 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "alabaster" +version = "0.7.16" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, +] [[package]] name = "async-timeout" @@ -6,17 +18,47 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] +[[package]] +name = "babel" +version = "2.16.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "certifi" +version = "2024.12.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["docs"] +files = [ + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, +] + [[package]] name = "cffi" version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -90,12 +132,116 @@ files = [ [package.dependencies] pycparser = "*" +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev", "docs"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -107,6 +253,7 @@ version = "7.6.10" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, @@ -184,6 +331,7 @@ version = "3.0.11" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["dev"] files = [ {file = "Cython-3.0.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:44292aae17524abb4b70a25111fe7dec1a0ad718711d47e3786a211d5408fdaa"}, {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75d45fbc20651c1b72e4111149fed3b33d270b0a4fb78328c54d965f28d55e1"}, @@ -253,12 +401,26 @@ files = [ {file = "cython-3.0.11.tar.gz", hash = "sha256:7146dd2af8682b4ca61331851e6aebce9fe5158e75300343f80c07ca80b1faff"}, ] +[[package]] +name = "docutils" +version = "0.21.2" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, +] + [[package]] name = "exceptiongroup" version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -267,23 +429,53 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["docs"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "ifaddr" version = "0.2.0" description = "Cross-platform network interface and IP address enumeration library" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, ] +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + [[package]] name = "importlib-metadata" version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] +markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -307,17 +499,37 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jinja2" +version = "3.1.5" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -336,12 +548,84 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -353,6 +637,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -364,6 +649,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -379,6 +665,7 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -390,6 +677,7 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -404,6 +692,7 @@ version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -426,6 +715,7 @@ version = "0.25.2" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, @@ -444,6 +734,7 @@ version = "3.1.2" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aed496f873670ce0ea8f980a7c1a2c6a08f415e0ebdf207bf651b2d922103374"}, {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee45b0b763f6b5fa5d74c7b91d694a9615561c428b320383660672f4471756e3"}, @@ -476,6 +767,7 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -494,6 +786,7 @@ version = "2.3.1" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, @@ -502,12 +795,35 @@ files = [ [package.dependencies] pytest = ">=7.0.0" +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "rich" version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -527,6 +843,7 @@ version = "75.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, @@ -541,12 +858,197 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +description = "Python documentation generator" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, + {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, +] + +[package.dependencies] +alabaster = ">=0.7.14,<0.8.0" +babel = ">=2.13" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +docutils = ">=0.20,<0.22" +imagesize = ">=1.3" +importlib-metadata = {version = ">=6.0", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.1" +packaging = ">=23.0" +Pygments = ">=2.17" +requests = ">=2.30.0" +snowballstemmer = ">=2.2" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.9" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +description = "Read the Docs theme for Sphinx" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, + {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, +] + +[package.dependencies] +docutils = ">0.18,<0.22" +sphinx = ">=6,<9" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "transifex-client", "twine", "wheel"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +groups = ["docs"] +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +groups = ["docs"] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["defusedxml (>=0.7.1)", "pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -581,6 +1083,7 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version < \"3.11\""} [[package]] name = "typing-extensions" @@ -588,17 +1091,39 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "urllib3" +version = "2.3.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "zipp" version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["dev", "docs"] +markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, @@ -613,6 +1138,6 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.9" -content-hash = "748c1d5a24ec0b6c1561daace768193ce87acc53d4cabf06c82551a45c079c94" +content-hash = "eb91a0dd1c260f37d2579b4793f537f8017f9e1801e2a372849439f5c9132245" diff --git a/pyproject.toml b/pyproject.toml index 931cfe058..8b6895b88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,10 @@ setuptools = ">=65.6.3,<76.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" +[tool.poetry.group.docs.dependencies] +sphinx = "^7.4.7 || ^8.1.3" +sphinx-rtd-theme = "^3.0.2" + [tool.ruff] target-version = "py38" line-length = 110 From 69f7c13e67e39365b1756393a6a51af0e8a4f9f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 07:19:33 -1000 Subject: [PATCH 1186/1433] chore: disable cython when building docs (#1498) --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 675f11ec2..aee2616a1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,7 +10,7 @@ build: post_install: # https://docs.readthedocs.com/platform/stable/build-customization.html#install-dependencies-with-poetry - pip install poetry - - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs + - SKIP_CYTHON=1 VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs sphinx: configuration: docs/conf.py From ae3c3523e5f2896989d0b932d53ef1e24ef4aee8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:30:08 -0600 Subject: [PATCH 1187/1433] feat: add simple address resolvers and examples (#1499) --- examples/resolve_address.py | 38 +++++++++++++++++ src/zeroconf/__init__.py | 3 ++ src/zeroconf/_services/info.pxd | 13 ++++++ src/zeroconf/_services/info.py | 76 +++++++++++++++++++++++++++------ tests/services/test_info.py | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 12 deletions(-) create mode 100755 examples/resolve_address.py diff --git a/examples/resolve_address.py b/examples/resolve_address.py new file mode 100755 index 000000000..eeecfda0d --- /dev/null +++ b/examples/resolve_address.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +"""Example of resolving a name to an IP address.""" + +import asyncio +import logging +import sys + +from zeroconf import AddressResolver, IPVersion +from zeroconf.asyncio import AsyncZeroconf + + +async def resolve_name(name: str) -> None: + aiozc = AsyncZeroconf() + await aiozc.zeroconf.async_wait_for_start() + resolver = AddressResolver(name) + if await resolver.async_request(aiozc.zeroconf, 3000): + print(f"{name} IP addresses:", resolver.ip_addresses_by_version(IPVersion.All)) + else: + print(f"Name {name} not resolved") + await aiozc.async_close() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + argv = sys.argv.copy() + if "--debug" in argv: + logging.getLogger("zeroconf").setLevel(logging.DEBUG) + argv.remove("--debug") + + if len(argv) < 2 or not argv[1]: + raise ValueError("Usage: resolve_address.py [--debug] ") + + name = argv[1] + if not name.endswith("."): + name += "." + + asyncio.run(resolve_name(name)) diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b3361a196..d3e74dfec 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -58,6 +58,9 @@ from ._services.browser import ServiceBrowser from ._services.info import ( # noqa # import needed for backwards compat ServiceInfo, + AddressResolver, + AddressResolverIPv4, + AddressResolverIPv6, instance_name_from_service_info, ) from ._services.registry import ( # noqa # import needed for backwards compat diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 53abe62a6..3f65bc0a7 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -22,6 +22,9 @@ from .._utils.ipaddress cimport ( ) from .._utils.time cimport current_time_millis +cdef cython.set _TYPE_AAAA_RECORDS +cdef cython.set _TYPE_A_RECORDS +cdef cython.set _TYPE_A_AAAA_RECORDS cdef object _resolve_all_futures_to_none @@ -75,6 +78,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public DNSText _dns_text_cache cdef public cython.list _dns_address_cache cdef public cython.set _get_address_and_nsec_records_cache + cdef public cython.set _query_record_types @cython.locals(record_update=RecordUpdate, update=bint, cache=DNSCache) cpdef void async_update_records(self, object zc, double now, cython.list records) @@ -155,3 +159,12 @@ cdef class ServiceInfo(RecordUpdateListener): cdef double _get_initial_delay(self) cdef double _get_random_delay(self) + +cdef class AddressResolver(ServiceInfo): + pass + +cdef class AddressResolverIPv6(ServiceInfo): + pass + +cdef class AddressResolverIPv4(ServiceInfo): + pass diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fd51eee1d..a6e815b51 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -88,6 +88,10 @@ # the A/AAAA/SRV records for a host. _AVOID_SYNC_DELAY_RANDOM_INTERVAL = (20, 120) +_TYPE_AAAA_RECORDS = {_TYPE_AAAA} +_TYPE_A_RECORDS = {_TYPE_A} +_TYPE_A_AAAA_RECORDS = {_TYPE_A, _TYPE_AAAA} + bytes_ = bytes float_ = float int_ = int @@ -146,6 +150,7 @@ class ServiceInfo(RecordUpdateListener): "_name", "_new_records_futures", "_properties", + "_query_record_types", "host_ttl", "interface_index", "key", @@ -210,6 +215,7 @@ def __init__( self._dns_service_cache: Optional[DNSService] = None self._dns_text_cache: Optional[DNSText] = None self._get_address_and_nsec_records_cache: Optional[Set[DNSRecord]] = None + self._query_record_types = {_TYPE_SRV, _TYPE_TXT, _TYPE_A, _TYPE_AAAA} @property def name(self) -> str: @@ -917,18 +923,22 @@ def _generate_request_query( cache = zc.cache history = zc.question_history qu_question = question_type is QU_QUESTION - self._add_question_with_known_answers( - out, qu_question, history, cache, now, name, _TYPE_SRV, _CLASS_IN, True - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, name, _TYPE_TXT, _CLASS_IN, True - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, server, _TYPE_A, _CLASS_IN, False - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, server, _TYPE_AAAA, _CLASS_IN, False - ) + if _TYPE_SRV in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_SRV, _CLASS_IN, True + ) + if _TYPE_TXT in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_TXT, _CLASS_IN, True + ) + if _TYPE_A in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_A, _CLASS_IN, False + ) + if _TYPE_AAAA in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_AAAA, _CLASS_IN, False + ) return out def __repr__(self) -> str: @@ -954,3 +964,45 @@ def __repr__(self) -> str: class AsyncServiceInfo(ServiceInfo): """An async version of ServiceInfo.""" + + +class AddressResolver(ServiceInfo): + """Resolve a host name to an IP address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = _TYPE_A_AAAA_RECORDS + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv4_addresses) or bool(self._ipv6_addresses) + + +class AddressResolverIPv6(ServiceInfo): + """Resolve a host name to an IPv6 address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = _TYPE_AAAA_RECORDS + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv6_addresses) + + +class AddressResolverIPv4(ServiceInfo): + """Resolve a host name to an IPv4 address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = _TYPE_A_RECORDS + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv4_addresses) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 1f8924a34..3d4c53028 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1797,3 +1797,77 @@ async def test_service_info_nsec_records(): assert nsec_record.type == const._TYPE_NSEC assert nsec_record.ttl == 50 assert nsec_record.rdtypes == [const._TYPE_A, const._TYPE_AAAA] + + +@pytest.mark.asyncio +async def test_address_resolver(): + """Test that the address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolver("address_resolver_test.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test.local.", + const._TYPE_A, + const._CLASS_IN, + 10000, + b"\x7f\x00\x00\x01", + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.addresses == [b"\x7f\x00\x00\x01"] + + +@pytest.mark.asyncio +async def test_address_resolver_ipv4(): + """Test that the IPv4 address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolverIPv4("address_resolver_test_ipv4.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test_ipv4.local.", + const._TYPE_A, + const._CLASS_IN, + 10000, + b"\x7f\x00\x00\x01", + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.addresses == [b"\x7f\x00\x00\x01"] + + +@pytest.mark.asyncio +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") +async def test_address_resolver_ipv6(): + """Test that the IPv6 address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolverIPv6("address_resolver_test_ipv6.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test_ipv6.local.", + const._TYPE_AAAA, + const._CLASS_IN, + 10000, + socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6"), + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.ip_addresses_by_version(IPVersion.All) == [ip_address("fe80::52e:c2f2:bc5f:e9c6")] From 6f5cfb643dd42997c31ad54426548f6be52bf82b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 30 Jan 2025 17:39:27 +0000 Subject: [PATCH 1188/1433] 0.142.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5874ebbf..174b0d7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.142.0 (2025-01-30) + +### Features + +- Add simple address resolvers and examples + ([#1499](https://github.com/python-zeroconf/python-zeroconf/pull/1499), + [`ae3c352`](https://github.com/python-zeroconf/python-zeroconf/commit/ae3c3523e5f2896989d0b932d53ef1e24ef4aee8)) + + ## v0.141.0 (2025-01-22) ### Features diff --git a/pyproject.toml b/pyproject.toml index 8b6895b88..f5084253e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.141.0" +version = "0.142.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d3e74dfec..1a41ddd3b 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -86,7 +86,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.141.0" +__version__ = "0.142.0" __license__ = "LGPL" From 9d383f597c89df4c70ee59d9fd481b174aaeff90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 13:32:06 -0600 Subject: [PATCH 1189/1433] chore: add tests for circular imports (#1501) --- tests/test_circular_imports.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_circular_imports.py diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py new file mode 100644 index 000000000..8bd443a42 --- /dev/null +++ b/tests/test_circular_imports.py @@ -0,0 +1,30 @@ +"""Test to check for circular imports.""" + +import asyncio +import sys + +import pytest + + +@pytest.mark.asyncio +@pytest.mark.timeout(30) # cloud can take > 9s +@pytest.mark.parametrize( + "module", + [ + "zeroconf", + "zeroconf.asyncio", + "zeroconf._protocol.incoming", + "zeroconf._protocol.outgoing", + "zeroconf.const", + "zeroconf._logger", + "zeroconf._transport", + "zeroconf._record_update", + "zeroconf._services.browser", + "zeroconf._services.info", + ], +) +async def test_circular_imports(module: str) -> None: + """Check that components can be imported without circular imports.""" + process = await asyncio.create_subprocess_exec(sys.executable, "-c", f"import {module}") + await process.communicate() + assert process.returncode == 0 From 64138a393f5395cd34bcfde05088773cdaa86662 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 13:50:48 -0600 Subject: [PATCH 1190/1433] chore: update to modern typing (#1502) --- examples/browser.py | 2 + examples/registration.py | 2 + examples/resolve_address.py | 2 + examples/resolver.py | 2 + examples/self_test.py | 1 + src/zeroconf/__init__.py | 4 +- src/zeroconf/_cache.py | 38 ++--- src/zeroconf/_core.py | 66 ++++----- src/zeroconf/_dns.py | 70 +++++----- src/zeroconf/_engine.py | 26 ++-- src/zeroconf/_exceptions.py | 2 + src/zeroconf/_handlers/__init__.py | 2 + src/zeroconf/_handlers/answers.py | 8 +- .../_handlers/multicast_outgoing_queue.py | 4 +- src/zeroconf/_handlers/query_handler.py | 60 ++++---- src/zeroconf/_handlers/record_manager.pxd | 2 +- src/zeroconf/_handlers/record_manager.py | 26 ++-- src/zeroconf/_history.py | 10 +- src/zeroconf/_logger.py | 6 +- src/zeroconf/_protocol/__init__.py | 2 + src/zeroconf/_protocol/incoming.py | 32 +++-- src/zeroconf/_protocol/outgoing.py | 26 ++-- src/zeroconf/_record_update.py | 8 +- src/zeroconf/_services/__init__.py | 20 +-- src/zeroconf/_services/browser.py | 104 +++++++------- src/zeroconf/_services/info.py | 130 +++++++++--------- src/zeroconf/_services/registry.py | 24 ++-- src/zeroconf/_services/types.py | 13 +- src/zeroconf/_transport.py | 5 +- src/zeroconf/_updates.py | 8 +- src/zeroconf/_utils/__init__.py | 2 + src/zeroconf/_utils/asyncio.py | 14 +- src/zeroconf/_utils/ipaddress.py | 14 +- src/zeroconf/_utils/name.py | 5 +- src/zeroconf/_utils/net.py | 34 ++--- src/zeroconf/_utils/time.py | 2 + src/zeroconf/asyncio.py | 56 ++++---- src/zeroconf/const.py | 2 + tests/benchmarks/__init__.py | 1 + tests/benchmarks/helpers.py | 2 + tests/benchmarks/test_cache.py | 2 + tests/benchmarks/test_incoming.py | 2 + tests/benchmarks/test_outgoing.py | 2 + tests/benchmarks/test_send.py | 2 + tests/benchmarks/test_txt_properties.py | 2 + tests/conftest.py | 2 + tests/services/__init__.py | 2 + tests/services/test_browser.py | 4 +- tests/services/test_registry.py | 2 + tests/services/test_types.py | 2 + tests/test_asyncio.py | 2 + tests/test_cache.py | 2 + tests/test_circular_imports.py | 2 + tests/test_dns.py | 2 + tests/test_engine.py | 2 + tests/test_exceptions.py | 2 + tests/test_handlers.py | 10 +- tests/test_history.py | 2 + tests/test_init.py | 2 + tests/test_logger.py | 2 + tests/test_protocol.py | 2 + tests/test_services.py | 2 + tests/test_updates.py | 4 +- tests/utils/__init__.py | 2 + tests/utils/test_ipaddress.py | 2 + tests/utils/test_name.py | 2 + tests/utils/test_net.py | 2 + 67 files changed, 510 insertions(+), 393 deletions(-) diff --git a/examples/browser.py b/examples/browser.py index 107be452f..92adc9491 100755 --- a/examples/browser.py +++ b/examples/browser.py @@ -5,6 +5,8 @@ The default is HTTP and HAP; use --find to search for all available services in the network """ +from __future__ import annotations + import argparse import logging from time import sleep diff --git a/examples/registration.py b/examples/registration.py index 1c42d890c..1ba19b16a 100755 --- a/examples/registration.py +++ b/examples/registration.py @@ -2,6 +2,8 @@ """Example of announcing a service (in this case, a fake HTTP server)""" +from __future__ import annotations + import argparse import logging import socket diff --git a/examples/resolve_address.py b/examples/resolve_address.py index eeecfda0d..88ce825b7 100755 --- a/examples/resolve_address.py +++ b/examples/resolve_address.py @@ -2,6 +2,8 @@ """Example of resolving a name to an IP address.""" +from __future__ import annotations + import asyncio import logging import sys diff --git a/examples/resolver.py b/examples/resolver.py index 1b74f97ef..a52050f41 100755 --- a/examples/resolver.py +++ b/examples/resolver.py @@ -2,6 +2,8 @@ """Example of resolving a service with a known name""" +from __future__ import annotations + import logging import sys diff --git a/examples/self_test.py b/examples/self_test.py index b12a8518a..3d1fa050c 100755 --- a/examples/self_test.py +++ b/examples/self_test.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +from __future__ import annotations import logging import socket diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 1a41ddd3b..26f60cde2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + from ._cache import DNSCache # noqa # import needed for backwards compat from ._core import Zeroconf from ._dns import ( # noqa # import needed for backwards compat @@ -57,10 +59,10 @@ ) from ._services.browser import ServiceBrowser from ._services.info import ( # noqa # import needed for backwards compat - ServiceInfo, AddressResolver, AddressResolverIPv4, AddressResolverIPv6, + ServiceInfo, instance_name_from_service_info, ) from ._services.registry import ( # noqa # import needed for backwards compat diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 1b7aae38f..5ac43f307 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -20,8 +20,10 @@ USA """ +from __future__ import annotations + from heapq import heapify, heappop, heappush -from typing import Dict, Iterable, List, Optional, Set, Tuple, Union, cast +from typing import Dict, Iterable, Union, cast from ._dns import ( DNSAddress, @@ -66,8 +68,8 @@ class DNSCache: def __init__(self) -> None: self.cache: _DNSRecordCacheType = {} - self._expire_heap: List[Tuple[float, DNSRecord]] = [] - self._expirations: Dict[DNSRecord, float] = {} + self._expire_heap: list[tuple[float, DNSRecord]] = [] + self._expirations: dict[DNSRecord, float] = {} self.service_cache: _DNSRecordCacheType = {} # Functions prefixed with async_ are NOT threadsafe and must @@ -135,7 +137,7 @@ def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: for entry in entries: self._async_remove(entry) - def async_expire(self, now: _float) -> List[DNSRecord]: + def async_expire(self, now: _float) -> list[DNSRecord]: """Purge expired entries from the cache. This function must be run in from event loop. @@ -145,7 +147,7 @@ def async_expire(self, now: _float) -> List[DNSRecord]: if not (expire_heap_len := len(self._expire_heap)): return [] - expired: List[DNSRecord] = [] + expired: list[DNSRecord] = [] # Find any expired records and add them to the to-delete list while self._expire_heap: when_record = self._expire_heap[0] @@ -182,7 +184,7 @@ def async_expire(self, now: _float) -> List[DNSRecord]: self.async_remove_records(expired) return expired - def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: + def async_get_unique(self, entry: _UniqueRecordsType) -> DNSRecord | None: """Gets a unique entry by key. Will return None if there is no matching entry. @@ -194,7 +196,7 @@ def async_get_unique(self, entry: _UniqueRecordsType) -> Optional[DNSRecord]: return None return store.get(entry) - def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DNSRecord]: + def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> list[DNSRecord]: """Gets all matching entries by details. This function is not thread-safe and must be called from @@ -202,7 +204,7 @@ def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DN """ key = name.lower() records = self.cache.get(key) - matches: List[DNSRecord] = [] + matches: list[DNSRecord] = [] if records is None: return matches for record in records.values(): @@ -210,7 +212,7 @@ def async_all_by_details(self, name: _str, type_: _int, class_: _int) -> List[DN matches.append(record) return matches - def async_entries_with_name(self, name: str) -> List[DNSRecord]: + def async_entries_with_name(self, name: str) -> list[DNSRecord]: """Returns a dict of entries whose key matches the name. This function is not threadsafe and must be called from @@ -218,7 +220,7 @@ def async_entries_with_name(self, name: str) -> List[DNSRecord]: """ return self.entries_with_name(name) - def async_entries_with_server(self, name: str) -> List[DNSRecord]: + def async_entries_with_server(self, name: str) -> list[DNSRecord]: """Returns a dict of entries whose key matches the server. This function is not threadsafe and must be called from @@ -230,7 +232,7 @@ def async_entries_with_server(self, name: str) -> List[DNSRecord]: # event loop, however they all make copies so they significantly # inefficient. - def get(self, entry: DNSEntry) -> Optional[DNSRecord]: + def get(self, entry: DNSEntry) -> DNSRecord | None: """Gets an entry by key. Will return None if there is no matching entry.""" if isinstance(entry, _UNIQUE_RECORD_TYPES): @@ -240,7 +242,7 @@ def get(self, entry: DNSEntry) -> Optional[DNSRecord]: return cached_entry return None - def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRecord]: + def get_by_details(self, name: str, type_: _int, class_: _int) -> DNSRecord | None: """Gets the first matching entry by details. Returns None if no entries match. Calling this function is not recommended as it will only @@ -261,7 +263,7 @@ def get_by_details(self, name: str, type_: _int, class_: _int) -> Optional[DNSRe return cached_entry return None - def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRecord]: + def get_all_by_details(self, name: str, type_: _int, class_: _int) -> list[DNSRecord]: """Gets all matching entries by details.""" key = name.lower() records = self.cache.get(key) @@ -269,19 +271,19 @@ def get_all_by_details(self, name: str, type_: _int, class_: _int) -> List[DNSRe return [] return [entry for entry in list(records.values()) if type_ == entry.type and class_ == entry.class_] - def entries_with_server(self, server: str) -> List[DNSRecord]: + def entries_with_server(self, server: str) -> list[DNSRecord]: """Returns a list of entries whose server matches the name.""" if entries := self.service_cache.get(server.lower()): return list(entries.values()) return [] - def entries_with_name(self, name: str) -> List[DNSRecord]: + def entries_with_name(self, name: str) -> list[DNSRecord]: """Returns a list of entries whose key matches the name.""" if entries := self.cache.get(name.lower()): return list(entries.values()) return [] - def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[DNSRecord]: + def current_entry_with_name_and_alias(self, name: str, alias: str) -> DNSRecord | None: now = current_time_millis() for record in reversed(self.entries_with_name(name)): if ( @@ -292,13 +294,13 @@ def current_entry_with_name_and_alias(self, name: str, alias: str) -> Optional[D return record return None - def names(self) -> List[str]: + def names(self) -> list[str]: """Return a copy of the list of current cache names.""" return list(self.cache) def async_mark_unique_records_older_than_1s_to_expire( self, - unique_types: Set[Tuple[_str, _int, _int]], + unique_types: set[tuple[_str, _int, _int]], answers: Iterable[DNSRecord], now: _float, ) -> None: diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 68cb8a9ac..01e98e8f9 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -20,12 +20,14 @@ USA """ +from __future__ import annotations + import asyncio import logging import sys import threading from types import TracebackType -from typing import Awaitable, Dict, List, Optional, Set, Tuple, Type, Union +from typing import Awaitable from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType @@ -108,9 +110,9 @@ def async_send_with_transport( packet: bytes, packet_num: int, out: DNSOutgoing, - addr: Optional[str], + addr: str | None, port: int, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), + v6_flow_scope: tuple[()] | tuple[int, int] = (), ) -> None: ipv6_socket = transport.is_ipv6 if addr is None: @@ -149,7 +151,7 @@ def __init__( self, interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, - ip_version: Optional[IPVersion] = None, + ip_version: IPVersion | None = None, apple_p2p: bool = False, ) -> None: """Creates an instance of the Zeroconf class, establishing @@ -181,7 +183,7 @@ def __init__( self.engine = AsyncEngine(self, listen_socket, respond_sockets) - self.browsers: Dict[ServiceListener, ServiceBrowser] = {} + self.browsers: dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() self.cache = DNSCache() self.question_history = QuestionHistory() @@ -192,9 +194,9 @@ def __init__( self.query_handler = QueryHandler(self) self.record_manager = RecordManager(self) - self._notify_futures: Set[asyncio.Future] = set() - self.loop: Optional[asyncio.AbstractEventLoop] = None - self._loop_thread: Optional[threading.Thread] = None + self._notify_futures: set[asyncio.Future] = set() + self.loop: asyncio.AbstractEventLoop | None = None + self._loop_thread: threading.Thread | None = None self.start() @@ -239,7 +241,7 @@ async def async_wait_for_start(self) -> None: raise NotRunningException @property - def listeners(self) -> Set[RecordUpdateListener]: + def listeners(self) -> set[RecordUpdateListener]: return self.record_manager.listeners async def async_wait(self, timeout: float) -> None: @@ -264,8 +266,8 @@ def get_service_info( type_: str, name: str, timeout: int = 3000, - question_type: Optional[DNSQuestionType] = None, - ) -> Optional[ServiceInfo]: + question_type: DNSQuestionType | None = None, + ) -> ServiceInfo | None: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds. @@ -301,7 +303,7 @@ def remove_all_service_listeners(self) -> None: def register_service( self, info: ServiceInfo, - ttl: Optional[int] = None, + ttl: int | None = None, allow_name_change: bool = False, cooperating_responders: bool = False, strict: bool = True, @@ -329,7 +331,7 @@ def register_service( async def async_register_service( self, info: ServiceInfo, - ttl: Optional[int] = None, + ttl: int | None = None, allow_name_change: bool = False, cooperating_responders: bool = False, strict: bool = True, @@ -380,8 +382,8 @@ async def async_get_service_info( type_: str, name: str, timeout: int = 3000, - question_type: Optional[DNSQuestionType] = None, - ) -> Optional[AsyncServiceInfo]: + question_type: DNSQuestionType | None = None, + ) -> AsyncServiceInfo | None: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds. @@ -400,7 +402,7 @@ async def _async_broadcast_service( self, info: ServiceInfo, interval: int, - ttl: Optional[int], + ttl: int | None, broadcast_addresses: bool = True, ) -> None: """Send a broadcasts to announce a service at intervals.""" @@ -412,7 +414,7 @@ async def _async_broadcast_service( def generate_service_broadcast( self, info: ServiceInfo, - ttl: Optional[int], + ttl: int | None, broadcast_addresses: bool = True, ) -> DNSOutgoing: """Generate a broadcast to announce a service.""" @@ -439,7 +441,7 @@ def _add_broadcast_answer( # pylint: disable=no-self-use self, out: DNSOutgoing, info: ServiceInfo, - override_ttl: Optional[int], + override_ttl: int | None, broadcast_addresses: bool = True, ) -> None: """Add answers to broadcast a service.""" @@ -481,7 +483,7 @@ async def async_unregister_service(self, info: ServiceInfo) -> Awaitable: self._async_broadcast_service(info, _UNREGISTER_TIME, 0, broadcast_addresses) ) - def generate_unregister_all_services(self) -> Optional[DNSOutgoing]: + def generate_unregister_all_services(self) -> DNSOutgoing | None: """Generate a DNSOutgoing goodbye for all services and remove them from the registry.""" service_infos = self.registry.async_get_service_infos() if not service_infos: @@ -562,7 +564,7 @@ async def async_check_service( def add_listener( self, listener: RecordUpdateListener, - question: Optional[Union[DNSQuestion, List[DNSQuestion]]], + question: DNSQuestion | list[DNSQuestion] | None, ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -584,7 +586,7 @@ def remove_listener(self, listener: RecordUpdateListener) -> None: def async_add_listener( self, listener: RecordUpdateListener, - question: Optional[Union[DNSQuestion, List[DNSQuestion]]], + question: DNSQuestion | list[DNSQuestion] | None, ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -604,10 +606,10 @@ def async_remove_listener(self, listener: RecordUpdateListener) -> None: def send( self, out: DNSOutgoing, - addr: Optional[str] = None, + addr: str | None = None, port: int = _MDNS_PORT, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - transport: Optional[_WrappedTransport] = None, + v6_flow_scope: tuple[()] | tuple[int, int] = (), + transport: _WrappedTransport | None = None, ) -> None: """Sends an outgoing packet threadsafe.""" assert self.loop is not None @@ -616,10 +618,10 @@ def send( def async_send( self, out: DNSOutgoing, - addr: Optional[str] = None, + addr: str | None = None, port: int = _MDNS_PORT, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = (), - transport: Optional[_WrappedTransport] = None, + v6_flow_scope: tuple[()] | tuple[int, int] = (), + transport: _WrappedTransport | None = None, ) -> None: """Sends an outgoing packet.""" if self.done: @@ -701,14 +703,14 @@ async def _async_close(self) -> None: await self.engine._async_close() # pylint: disable=protected-access self._shutdown_threads() - def __enter__(self) -> "Zeroconf": + def __enter__(self) -> Zeroconf: return self def __exit__( # pylint: disable=useless-return self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: self.close() return None diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index c22f8b170..bc0a3948e 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -20,9 +20,11 @@ USA """ +from __future__ import annotations + import enum import socket -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Any, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -94,7 +96,7 @@ def get_type(t: int) -> str: """Type accessor""" return _TYPES.get(t, f"?({t})") - def entry_to_string(self, hdr: str, other: Optional[Union[bytes, str]]) -> str: + def entry_to_string(self, hdr: str, other: bytes | str | None) -> str: """String representation with additional information""" return "{}[{},{}{},{}]{}".format( hdr, @@ -119,7 +121,7 @@ def _fast_init(self, name: str, type_: _int, class_: _int) -> None: self._fast_init_entry(name, type_, class_) self._hash = hash((self.key, type_, self.class_)) - def answered_by(self, rec: "DNSRecord") -> bool: + def answered_by(self, rec: DNSRecord) -> bool: """Returns true if the question is answered by the record""" return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name @@ -170,8 +172,8 @@ def __init__( name: str, type_: int, class_: int, - ttl: Union[float, int], - created: Optional[float] = None, + ttl: float | int, + created: float | None = None, ) -> None: self._fast_init_record(name, type_, class_, ttl, created or current_time_millis()) @@ -185,10 +187,10 @@ def __eq__(self, other: Any) -> bool: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException - def __lt__(self, other: "DNSRecord") -> bool: + def __lt__(self, other: DNSRecord) -> bool: return self.ttl < other.ttl - def suppressed_by(self, msg: "DNSIncoming") -> bool: + def suppressed_by(self, msg: DNSIncoming) -> bool: """Returns true if any answer in a message can suffice for the information held in this record.""" answers = msg.answers() @@ -208,7 +210,7 @@ def get_expiration_time(self, percent: _int) -> float: return self.created + (percent * self.ttl * 10) # TODO: Switch to just int here - def get_remaining_ttl(self, now: _float) -> Union[int, float]: + def get_remaining_ttl(self, now: _float) -> int | float: """Returns the remaining TTL in seconds.""" remain = (self.created + (_EXPIRE_FULL_TIME_MS * self.ttl) - now) / 1000.0 return 0 if remain < 0 else remain @@ -225,18 +227,18 @@ def is_recent(self, now: _float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" return self.created + (_RECENT_TIME_MS * self.ttl) > now - def _set_created_ttl(self, created: _float, ttl: Union[float, int]) -> None: + def _set_created_ttl(self, created: _float, ttl: float | int) -> None: """Set the created and ttl of a record.""" # It would be better if we made a copy instead of mutating the record # in place, but records currently don't have a copy method. self.created = created self.ttl = ttl - def write(self, out: "DNSOutgoing") -> None: # pylint: disable=no-self-use + def write(self, out: DNSOutgoing) -> None: # pylint: disable=no-self-use """Abstract method""" raise AbstractMethodException - def to_string(self, other: Union[bytes, str]) -> str: + def to_string(self, other: bytes | str) -> str: """String representation with additional information""" arg = f"{self.ttl}/{int(self.get_remaining_ttl(current_time_millis()))},{cast(Any, other)}" return DNSEntry.entry_to_string(self, "record", arg) @@ -254,8 +256,8 @@ def __init__( class_: int, ttl: int, address: bytes, - scope_id: Optional[int] = None, - created: Optional[float] = None, + scope_id: int | None = None, + created: float | None = None, ) -> None: self._fast_init(name, type_, class_, ttl, address, scope_id, created or current_time_millis()) @@ -266,7 +268,7 @@ def _fast_init( class_: _int, ttl: _float, address: bytes, - scope_id: Optional[_int], + scope_id: _int | None, created: _float, ) -> None: """Fast init for reuse.""" @@ -275,7 +277,7 @@ def _fast_init( self.scope_id = scope_id self._hash = hash((self.key, type_, self.class_, address, scope_id)) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet""" out.write_string(self.address) @@ -320,7 +322,7 @@ def __init__( ttl: int, cpu: str, os: str, - created: Optional[float] = None, + created: float | None = None, ) -> None: self._fast_init(name, type_, class_, ttl, cpu, os, created or current_time_millis()) @@ -333,7 +335,7 @@ def _fast_init( self.os = os self._hash = hash((self.key, type_, self.class_, cpu, os)) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet""" out.write_character_string(self.cpu.encode("utf-8")) out.write_character_string(self.os.encode("utf-8")) @@ -367,7 +369,7 @@ def __init__( class_: int, ttl: int, alias: str, - created: Optional[float] = None, + created: float | None = None, ) -> None: self._fast_init(name, type_, class_, ttl, alias, created or current_time_millis()) @@ -389,7 +391,7 @@ def max_size_compressed(self) -> int: + _NAME_COMPRESSION_MIN_SIZE ) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet""" out.write_name(self.alias) @@ -422,7 +424,7 @@ def __init__( class_: int, ttl: int, text: bytes, - created: Optional[float] = None, + created: float | None = None, ) -> None: self._fast_init(name, type_, class_, ttl, text, created or current_time_millis()) @@ -433,7 +435,7 @@ def _fast_init( self.text = text self._hash = hash((self.key, type_, self.class_, text)) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet""" out.write_string(self.text) @@ -466,12 +468,12 @@ def __init__( name: str, type_: int, class_: int, - ttl: Union[float, int], + ttl: float | int, priority: int, weight: int, port: int, server: str, - created: Optional[float] = None, + created: float | None = None, ) -> None: self._fast_init( name, type_, class_, ttl, priority, weight, port, server, created or current_time_millis() @@ -497,7 +499,7 @@ def _fast_init( self.server_key = server.lower() self._hash = hash((self.key, type_, self.class_, priority, weight, port, self.server_key)) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet""" out.write_short(self.priority) out.write_short(self.weight) @@ -537,10 +539,10 @@ def __init__( name: str, type_: int, class_: int, - ttl: Union[int, float], + ttl: int | float, next_name: str, - rdtypes: List[int], - created: Optional[float] = None, + rdtypes: list[int], + created: float | None = None, ) -> None: self._fast_init(name, type_, class_, ttl, next_name, rdtypes, created or current_time_millis()) @@ -551,7 +553,7 @@ def _fast_init( class_: _int, ttl: _float, next_name: str, - rdtypes: List[_int], + rdtypes: list[_int], created: _float, ) -> None: self._fast_init_record(name, type_, class_, ttl, created) @@ -559,7 +561,7 @@ def _fast_init( self.rdtypes = sorted(rdtypes) self._hash = hash((self.key, type_, self.class_, next_name, *self.rdtypes)) - def write(self, out: "DNSOutgoing") -> None: + def write(self, out: DNSOutgoing) -> None: """Used in constructing an outgoing packet.""" bitmap = bytearray(b"\0" * 32) total_octets = 0 @@ -610,21 +612,21 @@ class DNSRRSet: __slots__ = ("_lookup", "_records") - def __init__(self, records: List[DNSRecord]) -> None: + def __init__(self, records: list[DNSRecord]) -> None: """Create an RRset from records sets.""" self._records = records - self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None + self._lookup: dict[DNSRecord, DNSRecord] | None = None @property - def lookup(self) -> Dict[DNSRecord, DNSRecord]: + def lookup(self) -> dict[DNSRecord, DNSRecord]: """Return the lookup table.""" return self._get_lookup() - def lookup_set(self) -> Set[DNSRecord]: + def lookup_set(self) -> set[DNSRecord]: """Return the lookup table as aset.""" return set(self._get_lookup()) - def _get_lookup(self) -> Dict[DNSRecord, DNSRecord]: + def _get_lookup(self) -> dict[DNSRecord, DNSRecord]: """Return the lookup table, building it if needed.""" if self._lookup is None: # Build the hash table so we can lookup the record ttl diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 05f8c948c..7b22f788e 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -20,11 +20,13 @@ USA """ +from __future__ import annotations + import asyncio import itertools import socket import threading -from typing import TYPE_CHECKING, List, Optional, cast +from typing import TYPE_CHECKING, cast from ._record_update import RecordUpdate from ._utils.asyncio import get_running_loop, run_coro_with_timeout @@ -58,31 +60,31 @@ class AsyncEngine: def __init__( self, - zeroconf: "Zeroconf", - listen_socket: Optional[socket.socket], - respond_sockets: List[socket.socket], + zeroconf: Zeroconf, + listen_socket: socket.socket | None, + respond_sockets: list[socket.socket], ) -> None: - self.loop: Optional[asyncio.AbstractEventLoop] = None + self.loop: asyncio.AbstractEventLoop | None = None self.zc = zeroconf - self.protocols: List[AsyncListener] = [] - self.readers: List[_WrappedTransport] = [] - self.senders: List[_WrappedTransport] = [] - self.running_event: Optional[asyncio.Event] = None + self.protocols: list[AsyncListener] = [] + self.readers: list[_WrappedTransport] = [] + self.senders: list[_WrappedTransport] = [] + self.running_event: asyncio.Event | None = None self._listen_socket = listen_socket self._respond_sockets = respond_sockets - self._cleanup_timer: Optional[asyncio.TimerHandle] = None + self._cleanup_timer: asyncio.TimerHandle | None = None def setup( self, loop: asyncio.AbstractEventLoop, - loop_thread_ready: Optional[threading.Event], + loop_thread_ready: threading.Event | None, ) -> None: """Set up the instance.""" self.loop = loop self.running_event = asyncio.Event() self.loop.create_task(self._async_setup(loop_thread_ready)) - async def _async_setup(self, loop_thread_ready: Optional[threading.Event]) -> None: + async def _async_setup(self, loop_thread_ready: threading.Event | None) -> None: """Set up the instance.""" self._async_schedule_next_cache_cleanup() await self._async_create_endpoints() diff --git a/src/zeroconf/_exceptions.py b/src/zeroconf/_exceptions.py index 5eb58f793..5fc812593 100644 --- a/src/zeroconf/_exceptions.py +++ b/src/zeroconf/_exceptions.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + class Error(Exception): """Base class for all zeroconf exceptions.""" diff --git a/src/zeroconf/_handlers/__init__.py b/src/zeroconf/_handlers/__init__.py index 30920c6aa..584a74eca 100644 --- a/src/zeroconf/_handlers/__init__.py +++ b/src/zeroconf/_handlers/__init__.py @@ -19,3 +19,5 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from __future__ import annotations diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index 7ddde1976..ec53eb842 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -20,8 +20,10 @@ USA """ +from __future__ import annotations + from operator import attrgetter -from typing import Dict, List, Set +from typing import Dict, Set from .._dns import DNSQuestion, DNSRecord from .._protocol.outgoing import DNSOutgoing @@ -96,7 +98,7 @@ def construct_outgoing_multicast_answers( def construct_outgoing_unicast_answers( answers: _AnswerWithAdditionalsType, ucast_source: bool, - questions: List[DNSQuestion], + questions: list[DNSQuestion], id_: int_, ) -> DNSOutgoing: """Add answers and additionals to a DNSOutgoing.""" @@ -111,7 +113,7 @@ def construct_outgoing_unicast_answers( def _add_answers_additionals(out: DNSOutgoing, answers: _AnswerWithAdditionalsType) -> None: # Find additionals and suppress any additionals that are already in answers - sending: Set[DNSRecord] = set(answers) + sending: set[DNSRecord] = set(answers) # Answers are sorted to group names together to increase the chance # that similar names will end up in the same packet and can reduce the # overall size of the outgoing response via name compression diff --git a/src/zeroconf/_handlers/multicast_outgoing_queue.py b/src/zeroconf/_handlers/multicast_outgoing_queue.py index caf6470b1..73d5ee431 100644 --- a/src/zeroconf/_handlers/multicast_outgoing_queue.py +++ b/src/zeroconf/_handlers/multicast_outgoing_queue.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + import random from collections import deque from typing import TYPE_CHECKING @@ -53,7 +55,7 @@ class MulticastOutgoingQueue: "zc", ) - def __init__(self, zeroconf: "Zeroconf", additional_delay: _int, max_aggregation_delay: _int) -> None: + def __init__(self, zeroconf: Zeroconf, additional_delay: _int, max_aggregation_delay: _int) -> None: self.zc = zeroconf self.queue: deque[AnswerGroup] = deque() # Additional delay is used to implement diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index ccfc7a771..60209568a 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -20,7 +20,9 @@ USA """ -from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast +from __future__ import annotations + +from typing import TYPE_CHECKING, cast from .._cache import DNSCache, _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord, DNSRRSet @@ -52,8 +54,8 @@ _RESPOND_IMMEDIATE_TYPES = {_TYPE_NSEC, _TYPE_SRV, *_ADDRESS_RECORD_TYPES} -_EMPTY_SERVICES_LIST: List[ServiceInfo] = [] -_EMPTY_TYPES_LIST: List[str] = [] +_EMPTY_SERVICES_LIST: list[ServiceInfo] = [] +_EMPTY_TYPES_LIST: list[str] = [] _IPVersion_ALL = IPVersion.All @@ -77,8 +79,8 @@ def __init__( self, question: DNSQuestion, strategy_type: _int, - types: List[str], - services: List[ServiceInfo], + types: list[str], + services: list[ServiceInfo], ) -> None: """Create an answer strategy.""" self.question = question @@ -102,17 +104,17 @@ class _QueryResponse: "_ucast", ) - def __init__(self, cache: DNSCache, questions: List[DNSQuestion], is_probe: bool, now: float) -> None: + def __init__(self, cache: DNSCache, questions: list[DNSQuestion], is_probe: bool, now: float) -> None: """Build a query response.""" self._is_probe = is_probe self._questions = questions self._now = now self._cache = cache self._additionals: _AnswerWithAdditionalsType = {} - self._ucast: Set[DNSRecord] = set() - self._mcast_now: Set[DNSRecord] = set() - self._mcast_aggregate: Set[DNSRecord] = set() - self._mcast_aggregate_last_second: Set[DNSRecord] = set() + self._ucast: set[DNSRecord] = set() + self._mcast_now: set[DNSRecord] = set() + self._mcast_aggregate: set[DNSRecord] = set() + self._mcast_aggregate_last_second: set[DNSRecord] = set() def add_qu_question_response(self, answers: _AnswerWithAdditionalsType) -> None: """Generate a response to a multicast QU query.""" @@ -199,7 +201,7 @@ class QueryHandler: "zc", ) - def __init__(self, zc: "Zeroconf") -> None: + def __init__(self, zc: Zeroconf) -> None: """Init the query handler.""" self.zc = zc self.registry = zc.registry @@ -210,7 +212,7 @@ def __init__(self, zc: "Zeroconf") -> None: def _add_service_type_enumeration_query_answers( self, - types: List[str], + types: list[str], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, ) -> None: @@ -232,7 +234,7 @@ def _add_service_type_enumeration_query_answers( def _add_pointer_answers( self, - services: List[ServiceInfo], + services: list[ServiceInfo], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, ) -> None: @@ -251,23 +253,23 @@ def _add_pointer_answers( def _add_address_answers( self, - services: List[ServiceInfo], + services: list[ServiceInfo], answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, type_: _int, ) -> None: """Answer A/AAAA/ANY question.""" for service in services: - answers: List[DNSAddress] = [] - additionals: Set[DNSRecord] = set() - seen_types: Set[int] = set() + answers: list[DNSAddress] = [] + additionals: set[DNSRecord] = set() + seen_types: set[int] = set() for dns_address in service._dns_addresses(None, _IPVersion_ALL): seen_types.add(dns_address.type) if dns_address.type != type_: additionals.add(dns_address) elif not known_answers.suppresses(dns_address): answers.append(dns_address) - missing_types: Set[int] = _ADDRESS_RECORD_TYPES - seen_types + missing_types: set[int] = _ADDRESS_RECORD_TYPES - seen_types if answers: if missing_types: assert service.server is not None, "Service server must be set for NSEC record." @@ -282,8 +284,8 @@ def _answer_question( self, question: DNSQuestion, strategy_type: _int, - types: List[str], - services: List[ServiceInfo], + types: list[str], + services: list[ServiceInfo], known_answers: DNSRRSet, ) -> _AnswerWithAdditionalsType: """Answer a question.""" @@ -311,14 +313,14 @@ def _answer_question( return answer_set def async_response( # pylint: disable=unused-argument - self, msgs: List[DNSIncoming], ucast_source: bool - ) -> Optional[QuestionAnswers]: + self, msgs: list[DNSIncoming], ucast_source: bool + ) -> QuestionAnswers | None: """Deal with incoming query packets. Provides a response if possible. This function must be run in the event loop as it is not threadsafe. """ - strategies: List[_AnswerStrategy] = [] + strategies: list[_AnswerStrategy] = [] for msg in msgs: for question in msg._questions: strategies.extend(self._get_answer_strategies(question)) @@ -334,7 +336,7 @@ def async_response( # pylint: disable=unused-argument questions = msg._questions # Only decode known answers if we are not a probe and we have # at least one answer strategy - answers: List[DNSRecord] = [] + answers: list[DNSRecord] = [] for msg in msgs: if msg.is_probe(): is_probe = True @@ -343,7 +345,7 @@ def async_response( # pylint: disable=unused-argument query_res = _QueryResponse(self.cache, questions, is_probe, msg.now) known_answers = DNSRRSet(answers) - known_answers_set: Optional[Set[DNSRecord]] = None + known_answers_set: set[DNSRecord] | None = None now = msg.now for strategy in strategies: question = strategy.question @@ -373,12 +375,12 @@ def async_response( # pylint: disable=unused-argument def _get_answer_strategies( self, question: DNSQuestion, - ) -> List[_AnswerStrategy]: + ) -> list[_AnswerStrategy]: """Collect strategies to answer a question.""" name = question.name question_lower_name = name.lower() type_ = question.type - strategies: List[_AnswerStrategy] = [] + strategies: list[_AnswerStrategy] = [] if type_ == _TYPE_PTR and question_lower_name == _SERVICE_TYPE_ENUMERATION_NAME: types = self.registry.async_get_types() @@ -433,11 +435,11 @@ def _get_answer_strategies( def handle_assembled_query( self, - packets: List[DNSIncoming], + packets: list[DNSIncoming], addr: _str, port: _int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]], + v6_flow_scope: tuple[()] | tuple[int, int], ) -> None: """Respond to a (re)assembled query. diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index d4e068c2e..37232b131 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -21,7 +21,7 @@ cdef class RecordManager: cdef public DNSCache cache cdef public cython.set listeners - cpdef void async_updates(self, object now, object records) + cpdef void async_updates(self, object now, list records) cpdef void async_updates_complete(self, bint notify) diff --git a/src/zeroconf/_handlers/record_manager.py b/src/zeroconf/_handlers/record_manager.py index d4e2792c8..566f0e8c9 100644 --- a/src/zeroconf/_handlers/record_manager.py +++ b/src/zeroconf/_handlers/record_manager.py @@ -20,7 +20,9 @@ USA """ -from typing import TYPE_CHECKING, List, Optional, Set, Tuple, Union, cast +from __future__ import annotations + +from typing import TYPE_CHECKING, cast from .._cache import _UniqueRecordsType from .._dns import DNSQuestion, DNSRecord @@ -42,13 +44,13 @@ class RecordManager: __slots__ = ("cache", "listeners", "zc") - def __init__(self, zeroconf: "Zeroconf") -> None: + def __init__(self, zeroconf: Zeroconf) -> None: """Init the record manager.""" self.zc = zeroconf self.cache = zeroconf.cache - self.listeners: Set[RecordUpdateListener] = set() + self.listeners: set[RecordUpdateListener] = set() - def async_updates(self, now: _float, records: List[RecordUpdate]) -> None: + def async_updates(self, now: _float, records: list[RecordUpdate]) -> None: """Used to notify listeners of new information that has updated a record. @@ -79,12 +81,12 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: This function must be run in the event loop as it is not threadsafe. """ - updates: List[RecordUpdate] = [] - address_adds: List[DNSRecord] = [] - other_adds: List[DNSRecord] = [] - removes: Set[DNSRecord] = set() + updates: list[RecordUpdate] = [] + address_adds: list[DNSRecord] = [] + other_adds: list[DNSRecord] = [] + removes: set[DNSRecord] = set() now = msg.now - unique_types: Set[Tuple[str, int, int]] = set() + unique_types: set[tuple[str, int, int]] = set() cache = self.cache answers = msg.answers() @@ -165,7 +167,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: def async_add_listener( self, listener: RecordUpdateListener, - question: Optional[Union[DNSQuestion, List[DNSQuestion]]], + question: DNSQuestion | list[DNSQuestion] | None, ) -> None: """Adds a listener for a given question. The listener will have its update_record method called when information is available to @@ -188,14 +190,14 @@ def async_add_listener( self._async_update_matching_records(listener, questions) def _async_update_matching_records( - self, listener: RecordUpdateListener, questions: List[DNSQuestion] + self, listener: RecordUpdateListener, questions: list[DNSQuestion] ) -> None: """Calls back any existing entries in the cache that answer the question. This function must be run from the event loop. """ now = current_time_millis() - records: List[RecordUpdate] = [ + records: list[RecordUpdate] = [ RecordUpdate(record, None) for question in questions for record in self.cache.async_entries_with_name(question.name) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index aa28519c5..5bae7be04 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -20,7 +20,7 @@ USA """ -from typing import Dict, List, Set, Tuple +from __future__ import annotations from ._dns import DNSQuestion, DNSRecord from .const import _DUPLICATE_QUESTION_INTERVAL @@ -36,13 +36,13 @@ class QuestionHistory: def __init__(self) -> None: """Init a new QuestionHistory.""" - self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} + self._history: dict[DNSQuestion, tuple[float, set[DNSRecord]]] = {} - def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> None: + def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None: """Remember a question with known answers.""" self._history[question] = (now, known_answers) - def suppresses(self, question: DNSQuestion, now: _float, known_answers: Set[DNSRecord]) -> bool: + def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> bool: """Check to see if a question should be suppressed. https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 @@ -66,7 +66,7 @@ def suppresses(self, question: DNSQuestion, now: _float, known_answers: Set[DNSR def async_expire(self, now: _float) -> None: """Expire the history of old questions.""" - removes: List[DNSQuestion] = [] + removes: list[DNSQuestion] = [] for question, now_known_answers in self._history.items(): than, _ = now_known_answers if now - than > _DUPLICATE_QUESTION_INTERVAL: diff --git a/src/zeroconf/_logger.py b/src/zeroconf/_logger.py index 1556522eb..0d734dfde 100644 --- a/src/zeroconf/_logger.py +++ b/src/zeroconf/_logger.py @@ -21,9 +21,11 @@ USA """ +from __future__ import annotations + import logging import sys -from typing import Any, ClassVar, Dict, Union, cast +from typing import Any, ClassVar, cast log = logging.getLogger(__name__.split(".", maxsplit=1)[0]) log.addHandler(logging.NullHandler()) @@ -38,7 +40,7 @@ def set_logger_level_if_unset() -> None: class QuietLogger: - _seen_logs: ClassVar[Dict[str, Union[int, tuple]]] = {} + _seen_logs: ClassVar[dict[str, int | tuple]] = {} @classmethod def log_exception_warning(cls, *logger_data: Any) -> None: diff --git a/src/zeroconf/_protocol/__init__.py b/src/zeroconf/_protocol/__init__.py index 30920c6aa..584a74eca 100644 --- a/src/zeroconf/_protocol/__init__.py +++ b/src/zeroconf/_protocol/__init__.py @@ -19,3 +19,5 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from __future__ import annotations diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 6e009b293..7f4a8eec1 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -20,9 +20,11 @@ USA """ +from __future__ import annotations + import struct import sys -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any from .._dns import ( DNSAddress, @@ -61,7 +63,7 @@ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) -_seen_logs: Dict[str, Union[int, tuple]] = {} +_seen_logs: dict[str, int | tuple] = {} _str = str _int = int @@ -94,9 +96,9 @@ class DNSIncoming: def __init__( self, data: bytes, - source: Optional[Tuple[str, int]] = None, - scope_id: Optional[int] = None, - now: Optional[float] = None, + source: tuple[str, int] | None = None, + scope_id: int | None = None, + now: float | None = None, ) -> None: """Constructor from string holding bytes of packet""" self.flags = 0 @@ -104,9 +106,9 @@ def __init__( self.data = data self.view = data self._data_len = len(data) - self._name_cache: Dict[int, List[str]] = {} - self._questions: List[DNSQuestion] = [] - self._answers: List[DNSRecord] = [] + self._name_cache: dict[int, list[str]] = {} + self._questions: list[DNSQuestion] = [] + self._answers: list[DNSRecord] = [] self.id = 0 self._num_questions = 0 self._num_answers = 0 @@ -146,7 +148,7 @@ def truncated(self) -> bool: return (self.flags & _FLAGS_TC) == _FLAGS_TC @property - def questions(self) -> List[DNSQuestion]: + def questions(self) -> list[DNSQuestion]: """Questions in the packet.""" return self._questions @@ -189,7 +191,7 @@ def _log_exception_debug(cls, *logger_data: Any) -> None: log_exc_info = True log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info) - def answers(self) -> List[DNSRecord]: + def answers(self) -> list[DNSRecord]: """Answers in the packet.""" if not self._did_read_others: try: @@ -306,7 +308,7 @@ def _read_others(self) -> None: def _read_record( self, domain: _str, type_: _int, class_: _int, ttl: _int, length: _int - ) -> Optional[DNSRecord]: + ) -> DNSRecord | None: """Read known records types and skip unknown ones.""" if type_ == _TYPE_A: address_rec = DNSAddress.__new__(DNSAddress) @@ -384,7 +386,7 @@ def _read_record( self.offset += length return None - def _read_bitmap(self, end: _int) -> List[int]: + def _read_bitmap(self, end: _int) -> list[int]: """Reads an NSEC bitmap from the packet.""" rdtypes = [] view = self.view @@ -404,8 +406,8 @@ def _read_bitmap(self, end: _int) -> List[int]: def _read_name(self) -> str: """Reads a domain name from the packet.""" - labels: List[str] = [] - seen_pointers: Set[int] = set() + labels: list[str] = [] + seen_pointers: set[int] = set() original_offset = self.offset self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) self._name_cache[original_offset] = labels @@ -416,7 +418,7 @@ def _read_name(self) -> str: ) return name - def _decode_labels_at_offset(self, off: _int, labels: List[str], seen_pointers: Set[int]) -> int: + def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. view = self.view while off < self._data_len: diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index c937350ed..f5d098211 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -20,10 +20,12 @@ USA """ +from __future__ import annotations + import enum import logging from struct import Struct -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Sequence from .._dns import DNSPointer, DNSQuestion, DNSRecord from .._exceptions import NamePartTooLongException @@ -98,20 +100,20 @@ def __init__(self, flags: int, multicast: bool = True, id_: int = 0) -> None: self.finished = False self.id = id_ self.multicast = multicast - self.packets_data: List[bytes] = [] + self.packets_data: list[bytes] = [] # these 3 are per-packet -- see also _reset_for_next_packet() - self.names: Dict[str, int] = {} - self.data: List[bytes] = [] + self.names: dict[str, int] = {} + self.data: list[bytes] = [] self.size: int = _DNS_PACKET_HEADER_LEN self.allow_long: bool = True self.state = STATE_INIT - self.questions: List[DNSQuestion] = [] - self.answers: List[Tuple[DNSRecord, float]] = [] - self.authorities: List[DNSPointer] = [] - self.additionals: List[DNSRecord] = [] + self.questions: list[DNSQuestion] = [] + self.answers: list[tuple[DNSRecord, float]] = [] + self.authorities: list[DNSPointer] = [] + self.additionals: list[DNSRecord] = [] def is_query(self) -> bool: """Returns true if this is a query.""" @@ -150,7 +152,7 @@ def add_answer(self, inp: DNSIncoming, record: DNSRecord) -> None: if not record.suppressed_by(inp): self.add_answer_at_time(record, 0.0) - def add_answer_at_time(self, record: Optional[DNSRecord], now: float_) -> None: + def add_answer_at_time(self, record: DNSRecord | None, now: float_) -> None: """Adds an answer if it does not expire by a certain time""" now_double = now if record is not None and (now_double == 0 or not record.is_expired(now_double)): @@ -220,7 +222,7 @@ def write_short(self, value: int_) -> None: self.data.append(self._get_short(value)) self.size += 2 - def _write_int(self, value: Union[float, int]) -> None: + def _write_int(self, value: float | int) -> None: """Writes an unsigned integer to the packet""" value_as_int = int(value) long_bytes = LONG_LOOKUP.get(value_as_int) @@ -313,7 +315,7 @@ def _write_question(self, question: DNSQuestion_) -> bool: self._write_record_class(question) return self._check_data_limit_or_rollback(start_data_length, start_size) - def _write_record_class(self, record: Union[DNSQuestion_, DNSRecord_]) -> None: + def _write_record_class(self, record: DNSQuestion_ | DNSRecord_) -> None: """Write out the record class including the unique/unicast (QU) bit.""" class_ = record.class_ if record.unique is True and self.multicast: @@ -409,7 +411,7 @@ def _has_more_to_add( or additional_offset < len(self.additionals) ) - def packets(self) -> List[bytes]: + def packets(self) -> list[bytes]: """Returns a list of bytestrings containing the packets' bytes No further parts should be added to the packet once this diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index 912ab6f1d..5f8175113 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -20,7 +20,7 @@ USA """ -from typing import Optional +from __future__ import annotations from ._dns import DNSRecord @@ -30,16 +30,16 @@ class RecordUpdate: __slots__ = ("new", "old") - def __init__(self, new: DNSRecord, old: Optional[DNSRecord] = None) -> None: + def __init__(self, new: DNSRecord, old: DNSRecord | None = None) -> None: """RecordUpdate represents a change in a DNS record.""" self._fast_init(new, old) - def _fast_init(self, new: _DNSRecord, old: Optional[_DNSRecord]) -> None: + def _fast_init(self, new: _DNSRecord, old: _DNSRecord | None) -> None: """Fast init for RecordUpdate.""" self.new = new self.old = old - def __getitem__(self, index: int) -> Optional[DNSRecord]: + def __getitem__(self, index: int) -> DNSRecord | None: """Get the new or old record.""" if index == 0: return self.new diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 7a6bddebb..6936aed61 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -20,8 +20,10 @@ USA """ +from __future__ import annotations + import enum -from typing import TYPE_CHECKING, Any, Callable, List +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from .._core import Zeroconf @@ -35,13 +37,13 @@ class ServiceStateChange(enum.Enum): class ServiceListener: - def add_service(self, zc: "Zeroconf", type_: str, name: str) -> None: + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() - def remove_service(self, zc: "Zeroconf", type_: str, name: str) -> None: + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() - def update_service(self, zc: "Zeroconf", type_: str, name: str) -> None: + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() @@ -49,27 +51,27 @@ class Signal: __slots__ = ("_handlers",) def __init__(self) -> None: - self._handlers: List[Callable[..., None]] = [] + self._handlers: list[Callable[..., None]] = [] def fire(self, **kwargs: Any) -> None: for h in self._handlers[:]: h(**kwargs) @property - def registration_interface(self) -> "SignalRegistrationInterface": + def registration_interface(self) -> SignalRegistrationInterface: return SignalRegistrationInterface(self._handlers) class SignalRegistrationInterface: __slots__ = ("_handlers",) - def __init__(self, handlers: List[Callable[..., None]]) -> None: + def __init__(self, handlers: list[Callable[..., None]]) -> None: self._handlers = handlers - def register_handler(self, handler: Callable[..., None]) -> "SignalRegistrationInterface": + def register_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface: self._handlers.append(handler) return self - def unregister_handler(self, handler: Callable[..., None]) -> "SignalRegistrationInterface": + def unregister_handler(self, handler: Callable[..., None]) -> SignalRegistrationInterface: self._handlers.remove(handler) return self diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 42aaa1ac8..c2ab115b0 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + import asyncio import heapq import queue @@ -36,11 +38,7 @@ Dict, Iterable, List, - Optional, Set, - Tuple, - Type, - Union, cast, ) @@ -155,13 +153,13 @@ def __repr__(self) -> str: ">" ) - def __lt__(self, other: "_ScheduledPTRQuery") -> bool: + def __lt__(self, other: _ScheduledPTRQuery) -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis < other.when_millis return NotImplemented - def __le__(self, other: "_ScheduledPTRQuery") -> bool: + def __le__(self, other: _ScheduledPTRQuery) -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis < other.when_millis or self.__eq__(other) @@ -173,13 +171,13 @@ def __eq__(self, other: Any) -> bool: return self.when_millis == other.when_millis return NotImplemented - def __ge__(self, other: "_ScheduledPTRQuery") -> bool: + def __ge__(self, other: _ScheduledPTRQuery) -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis > other.when_millis or self.__eq__(other) return NotImplemented - def __gt__(self, other: "_ScheduledPTRQuery") -> bool: + def __gt__(self, other: _ScheduledPTRQuery) -> bool: """Compare two scheduled queries.""" if type(other) is _ScheduledPTRQuery: return self.when_millis > other.when_millis @@ -197,7 +195,7 @@ def __init__(self, now_millis: float, multicast: bool) -> None: self.out = DNSOutgoing(_FLAGS_QR_QUERY, multicast) self.bytes = 0 - def add(self, max_compressed_size: int_, question: DNSQuestion, answers: Set[DNSPointer]) -> None: + def add(self, max_compressed_size: int_, question: DNSQuestion, answers: set[DNSPointer]) -> None: """Add a new set of questions and known answers to the outgoing.""" self.out.add_question(question) for answer in answers: @@ -209,7 +207,7 @@ def group_ptr_queries_with_known_answers( now: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers, -) -> List[DNSOutgoing]: +) -> list[DNSOutgoing]: """Aggregate queries so that as many known answers as possible fit in the same packet without having known answers spill over into the next packet unless the question and known answers are always going to exceed the packet size. @@ -225,19 +223,19 @@ def _group_ptr_queries_with_known_answers( now_millis: float_, multicast: bool_, question_with_known_answers: _QuestionWithKnownAnswers, -) -> List[DNSOutgoing]: +) -> list[DNSOutgoing]: """Inner wrapper for group_ptr_queries_with_known_answers.""" # This is the maximum size the query + known answers can be with name compression. # The actual size of the query + known answers may be a bit smaller since other # parts may be shared when the final DNSOutgoing packets are constructed. The # goal of this algorithm is to quickly bucket the query + known answers without # the overhead of actually constructing the packets. - query_by_size: Dict[DNSQuestion, int] = { + query_by_size: dict[DNSQuestion, int] = { question: (question.max_size + sum(answer.max_size_compressed for answer in known_answers)) for question, known_answers in question_with_known_answers.items() } max_bucket_size = _MAX_MSG_TYPICAL - _DNS_PACKET_HEADER_LEN - query_buckets: List[_DNSPointerOutgoingBucket] = [] + query_buckets: list[_DNSPointerOutgoingBucket] = [] for question in sorted( query_by_size, key=query_by_size.get, # type: ignore @@ -261,12 +259,12 @@ def _group_ptr_queries_with_known_answers( def generate_service_query( - zc: "Zeroconf", + zc: Zeroconf, now_millis: float_, - types_: Set[str], + types_: set[str], multicast: bool, - question_type: Optional[DNSQuestionType], -) -> List[DNSOutgoing]: + question_type: DNSQuestionType | None, +) -> list[DNSOutgoing]: """Generate a service query for sending with zeroconf.send.""" questions_with_known_answers: _QuestionWithKnownAnswers = {} qu_question = not multicast if question_type is None else question_type is QU_QUESTION @@ -296,7 +294,7 @@ def generate_service_query( def _on_change_dispatcher( listener: ServiceListener, - zeroconf: "Zeroconf", + zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange, @@ -346,14 +344,14 @@ class QueryScheduler: def __init__( self, - zc: "Zeroconf", - types: Set[str], - addr: Optional[str], + zc: Zeroconf, + types: set[str], + addr: str | None, port: int, multicast: bool, delay: int, - first_random_delay_interval: Tuple[int, int], - question_type: Optional[DNSQuestionType], + first_random_delay_interval: tuple[int, int], + question_type: DNSQuestionType | None, ) -> None: self._zc = zc self._types = types @@ -362,11 +360,11 @@ def __init__( self._multicast = multicast self._first_random_delay_interval = first_random_delay_interval self._min_time_between_queries_millis = delay - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None self._startup_queries_sent = 0 - self._next_scheduled_for_alias: Dict[str, _ScheduledPTRQuery] = {} + self._next_scheduled_for_alias: dict[str, _ScheduledPTRQuery] = {} self._query_heap: list[_ScheduledPTRQuery] = [] - self._next_run: Optional[asyncio.TimerHandle] = None + self._next_run: asyncio.TimerHandle | None = None self._clock_resolution_millis = time.get_clock_info("monotonic").resolution * 1000 self._question_type = question_type @@ -500,10 +498,10 @@ def _process_ready_types(self) -> None: # with a minimum time between queries of _min_time_between_queries # which defaults to 10s - ready_types: Set[str] = set() - next_scheduled: Optional[_ScheduledPTRQuery] = None + ready_types: set[str] = set() + next_scheduled: _ScheduledPTRQuery | None = None end_time_millis = now_millis + self._clock_resolution_millis - schedule_rescue: List[_ScheduledPTRQuery] = [] + schedule_rescue: list[_ScheduledPTRQuery] = [] while self._query_heap: query = self._query_heap[0] @@ -538,7 +536,7 @@ def _process_ready_types(self) -> None: self._next_run = self._loop.call_at(millis_to_seconds(next_when_millis), self._process_ready_types) def async_send_ready_queries( - self, first_request: bool, now_millis: float_, ready_types: Set[str] + self, first_request: bool, now_millis: float_, ready_types: set[str] ) -> None: """Send any ready queries.""" # If they did not specify and this is the first request, ask QU questions @@ -569,14 +567,14 @@ class _ServiceBrowserBase(RecordUpdateListener): def __init__( self, - zc: "Zeroconf", - type_: Union[str, list], - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[ServiceListener] = None, - addr: Optional[str] = None, + zc: Zeroconf, + type_: str | list, + handlers: ServiceListener | list[Callable[..., None]] | None = None, + listener: ServiceListener | None = None, + addr: str | None = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, - question_type: Optional[DNSQuestionType] = None, + question_type: DNSQuestionType | None = None, ) -> None: """Used to browse for a service for specific type(s). @@ -596,7 +594,7 @@ def __init__( discovers changes in the services availability. """ assert handlers or listener, "You need to specify at least one handler" - self.types: Set[str] = set(type_ if isinstance(type_, list) else [type_]) + self.types: set[str] = set(type_ if isinstance(type_, list) else [type_]) for check_type_ in self.types: # Will generate BadTypeInNameException on a bad name service_type_name(check_type_, strict=False) @@ -604,7 +602,7 @@ def __init__( self._cache = zc.cache assert zc.loop is not None self._loop = zc.loop - self._pending_handlers: Dict[Tuple[str, str], ServiceStateChange] = {} + self._pending_handlers: dict[tuple[str, str], ServiceStateChange] = {} self._service_state_changed = Signal() self.query_scheduler = QueryScheduler( zc, @@ -617,7 +615,7 @@ def __init__( question_type, ) self.done = False - self._query_sender_task: Optional[asyncio.Task] = None + self._query_sender_task: asyncio.Task | None = None if hasattr(handlers, "add_service"): listener = cast("ServiceListener", handlers) @@ -645,7 +643,7 @@ def _async_start(self) -> None: def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface - def _names_matching_types(self, names: Iterable[str]) -> List[Tuple[str, str]]: + def _names_matching_types(self, names: Iterable[str]) -> list[tuple[str, str]]: """Return the type and name for records matching the types we are browsing.""" return [ (type_, name) for name in names for type_ in self.types.intersection(cached_possible_types(name)) @@ -670,7 +668,7 @@ def _enqueue_callback( ): self._pending_handlers[key] = state_change - def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float_, records: list[RecordUpdate]) -> None: """Callback invoked by Zeroconf when new information arrives. Updates information required by browser in the Zeroconf cache. @@ -727,7 +725,7 @@ def async_update_records_complete(self) -> None: self._fire_service_state_changed_event(pending) self._pending_handlers.clear() - def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], ServiceStateChange]) -> None: + def _fire_service_state_changed_event(self, event: tuple[tuple[str, str], ServiceStateChange]) -> None: """Fire a service state changed event. When running with ServiceBrowser, this will happen in the dedicated @@ -769,14 +767,14 @@ class ServiceBrowser(_ServiceBrowserBase, threading.Thread): def __init__( self, - zc: "Zeroconf", - type_: Union[str, list], - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[ServiceListener] = None, - addr: Optional[str] = None, + zc: Zeroconf, + type_: str | list, + handlers: ServiceListener | list[Callable[..., None]] | None = None, + listener: ServiceListener | None = None, + addr: str | None = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, - question_type: Optional[DNSQuestionType] = None, + question_type: DNSQuestionType | None = None, ) -> None: assert zc.loop is not None if not zc.loop.is_running(): @@ -821,14 +819,14 @@ def async_update_records_complete(self) -> None: self.queue.put(pending) self._pending_handlers.clear() - def __enter__(self) -> "ServiceBrowser": + def __enter__(self) -> ServiceBrowser: return self def __exit__( # pylint: disable=useless-return self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: self.cancel() return None diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index a6e815b51..677774594 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -20,9 +20,11 @@ USA """ +from __future__ import annotations + import asyncio import random -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, cast from .._cache import DNSCache from .._dns import ( @@ -106,7 +108,7 @@ from .._core import Zeroconf -def instance_name_from_service_info(info: "ServiceInfo", strict: bool = True) -> str: +def instance_name_from_service_info(info: ServiceInfo, strict: bool = True) -> str: """Calculate the instance name from the ServiceInfo.""" # This is kind of funky because of the subtype based tests # need to make subtypes a first class citizen @@ -168,17 +170,17 @@ def __init__( self, type_: str, name: str, - port: Optional[int] = None, + port: int | None = None, weight: int = 0, priority: int = 0, - properties: Union[bytes, Dict] = b"", - server: Optional[str] = None, + properties: bytes | dict = b"", + server: str | None = None, host_ttl: int = _DNS_HOST_TTL, other_ttl: int = _DNS_OTHER_TTL, *, - addresses: Optional[List[bytes]] = None, - parsed_addresses: Optional[List[str]] = None, - interface_index: Optional[int] = None, + addresses: list[bytes] | None = None, + parsed_addresses: list[str] | None = None, + interface_index: int | None = None, ) -> None: # Accept both none, or one, but not both. if addresses is not None and parsed_addresses is not None: @@ -190,8 +192,8 @@ def __init__( self.type = type_ self._name = name self.key = name.lower() - self._ipv4_addresses: List[ZeroconfIPv4Address] = [] - self._ipv6_addresses: List[ZeroconfIPv6Address] = [] + self._ipv4_addresses: list[ZeroconfIPv4Address] = [] + self._ipv6_addresses: list[ZeroconfIPv6Address] = [] if addresses is not None: self.addresses = addresses elif parsed_addresses is not None: @@ -201,20 +203,20 @@ def __init__( self.priority = priority self.server = server if server else None self.server_key = server.lower() if server else None - self._properties: Optional[Dict[bytes, Optional[bytes]]] = None - self._decoded_properties: Optional[Dict[str, Optional[str]]] = None + self._properties: dict[bytes, bytes | None] | None = None + self._decoded_properties: dict[str, str | None] | None = None if isinstance(properties, bytes): self._set_text(properties) else: self._set_properties(properties) self.host_ttl = host_ttl self.other_ttl = other_ttl - self._new_records_futures: Optional[Set[asyncio.Future]] = None - self._dns_address_cache: Optional[List[DNSAddress]] = None - self._dns_pointer_cache: Optional[DNSPointer] = None - self._dns_service_cache: Optional[DNSService] = None - self._dns_text_cache: Optional[DNSText] = None - self._get_address_and_nsec_records_cache: Optional[Set[DNSRecord]] = None + self._new_records_futures: set[asyncio.Future] | None = None + self._dns_address_cache: list[DNSAddress] | None = None + self._dns_pointer_cache: DNSPointer | None = None + self._dns_service_cache: DNSService | None = None + self._dns_text_cache: DNSText | None = None + self._get_address_and_nsec_records_cache: set[DNSRecord] | None = None self._query_record_types = {_TYPE_SRV, _TYPE_TXT, _TYPE_A, _TYPE_AAAA} @property @@ -232,7 +234,7 @@ def name(self, name: str) -> None: self._dns_text_cache = None @property - def addresses(self) -> List[bytes]: + def addresses(self) -> list[bytes]: """IPv4 addresses of this service. Only IPv4 addresses are returned for backward compatibility. @@ -242,7 +244,7 @@ def addresses(self) -> List[bytes]: return self.addresses_by_version(IPVersion.V4Only) @addresses.setter - def addresses(self, value: List[bytes]) -> None: + def addresses(self, value: list[bytes]) -> None: """Replace the addresses list. This replaces all currently stored addresses, both IPv4 and IPv6. @@ -272,7 +274,7 @@ def addresses(self, value: List[bytes]) -> None: self._ipv6_addresses.append(addr) @property - def properties(self) -> Dict[bytes, Optional[bytes]]: + def properties(self) -> dict[bytes, bytes | None]: """Return properties as bytes.""" if self._properties is None: self._unpack_text_into_properties() @@ -281,7 +283,7 @@ def properties(self) -> Dict[bytes, Optional[bytes]]: return self._properties @property - def decoded_properties(self) -> Dict[str, Optional[str]]: + def decoded_properties(self) -> dict[str, str | None]: """Return properties as strings.""" if self._decoded_properties is None: self._generate_decoded_properties() @@ -297,7 +299,7 @@ def async_clear_cache(self) -> None: self._dns_text_cache = None self._get_address_and_nsec_records_cache = None - async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + async def async_wait(self, timeout: float, loop: asyncio.AbstractEventLoop | None = None) -> None: """Calling task waits for a given number of milliseconds or until notified.""" if not self._new_records_futures: self._new_records_futures = set() @@ -305,7 +307,7 @@ async def async_wait(self, timeout: float, loop: Optional[asyncio.AbstractEventL loop or asyncio.get_running_loop(), self._new_records_futures, timeout ) - def addresses_by_version(self, version: IPVersion) -> List[bytes]: + def addresses_by_version(self, version: IPVersion) -> list[bytes]: """List addresses matching IP version. Addresses are guaranteed to be returned in LIFO (last in, first out) @@ -325,7 +327,7 @@ def addresses_by_version(self, version: IPVersion) -> List[bytes]: def ip_addresses_by_version( self, version: IPVersion - ) -> Union[List[ZeroconfIPv4Address], List[ZeroconfIPv6Address]]: + ) -> list[ZeroconfIPv4Address] | list[ZeroconfIPv6Address]: """List ip_address objects matching IP version. Addresses are guaranteed to be returned in LIFO (last in, first out) @@ -338,7 +340,7 @@ def ip_addresses_by_version( def _ip_addresses_by_version_value( self, version_value: int_ - ) -> Union[List[ZeroconfIPv4Address], List[ZeroconfIPv6Address]]: + ) -> list[ZeroconfIPv4Address] | list[ZeroconfIPv6Address]: """Backend for addresses_by_version that uses the raw value.""" if version_value == _IPVersion_All_value: return [*self._ipv4_addresses, *self._ipv6_addresses] # type: ignore[return-value] @@ -346,7 +348,7 @@ def _ip_addresses_by_version_value( return self._ipv4_addresses return self._ipv6_addresses - def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + def parsed_addresses(self, version: IPVersion = IPVersion.All) -> list[str]: """List addresses in their parsed string form. Addresses are guaranteed to be returned in LIFO (last in, first out) @@ -357,7 +359,7 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: """ return [str_without_scope_id(addr) for addr in self._ip_addresses_by_version_value(version.value)] - def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]: + def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> list[str]: """Equivalent to parsed_addresses, with the exception that IPv6 Link-Local addresses are qualified with % when available @@ -369,9 +371,9 @@ def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[st """ return [str(addr) for addr in self._ip_addresses_by_version_value(version.value)] - def _set_properties(self, properties: Dict[Union[str, bytes], Optional[Union[str, bytes]]]) -> None: + def _set_properties(self, properties: dict[str | bytes, str | bytes | None]) -> None: """Sets properties and text of this info from a dictionary""" - list_: List[bytes] = [] + list_: list[bytes] = [] properties_contain_str = False result = b"" for key, value in properties.items(): @@ -425,7 +427,7 @@ def _unpack_text_into_properties(self) -> None: return index = 0 - properties: Dict[bytes, Optional[bytes]] = {} + properties: dict[bytes, bytes | None] = {} while index < end: length = text[index] index += 1 @@ -443,10 +445,10 @@ def get_name(self) -> str: return self._name[: len(self._name) - len(self.type) - 1] def _get_ip_addresses_from_cache_lifo( - self, zc: "Zeroconf", now: float_, type: int_ - ) -> List[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: + self, zc: Zeroconf, now: float_, type: int_ + ) -> list[ZeroconfIPv4Address | ZeroconfIPv6Address]: """Set IPv6 addresses from the cache.""" - address_list: List[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]] = [] + address_list: list[ZeroconfIPv4Address | ZeroconfIPv6Address] = [] for record in self._get_address_records_from_cache_by_type(zc, type): if record.is_expired(now): continue @@ -456,7 +458,7 @@ def _get_ip_addresses_from_cache_lifo( address_list.reverse() # Reverse to get LIFO order return address_list - def _set_ipv6_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: + def _set_ipv6_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: self._ipv6_addresses = cast( @@ -466,7 +468,7 @@ def _set_ipv6_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: else: self._ipv6_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA) - def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: + def _set_ipv4_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None: """Set IPv4 addresses from the cache.""" if TYPE_CHECKING: self._ipv4_addresses = cast( @@ -476,7 +478,7 @@ def _set_ipv4_addresses_from_cache(self, zc: "Zeroconf", now: float_) -> None: else: self._ipv4_addresses = self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A) - def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float_, records: list[RecordUpdate]) -> None: """Updates service information from a DNS record. This method will be run in the event loop. @@ -488,7 +490,7 @@ def async_update_records(self, zc: "Zeroconf", now: float_, records: List[Record if updated and new_records_futures: _resolve_all_futures_to_none(new_records_futures) - def _process_record_threadsafe(self, zc: "Zeroconf", record: DNSRecord, now: float_) -> bool: + def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float_) -> bool: """Thread safe record updating. Returns True if a new record was added. @@ -575,17 +577,17 @@ def _process_record_threadsafe(self, zc: "Zeroconf", record: DNSRecord, now: flo def dns_addresses( self, - override_ttl: Optional[int] = None, + override_ttl: int | None = None, version: IPVersion = IPVersion.All, - ) -> List[DNSAddress]: + ) -> list[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" return self._dns_addresses(override_ttl, version) def _dns_addresses( self, - override_ttl: Optional[int], + override_ttl: int | None, version: IPVersion, - ) -> List[DNSAddress]: + ) -> list[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" cacheable = version is IPVersion.All and override_ttl is None if self._dns_address_cache is not None and cacheable: @@ -609,11 +611,11 @@ def _dns_addresses( self._dns_address_cache = records return records - def dns_pointer(self, override_ttl: Optional[int] = None) -> DNSPointer: + def dns_pointer(self, override_ttl: int | None = None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" return self._dns_pointer(override_ttl) - def _dns_pointer(self, override_ttl: Optional[int]) -> DNSPointer: + def _dns_pointer(self, override_ttl: int | None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" cacheable = override_ttl is None if self._dns_pointer_cache is not None and cacheable: @@ -630,11 +632,11 @@ def _dns_pointer(self, override_ttl: Optional[int]) -> DNSPointer: self._dns_pointer_cache = record return record - def dns_service(self, override_ttl: Optional[int] = None) -> DNSService: + def dns_service(self, override_ttl: int | None = None) -> DNSService: """Return DNSService from ServiceInfo.""" return self._dns_service(override_ttl) - def _dns_service(self, override_ttl: Optional[int]) -> DNSService: + def _dns_service(self, override_ttl: int | None) -> DNSService: """Return DNSService from ServiceInfo.""" cacheable = override_ttl is None if self._dns_service_cache is not None and cacheable: @@ -657,11 +659,11 @@ def _dns_service(self, override_ttl: Optional[int]) -> DNSService: self._dns_service_cache = record return record - def dns_text(self, override_ttl: Optional[int] = None) -> DNSText: + def dns_text(self, override_ttl: int | None = None) -> DNSText: """Return DNSText from ServiceInfo.""" return self._dns_text(override_ttl) - def _dns_text(self, override_ttl: Optional[int]) -> DNSText: + def _dns_text(self, override_ttl: int | None) -> DNSText: """Return DNSText from ServiceInfo.""" cacheable = override_ttl is None if self._dns_text_cache is not None and cacheable: @@ -678,11 +680,11 @@ def _dns_text(self, override_ttl: Optional[int]) -> DNSText: self._dns_text_cache = record return record - def dns_nsec(self, missing_types: List[int], override_ttl: Optional[int] = None) -> DNSNsec: + def dns_nsec(self, missing_types: list[int], override_ttl: int | None = None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return self._dns_nsec(missing_types, override_ttl) - def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DNSNsec: + def _dns_nsec(self, missing_types: list[int], override_ttl: int | None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -694,17 +696,17 @@ def _dns_nsec(self, missing_types: List[int], override_ttl: Optional[int]) -> DN 0.0, ) - def get_address_and_nsec_records(self, override_ttl: Optional[int] = None) -> Set[DNSRecord]: + def get_address_and_nsec_records(self, override_ttl: int | None = None) -> set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" return self._get_address_and_nsec_records(override_ttl) - def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSRecord]: + def _get_address_and_nsec_records(self, override_ttl: int | None) -> set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" cacheable = override_ttl is None if self._get_address_and_nsec_records_cache is not None and cacheable: return self._get_address_and_nsec_records_cache - missing_types: Set[int] = _ADDRESS_RECORD_TYPES.copy() - records: Set[DNSRecord] = set() + missing_types: set[int] = _ADDRESS_RECORD_TYPES.copy() + records: set[DNSRecord] = set() for dns_address in self._dns_addresses(override_ttl, IPVersion.All): missing_types.discard(dns_address.type) records.add(dns_address) @@ -715,7 +717,7 @@ def _get_address_and_nsec_records(self, override_ttl: Optional[int]) -> Set[DNSR self._get_address_and_nsec_records_cache = records return records - def _get_address_records_from_cache_by_type(self, zc: "Zeroconf", _type: int_) -> List[DNSAddress]: + def _get_address_records_from_cache_by_type(self, zc: Zeroconf, _type: int_) -> list[DNSAddress]: """Get the addresses from the cache.""" if self.server_key is None: return [] @@ -738,14 +740,14 @@ def set_server_if_missing(self) -> None: self.server = self._name self.server_key = self.key - def load_from_cache(self, zc: "Zeroconf", now: Optional[float_] = None) -> bool: + def load_from_cache(self, zc: Zeroconf, now: float_ | None = None) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. """ return self._load_from_cache(zc, now or current_time_millis()) - def _load_from_cache(self, zc: "Zeroconf", now: float_) -> bool: + def _load_from_cache(self, zc: Zeroconf, now: float_) -> bool: """Populate the service info from the cache. This method is designed to be threadsafe. @@ -775,10 +777,10 @@ def _is_complete(self) -> bool: def request( self, - zc: "Zeroconf", + zc: Zeroconf, timeout: float, - question_type: Optional[DNSQuestionType] = None, - addr: Optional[str] = None, + question_type: DNSQuestionType | None = None, + addr: str | None = None, port: int = _MDNS_PORT, ) -> bool: """Returns true if the service could be discovered on the @@ -814,10 +816,10 @@ def _get_random_delay(self) -> int_: async def async_request( self, - zc: "Zeroconf", + zc: Zeroconf, timeout: float, - question_type: Optional[DNSQuestionType] = None, - addr: Optional[str] = None, + question_type: DNSQuestionType | None = None, + addr: str | None = None, port: int = _MDNS_PORT, ) -> bool: """Returns true if the service could be discovered on the @@ -914,7 +916,7 @@ def _add_question_with_known_answers( out.add_answer_at_time(answer, now) def _generate_request_query( - self, zc: "Zeroconf", now: float_, question_type: DNSQuestionType + self, zc: Zeroconf, now: float_, question_type: DNSQuestionType ) -> DNSOutgoing: """Generate the request query.""" out = DNSOutgoing(_FLAGS_QR_QUERY) diff --git a/src/zeroconf/_services/registry.py b/src/zeroconf/_services/registry.py index 4100c690e..937992eb0 100644 --- a/src/zeroconf/_services/registry.py +++ b/src/zeroconf/_services/registry.py @@ -20,7 +20,7 @@ USA """ -from typing import Dict, List, Optional, Union +from __future__ import annotations from .._exceptions import ServiceNameAlreadyRegistered from .info import ServiceInfo @@ -41,16 +41,16 @@ def __init__( self, ) -> None: """Create the ServiceRegistry class.""" - self._services: Dict[str, ServiceInfo] = {} - self.types: Dict[str, List] = {} - self.servers: Dict[str, List] = {} + self._services: dict[str, ServiceInfo] = {} + self.types: dict[str, list] = {} + self.servers: dict[str, list] = {} self.has_entries: bool = False def async_add(self, info: ServiceInfo) -> None: """Add a new service to the registry.""" self._add(info) - def async_remove(self, info: Union[List[ServiceInfo], ServiceInfo]) -> None: + def async_remove(self, info: list[ServiceInfo] | ServiceInfo) -> None: """Remove a new service from the registry.""" self._remove(info if isinstance(info, list) else [info]) @@ -59,27 +59,27 @@ def async_update(self, info: ServiceInfo) -> None: self._remove([info]) self._add(info) - def async_get_service_infos(self) -> List[ServiceInfo]: + def async_get_service_infos(self) -> list[ServiceInfo]: """Return all ServiceInfo.""" return list(self._services.values()) - def async_get_info_name(self, name: str) -> Optional[ServiceInfo]: + def async_get_info_name(self, name: str) -> ServiceInfo | None: """Return all ServiceInfo for the name.""" return self._services.get(name) - def async_get_types(self) -> List[str]: + def async_get_types(self) -> list[str]: """Return all types.""" return list(self.types) - def async_get_infos_type(self, type_: str) -> List[ServiceInfo]: + def async_get_infos_type(self, type_: str) -> list[ServiceInfo]: """Return all ServiceInfo matching type.""" return self._async_get_by_index(self.types, type_) - def async_get_infos_server(self, server: str) -> List[ServiceInfo]: + def async_get_infos_server(self, server: str) -> list[ServiceInfo]: """Return all ServiceInfo matching server.""" return self._async_get_by_index(self.servers, server) - def _async_get_by_index(self, records: Dict[str, List], key: _str) -> List[ServiceInfo]: + def _async_get_by_index(self, records: dict[str, list], key: _str) -> list[ServiceInfo]: """Return all ServiceInfo matching the index.""" record_list = records.get(key) if record_list is None: @@ -98,7 +98,7 @@ def _add(self, info: ServiceInfo) -> None: self.servers.setdefault(info.server_key, []).append(info.key) self.has_entries = True - def _remove(self, infos: List[ServiceInfo]) -> None: + def _remove(self, infos: list[ServiceInfo]) -> None: """Remove a services under the lock.""" for info in infos: old_service_info = self._services.get(info.key) diff --git a/src/zeroconf/_services/types.py b/src/zeroconf/_services/types.py index 63b6d19a1..af25dc6db 100644 --- a/src/zeroconf/_services/types.py +++ b/src/zeroconf/_services/types.py @@ -20,8 +20,9 @@ USA """ +from __future__ import annotations + import time -from typing import Optional, Set, Tuple, Union from .._core import Zeroconf from .._services import ServiceListener @@ -37,7 +38,7 @@ class ZeroconfServiceTypes(ServiceListener): def __init__(self) -> None: """Keep track of found services in a set.""" - self.found_services: Set[str] = set() + self.found_services: set[str] = set() def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: """Service added.""" @@ -52,11 +53,11 @@ def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: @classmethod def find( cls, - zc: Optional[Zeroconf] = None, - timeout: Union[int, float] = 5, + zc: Zeroconf | None = None, + timeout: int | float = 5, interfaces: InterfacesType = InterfaceChoice.All, - ip_version: Optional[IPVersion] = None, - ) -> Tuple[str, ...]: + ip_version: IPVersion | None = None, + ) -> tuple[str, ...]: """ Return all of the advertised services on any local networks. diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index b08110943..c8d7699b9 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -20,9 +20,10 @@ USA """ +from __future__ import annotations + import asyncio import socket -from typing import Tuple class _WrappedTransport: @@ -42,7 +43,7 @@ def __init__( is_ipv6: bool, sock: socket.socket, fileno: int, - sock_name: Tuple, + sock_name: tuple, ) -> None: """Initialize the wrapped transport. diff --git a/src/zeroconf/_updates.py b/src/zeroconf/_updates.py index 58be33d8c..c0bf9b8c9 100644 --- a/src/zeroconf/_updates.py +++ b/src/zeroconf/_updates.py @@ -20,7 +20,9 @@ USA """ -from typing import TYPE_CHECKING, List +from __future__ import annotations + +from typing import TYPE_CHECKING from ._dns import DNSRecord from ._record_update import RecordUpdate @@ -40,7 +42,7 @@ class RecordUpdateListener: """ def update_record( # pylint: disable=no-self-use - self, zc: "Zeroconf", now: float, record: DNSRecord + self, zc: Zeroconf, now: float, record: DNSRecord ) -> None: """Update a single record. @@ -49,7 +51,7 @@ def update_record( # pylint: disable=no-self-use """ raise RuntimeError("update_record is deprecated and will be removed in a future version.") - def async_update_records(self, zc: "Zeroconf", now: float_, records: List[RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float_, records: list[RecordUpdate]) -> None: """Update multiple records in one shot. All records that are received in a single packet are passed diff --git a/src/zeroconf/_utils/__init__.py b/src/zeroconf/_utils/__init__.py index 30920c6aa..584a74eca 100644 --- a/src/zeroconf/_utils/__init__.py +++ b/src/zeroconf/_utils/__init__.py @@ -19,3 +19,5 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from __future__ import annotations diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index 6d070e306..07b3f4223 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -20,11 +20,13 @@ USA """ +from __future__ import annotations + import asyncio import concurrent.futures import contextlib import sys -from typing import Any, Awaitable, Coroutine, Optional, Set +from typing import Any, Awaitable, Coroutine if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout @@ -47,7 +49,7 @@ def _set_future_none_if_not_done(fut: asyncio.Future) -> None: fut.set_result(None) -def _resolve_all_futures_to_none(futures: Set[asyncio.Future]) -> None: +def _resolve_all_futures_to_none(futures: set[asyncio.Future]) -> None: """Resolve all futures to None.""" for fut in futures: _set_future_none_if_not_done(fut) @@ -55,7 +57,7 @@ def _resolve_all_futures_to_none(futures: Set[asyncio.Future]) -> None: async def wait_for_future_set_or_timeout( - loop: asyncio.AbstractEventLoop, future_set: Set[asyncio.Future], timeout: float + loop: asyncio.AbstractEventLoop, future_set: set[asyncio.Future], timeout: float ) -> None: """Wait for a future or timeout (in milliseconds).""" future = loop.create_future() @@ -75,7 +77,7 @@ async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: await event.wait() -async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.Task]: +async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> set[asyncio.Task]: """Return all tasks running.""" await asyncio.sleep(0) # flush out any call_soon_threadsafe # If there are multiple event loops running, all_tasks is not @@ -87,7 +89,7 @@ async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> Set[asyncio.T return set() -async def _wait_for_loop_tasks(wait_tasks: Set[asyncio.Task]) -> None: +async def _wait_for_loop_tasks(wait_tasks: set[asyncio.Task]) -> None: """Wait for the event loop thread we started to shutdown.""" await asyncio.wait(wait_tasks, timeout=_TASK_AWAIT_TIMEOUT) @@ -130,7 +132,7 @@ def shutdown_loop(loop: asyncio.AbstractEventLoop) -> None: loop.call_soon_threadsafe(loop.stop) -def get_running_loop() -> Optional[asyncio.AbstractEventLoop]: +def get_running_loop() -> asyncio.AbstractEventLoop | None: """Check if an event loop is already running.""" with contextlib.suppress(RuntimeError): return asyncio.get_running_loop() diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index 64cdfb638..d172d0c9f 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -20,9 +20,11 @@ USA """ +from __future__ import annotations + from functools import cache, lru_cache from ipaddress import AddressValueError, IPv4Address, IPv6Address, NetmaskValueError -from typing import Any, Optional, Union +from typing import Any from .._dns import DNSAddress from ..const import _TYPE_AAAA @@ -99,8 +101,8 @@ def is_loopback(self) -> bool: @lru_cache(maxsize=512) def _cached_ip_addresses( - address: Union[str, bytes, int], -) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: + address: str | bytes | int, +) -> ZeroconfIPv4Address | ZeroconfIPv6Address | None: """Cache IP addresses.""" try: return ZeroconfIPv4Address(address) @@ -119,7 +121,7 @@ def _cached_ip_addresses( def get_ip_address_object_from_record( record: DNSAddress, -) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: +) -> ZeroconfIPv4Address | ZeroconfIPv6Address | None: """Get the IP address object from the record.""" if record.type == _TYPE_AAAA and record.scope_id: return ip_bytes_and_scope_to_address(record.address, record.scope_id) @@ -128,7 +130,7 @@ def get_ip_address_object_from_record( def ip_bytes_and_scope_to_address( address: bytes_, scope: int_ -) -> Optional[Union[ZeroconfIPv4Address, ZeroconfIPv6Address]]: +) -> ZeroconfIPv4Address | ZeroconfIPv6Address | None: """Convert the bytes and scope to an IP address object.""" base_address = cached_ip_addresses_wrapper(address) if base_address is not None and base_address.is_link_local: @@ -137,7 +139,7 @@ def ip_bytes_and_scope_to_address( return base_address -def str_without_scope_id(addr: Union[ZeroconfIPv4Address, ZeroconfIPv6Address]) -> str: +def str_without_scope_id(addr: ZeroconfIPv4Address | ZeroconfIPv6Address) -> str: """Return the string representation of the address without the scope id.""" if addr.version == 6: address_str = str(addr) diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index cda01b28e..de35f7afb 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -20,8 +20,9 @@ USA """ +from __future__ import annotations + from functools import lru_cache -from typing import Set from .._exceptions import BadTypeInNameException from ..const import ( @@ -162,7 +163,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis return service_name + trailer -def possible_types(name: str) -> Set[str]: +def possible_types(name: str) -> set[str]: """Build a set of all possible types from a fully qualified name.""" labels = name.split(".") label_count = len(labels) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 7298bec4d..3cc4336bf 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -20,13 +20,15 @@ USA """ +from __future__ import annotations + import enum import errno import ipaddress import socket import struct import sys -from typing import Any, List, Optional, Sequence, Tuple, Union, cast +from typing import Any, Sequence, Tuple, Union, cast import ifaddr @@ -70,11 +72,11 @@ def _encode_address(address: str) -> bytes: return socket.inet_pton(address_family, address) -def get_all_addresses() -> List[str]: +def get_all_addresses() -> list[str]: return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) -def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: +def get_all_addresses_v6() -> list[tuple[tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( @@ -82,7 +84,7 @@ def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: ) -def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: +def ip6_to_address_and_index(adapters: list[Any], ip: str) -> tuple[tuple[str, int, int], int]: if "%" in ip: ip = ip[: ip.index("%")] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) @@ -98,7 +100,7 @@ def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, i raise RuntimeError(f"No adapter found for IP address {ip}") -def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]: +def interface_index_to_ip6_address(adapters: list[Any], index: int) -> tuple[str, int, int]: for adapter in adapters: if adapter.index == index: for adapter_ip in adapter.ips: @@ -110,8 +112,8 @@ def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str def ip6_addresses_to_indexes( - interfaces: Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], -) -> List[Tuple[Tuple[str, int, int], int]]: + interfaces: Sequence[str | int | tuple[tuple[str, int, int], int]], +) -> list[tuple[tuple[str, int, int], int]]: """Convert IPv6 interface addresses to interface indexes. IPv4 addresses are ignored. @@ -133,14 +135,14 @@ def ip6_addresses_to_indexes( def normalize_interface_choice( choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only -) -> List[Union[str, Tuple[Tuple[str, int, int], int]]]: +) -> list[str | tuple[tuple[str, int, int], int]]: """Convert the interfaces choice into internal representation. :param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only). :param ip_address: IP version to use (ignored if `choice` is a list). :returns: List of IP addresses (for IPv4) and indexes (for IPv6). """ - result: List[Union[str, Tuple[Tuple[str, int, int], int]]] = [] + result: list[str | tuple[tuple[str, int, int], int]] = [] if choice is InterfaceChoice.Default: if ip_version != IPVersion.V4Only: # IPv6 multicast uses interface 0 to mean the default @@ -196,7 +198,7 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: def set_mdns_port_socket_options_for_ip_version( s: socket.socket, - bind_addr: Union[Tuple[str], Tuple[str, int, int]], + bind_addr: tuple[str] | tuple[str, int, int], ip_version: IPVersion, ) -> None: """Set ttl/hops and loop for mdns port.""" @@ -219,11 +221,11 @@ def set_mdns_port_socket_options_for_ip_version( def new_socket( - bind_addr: Union[Tuple[str], Tuple[str, int, int]], + bind_addr: tuple[str] | tuple[str, int, int], port: int = _MDNS_PORT, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, -) -> Optional[socket.socket]: +) -> socket.socket | None: log.debug( "Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r", port, @@ -265,7 +267,7 @@ def new_socket( def add_multicast_member( listen_socket: socket.socket, - interface: Union[str, Tuple[Tuple[str, int, int], int]], + interface: str | tuple[tuple[str, int, int], int], ) -> bool: # This is based on assumptions in normalize_interface_choice is_v6 = isinstance(interface, tuple) @@ -331,9 +333,9 @@ def add_multicast_member( def new_respond_socket( - interface: Union[str, Tuple[Tuple[str, int, int], int]], + interface: str | tuple[tuple[str, int, int], int], apple_p2p: bool = False, -) -> Optional[socket.socket]: +) -> socket.socket | None: is_v6 = isinstance(interface, tuple) respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), @@ -360,7 +362,7 @@ def create_sockets( unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, -) -> Tuple[Optional[socket.socket], List[socket.socket]]: +) -> tuple[socket.socket | None, list[socket.socket]]: if unicast: listen_socket = None else: diff --git a/src/zeroconf/_utils/time.py b/src/zeroconf/_utils/time.py index 055e0658a..4057f0630 100644 --- a/src/zeroconf/_utils/time.py +++ b/src/zeroconf/_utils/time.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + import time _float = float diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 926ef5099..2a29a4bb7 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -20,10 +20,12 @@ USA """ +from __future__ import annotations + import asyncio import contextlib from types import TracebackType # used in type hints -from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Awaitable, Callable from ._core import Zeroconf from ._dns import DNSQuestionType @@ -63,14 +65,14 @@ class AsyncServiceBrowser(_ServiceBrowserBase): def __init__( self, - zeroconf: "Zeroconf", - type_: Union[str, list], - handlers: Optional[Union[ServiceListener, List[Callable[..., None]]]] = None, - listener: Optional[ServiceListener] = None, - addr: Optional[str] = None, + zeroconf: Zeroconf, + type_: str | list, + handlers: ServiceListener | list[Callable[..., None]] | None = None, + listener: ServiceListener | None = None, + addr: str | None = None, port: int = _MDNS_PORT, delay: int = _BROWSER_TIME, - question_type: Optional[DNSQuestionType] = None, + question_type: DNSQuestionType | None = None, ) -> None: super().__init__(zeroconf, type_, handlers, listener, addr, port, delay, question_type) self._async_start() @@ -79,15 +81,15 @@ async def async_cancel(self) -> None: """Cancel the browser.""" self._async_cancel() - async def __aenter__(self) -> "AsyncServiceBrowser": + async def __aenter__(self) -> AsyncServiceBrowser: return self async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: await self.async_cancel() return None @@ -98,11 +100,11 @@ class AsyncZeroconfServiceTypes(ZeroconfServiceTypes): @classmethod async def async_find( cls, - aiozc: Optional["AsyncZeroconf"] = None, - timeout: Union[int, float] = 5, + aiozc: AsyncZeroconf | None = None, + timeout: int | float = 5, interfaces: InterfacesType = InterfaceChoice.All, - ip_version: Optional[IPVersion] = None, - ) -> Tuple[str, ...]: + ip_version: IPVersion | None = None, + ) -> tuple[str, ...]: """ Return all of the advertised services on any local networks. @@ -145,9 +147,9 @@ def __init__( self, interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, - ip_version: Optional[IPVersion] = None, + ip_version: IPVersion | None = None, apple_p2p: bool = False, - zc: Optional[Zeroconf] = None, + zc: Zeroconf | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, and listening. @@ -170,12 +172,12 @@ def __init__( ip_version=ip_version, apple_p2p=apple_p2p, ) - self.async_browsers: Dict[ServiceListener, AsyncServiceBrowser] = {} + self.async_browsers: dict[ServiceListener, AsyncServiceBrowser] = {} async def async_register_service( self, info: ServiceInfo, - ttl: Optional[int] = None, + ttl: int | None = None, allow_name_change: bool = False, cooperating_responders: bool = False, strict: bool = True, @@ -236,8 +238,8 @@ async def async_get_service_info( type_: str, name: str, timeout: int = 3000, - question_type: Optional[DNSQuestionType] = None, - ) -> Optional[AsyncServiceInfo]: + question_type: DNSQuestionType | None = None, + ) -> AsyncServiceInfo | None: """Returns network's service information for a particular name and type, or None if no service matches by the timeout, which defaults to 3 seconds. @@ -268,14 +270,14 @@ async def async_remove_all_service_listeners(self) -> None: *(self.async_remove_service_listener(listener) for listener in list(self.async_browsers)) ) - async def __aenter__(self) -> "AsyncZeroconf": + async def __aenter__(self) -> AsyncZeroconf: return self async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: await self.async_close() return None diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index d84cb73ba..3b4b3abcc 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -20,6 +20,8 @@ USA """ +from __future__ import annotations + import re import socket diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py index e69de29bb..9d48db4f9 100644 --- a/tests/benchmarks/__init__.py +++ b/tests/benchmarks/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/benchmarks/helpers.py b/tests/benchmarks/helpers.py index e701e0b64..4f5f7d66c 100644 --- a/tests/benchmarks/helpers.py +++ b/tests/benchmarks/helpers.py @@ -1,5 +1,7 @@ """Benchmark helpers.""" +from __future__ import annotations + import socket from zeroconf import DNSAddress, DNSOutgoing, DNSService, DNSText, const diff --git a/tests/benchmarks/test_cache.py b/tests/benchmarks/test_cache.py index 6fde9438f..7813f6798 100644 --- a/tests/benchmarks/test_cache.py +++ b/tests/benchmarks/test_cache.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pytest_codspeed import BenchmarkFixture from zeroconf import DNSCache, DNSPointer, current_time_millis diff --git a/tests/benchmarks/test_incoming.py b/tests/benchmarks/test_incoming.py index e0552f3a1..6d31e51e5 100644 --- a/tests/benchmarks/test_incoming.py +++ b/tests/benchmarks/test_incoming.py @@ -1,5 +1,7 @@ """Benchmark for DNSIncoming.""" +from __future__ import annotations + import socket from pytest_codspeed import BenchmarkFixture diff --git a/tests/benchmarks/test_outgoing.py b/tests/benchmarks/test_outgoing.py index 69de540ea..a8db4d6f8 100644 --- a/tests/benchmarks/test_outgoing.py +++ b/tests/benchmarks/test_outgoing.py @@ -1,5 +1,7 @@ """Benchmark for DNSOutgoing.""" +from __future__ import annotations + from pytest_codspeed import BenchmarkFixture from zeroconf._protocol.outgoing import State diff --git a/tests/benchmarks/test_send.py b/tests/benchmarks/test_send.py index 7a6d664b7..596662a2b 100644 --- a/tests/benchmarks/test_send.py +++ b/tests/benchmarks/test_send.py @@ -1,5 +1,7 @@ """Benchmark for sending packets.""" +from __future__ import annotations + import pytest from pytest_codspeed import BenchmarkFixture diff --git a/tests/benchmarks/test_txt_properties.py b/tests/benchmarks/test_txt_properties.py index ad75ab359..72afa0b65 100644 --- a/tests/benchmarks/test_txt_properties.py +++ b/tests/benchmarks/test_txt_properties.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pytest_codspeed import BenchmarkFixture from zeroconf import ServiceInfo diff --git a/tests/conftest.py b/tests/conftest.py index ba49cef6c..1f323785c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """conftest for zeroconf tests.""" +from __future__ import annotations + import threading from unittest.mock import patch diff --git a/tests/services/__init__.py b/tests/services/__init__.py index 30920c6aa..584a74eca 100644 --- a/tests/services/__init__.py +++ b/tests/services/__init__.py @@ -19,3 +19,5 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from __future__ import annotations diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 5268c3414..986df64eb 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._services.browser.""" +from __future__ import annotations + import asyncio import logging import os @@ -863,7 +865,7 @@ def test_legacy_record_update_listener(): class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def update_record(self, zc: "Zeroconf", now: float, record: r.DNSRecord) -> None: + def update_record(self, zc: Zeroconf, now: float, record: r.DNSRecord) -> None: nonlocal updates updates.append(record) diff --git a/tests/services/test_registry.py b/tests/services/test_registry.py index 999e422c0..c3ae3a28b 100644 --- a/tests/services/test_registry.py +++ b/tests/services/test_registry.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._services.registry.""" +from __future__ import annotations + import socket import unittest diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 811b22c53..632922465 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._services.types.""" +from __future__ import annotations + import logging import os import socket diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 86e9e8c7b..40ecf8162 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,5 +1,7 @@ """Unit tests for aio.py.""" +from __future__ import annotations + import asyncio import logging import os diff --git a/tests/test_cache.py b/tests/test_cache.py index f5304cef2..9d55435d5 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._cache.""" +from __future__ import annotations + import logging import unittest.mock from heapq import heapify, heappop diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index 8bd443a42..74ed1f124 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -1,5 +1,7 @@ """Test to check for circular imports.""" +from __future__ import annotations + import asyncio import sys diff --git a/tests/test_dns.py b/tests/test_dns.py index 491e2ca7f..246c8dcfb 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._dns.""" +from __future__ import annotations + import logging import os import socket diff --git a/tests/test_engine.py b/tests/test_engine.py index 23a039497..b7a94c866 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._engine""" +from __future__ import annotations + import asyncio import itertools import logging diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index cf004d2c0..ab181db1f 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._exceptions""" +from __future__ import annotations + import logging import unittest.mock diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 80ee7f407..fd0e689c4 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._handlers""" +from __future__ import annotations + import asyncio import logging import os @@ -1371,7 +1373,7 @@ async def test_record_update_manager_add_listener_callsback_existing_records(): class MyListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -1973,7 +1975,7 @@ async def test_add_listener_warns_when_not_using_record_update_listener(caplog): class MyListener: """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -2005,7 +2007,7 @@ async def test_async_updates_iteration_safe(): class OtherListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) @@ -2014,7 +2016,7 @@ def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.Recor class ListenerThatAddsListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def async_update_records(self, zc: "Zeroconf", now: float, records: list[r.RecordUpdate]) -> None: + def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordUpdate]) -> None: """Update multiple records in one shot.""" updated.extend(records) zc.async_add_listener(other, None) diff --git a/tests/test_history.py b/tests/test_history.py index 606362d1d..4c9836ce6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,5 +1,7 @@ """Unit tests for _history.py.""" +from __future__ import annotations + import zeroconf as r import zeroconf.const as const from zeroconf._history import QuestionHistory diff --git a/tests/test_init.py b/tests/test_init.py index 78fb1e370..a36ff8fd2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf.py""" +from __future__ import annotations + import logging import socket import time diff --git a/tests/test_logger.py b/tests/test_logger.py index ecaf9dd01..aa5b5382b 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,5 +1,7 @@ """Unit tests for logger.py.""" +from __future__ import annotations + import logging from unittest.mock import call, patch diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 1397c60cd..08d7e600a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._protocol""" +from __future__ import annotations + import copy import logging import os diff --git a/tests/test_services.py b/tests/test_services.py index 992070e23..e93174cc7 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._services.""" +from __future__ import annotations + import logging import os import socket diff --git a/tests/test_updates.py b/tests/test_updates.py index 1af85736b..a057486cc 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._updates.""" +from __future__ import annotations + import logging import socket import time @@ -45,7 +47,7 @@ def test_legacy_record_update_listener(): class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" - def update_record(self, zc: "Zeroconf", now: float, record: r.DNSRecord) -> None: + def update_record(self, zc: Zeroconf, now: float, record: r.DNSRecord) -> None: nonlocal updates updates.append(record) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 30920c6aa..584a74eca 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -19,3 +19,5 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA """ + +from __future__ import annotations diff --git a/tests/utils/test_ipaddress.py b/tests/utils/test_ipaddress.py index c6f63aafc..4379f458b 100644 --- a/tests/utils/test_ipaddress.py +++ b/tests/utils/test_ipaddress.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._utils.ipaddress.""" +from __future__ import annotations + from zeroconf import const from zeroconf._dns import DNSAddress from zeroconf._utils import ipaddress diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 6f2c6b138..1feb77131 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._utils.name.""" +from __future__ import annotations + import socket import pytest diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 17212af23..489a6460c 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -1,5 +1,7 @@ """Unit tests for zeroconf._utils.net.""" +from __future__ import annotations + import errno import socket import unittest From bcf4a440a3865a5a9e1021a7f9772fc618694e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 13:58:46 -0600 Subject: [PATCH 1191/1433] chore: fix missed future annotations (#1503) --- src/zeroconf/_listener.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 1980a8201..925c689e0 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -20,11 +20,13 @@ USA """ +from __future__ import annotations + import asyncio import logging import random from functools import partial -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Tuple, cast from ._logger import QuietLogger, log from ._protocol.incoming import DNSIncoming @@ -68,23 +70,21 @@ class AsyncListener: "zc", ) - def __init__(self, zc: "Zeroconf") -> None: + def __init__(self, zc: Zeroconf) -> None: self.zc = zc self._registry = zc.registry self._record_manager = zc.record_manager self._query_handler = zc.query_handler - self.data: Optional[bytes] = None + self.data: bytes | None = None self.last_time: float = 0 - self.last_message: Optional[DNSIncoming] = None - self.transport: Optional[_WrappedTransport] = None - self.sock_description: Optional[str] = None - self._deferred: Dict[str, List[DNSIncoming]] = {} - self._timers: Dict[str, asyncio.TimerHandle] = {} + self.last_message: DNSIncoming | None = None + self.transport: _WrappedTransport | None = None + self.sock_description: str | None = None + self._deferred: dict[str, list[DNSIncoming]] = {} + self._timers: dict[str, asyncio.TimerHandle] = {} super().__init__() - def datagram_received( - self, data: _bytes, addrs: Union[Tuple[str, int], Tuple[str, int, int, int]] - ) -> None: + def datagram_received(self, data: _bytes, addrs: tuple[str, int] | tuple[str, int, int, int]) -> None: data_len = len(data) debug = DEBUG_ENABLED() @@ -108,7 +108,7 @@ def _process_datagram_at_time( data_len: _int, now: _float, data: _bytes, - addrs: Union[Tuple[str, int], Tuple[str, int, int, int]], + addrs: tuple[str, int] | tuple[str, int, int, int], ) -> None: if ( self.data == data @@ -129,7 +129,7 @@ def _process_datagram_at_time( return if len(addrs) == 2: - v6_flow_scope: Union[Tuple[()], Tuple[int, int]] = () + v6_flow_scope: tuple[()] | tuple[int, int] = () # https://github.com/python/mypy/issues/1178 addr, port = addrs # type: ignore addr_port = addrs @@ -189,7 +189,7 @@ def handle_query_or_defer( addr: _str, port: _int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]], + v6_flow_scope: tuple[()] | tuple[int, int], ) -> None: """Deal with incoming query packets. Provides a response if possible.""" @@ -224,11 +224,11 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None: def _respond_query( self, - msg: Optional[DNSIncoming], + msg: DNSIncoming | None, addr: _str, port: _int, transport: _WrappedTransport, - v6_flow_scope: Union[Tuple[()], Tuple[int, int]], + v6_flow_scope: tuple[()] | tuple[int, int], ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" self._cancel_any_timers_for_addr(addr) @@ -252,5 +252,5 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = wrapped_transport self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" - def connection_lost(self, exc: Optional[Exception]) -> None: + def connection_lost(self, exc: Exception | None) -> None: """Handle connection lost.""" From 44457be4571add2f851192db3b37a96d9d27b00e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 14:06:05 -0600 Subject: [PATCH 1192/1433] feat: eliminate async_timeout dep on python less than 3.11 (#1500) --- poetry.lock | 15 +-------------- pyproject.toml | 1 - src/zeroconf/_core.py | 20 ++++++++++++++------ src/zeroconf/_engine.py | 15 ++++++++------- src/zeroconf/_utils/asyncio.py | 21 +++++++++++---------- src/zeroconf/asyncio.py | 5 +++-- tests/utils/test_asyncio.py | 13 +++++++------ 7 files changed, 44 insertions(+), 46 deletions(-) diff --git a/poetry.lock b/poetry.lock index 14c79f618..962899b2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,19 +12,6 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version < \"3.11\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - [[package]] name = "babel" version = "2.16.0" @@ -1140,4 +1127,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "eb91a0dd1c260f37d2579b4793f537f8017f9e1801e2a372849439f5c9132245" +content-hash = "ea903296f015035c594eb8cce08d4dedc716074e33644033938dfdb5f047d72e" diff --git a/pyproject.toml b/pyproject.toml index f5084253e..7514d9a5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,6 @@ prerelease = true [tool.poetry.dependencies] python = "^3.9" -async-timeout = {version = ">=3.0.0", python = "<3.11"} ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 01e98e8f9..3f007c174 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -55,8 +55,8 @@ get_running_loop, run_coro_with_timeout, shutdown_loop, - wait_event_or_timeout, wait_for_future_set_or_timeout, + wait_future_or_timeout, ) from ._utils.name import service_type_name from ._utils.net import ( @@ -203,7 +203,15 @@ def __init__( @property def started(self) -> bool: """Check if the instance has started.""" - return bool(not self.done and self.engine.running_event and self.engine.running_event.is_set()) + running_future = self.engine.running_future + return bool( + not self.done + and running_future + and running_future.done() + and not running_future.cancelled() + and not running_future.exception() + and running_future.result() + ) def start(self) -> None: """Start Zeroconf.""" @@ -227,7 +235,7 @@ def _run_loop() -> None: self._loop_thread.start() loop_thread_ready.wait() - async def async_wait_for_start(self) -> None: + async def async_wait_for_start(self, timeout: float = _STARTUP_TIMEOUT) -> None: """Wait for start up for actions that require a running Zeroconf instance. Throws NotRunningException if the instance is not running or could @@ -235,9 +243,9 @@ async def async_wait_for_start(self) -> None: """ if self.done: # If the instance was shutdown from under us, raise immediately raise NotRunningException - assert self.engine.running_event is not None - await wait_event_or_timeout(self.engine.running_event, timeout=_STARTUP_TIMEOUT) - if not self.engine.running_event.is_set() or self.done: + assert self.engine.running_future is not None + await wait_future_or_timeout(self.engine.running_future, timeout=timeout) + if not self.started: raise NotRunningException @property diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 7b22f788e..8c800a33a 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -53,7 +53,7 @@ class AsyncEngine: "loop", "protocols", "readers", - "running_event", + "running_future", "senders", "zc", ) @@ -69,7 +69,7 @@ def __init__( self.protocols: list[AsyncListener] = [] self.readers: list[_WrappedTransport] = [] self.senders: list[_WrappedTransport] = [] - self.running_event: asyncio.Event | None = None + self.running_future: asyncio.Future[bool | None] | None = None self._listen_socket = listen_socket self._respond_sockets = respond_sockets self._cleanup_timer: asyncio.TimerHandle | None = None @@ -81,15 +81,15 @@ def setup( ) -> None: """Set up the instance.""" self.loop = loop - self.running_event = asyncio.Event() + self.running_future = loop.create_future() self.loop.create_task(self._async_setup(loop_thread_ready)) async def _async_setup(self, loop_thread_ready: threading.Event | None) -> None: """Set up the instance.""" self._async_schedule_next_cache_cleanup() await self._async_create_endpoints() - assert self.running_event is not None - self.running_event.set() + assert self.running_future is not None + self.running_future.set_result(True) if loop_thread_ready: loop_thread_ready.set() @@ -142,8 +142,9 @@ async def _async_close(self) -> None: def _async_shutdown(self) -> None: """Shutdown transports and sockets.""" - assert self.running_event is not None - self.running_event.clear() + assert self.running_future is not None + assert self.loop is not None + self.running_future = self.loop.create_future() for wrapped_transport in itertools.chain(self.senders, self.readers): wrapped_transport.transport.close() diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index 07b3f4223..c92d99d56 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -28,11 +28,6 @@ import sys from typing import Any, Awaitable, Coroutine -if sys.version_info[:2] < (3, 11): - from async_timeout import timeout as asyncio_timeout -else: - from asyncio import timeout as asyncio_timeout # type: ignore[attr-defined] - from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT from .time import millis_to_seconds @@ -70,11 +65,17 @@ async def wait_for_future_set_or_timeout( future_set.discard(future) -async def wait_event_or_timeout(event: asyncio.Event, timeout: float) -> None: - """Wait for an event or timeout.""" - with contextlib.suppress(asyncio.TimeoutError): - async with asyncio_timeout(timeout): - await event.wait() +async def wait_future_or_timeout(future: asyncio.Future[bool | None], timeout: float) -> None: + """Wait for a future or timeout.""" + loop = asyncio.get_running_loop() + handle = loop.call_later(timeout, _set_future_none_if_not_done, future) + try: + await future + except asyncio.CancelledError: + if sys.version_info >= (3, 11) and (task := asyncio.current_task()) and task.cancelling(): + raise + finally: + handle.cancel() async def _async_get_all_tasks(loop: asyncio.AbstractEventLoop) -> set[asyncio.Task]: diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 2a29a4bb7..ce5a43eb9 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -29,6 +29,7 @@ from ._core import Zeroconf from ._dns import DNSQuestionType +from ._exceptions import NotRunningException from ._services import ServiceListener from ._services.browser import _ServiceBrowserBase from ._services.info import AsyncServiceInfo, ServiceInfo @@ -227,8 +228,8 @@ async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" if not self.zeroconf.done: - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(self.zeroconf.async_wait_for_start(), timeout=1) + with contextlib.suppress(NotRunningException): + await self.zeroconf.async_wait_for_start(timeout=1.0) await self.async_remove_all_service_listeners() await self.async_unregister_all_services() await self.zeroconf._async_close() # pylint: disable=protected-access diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 09137a719..7989a82cf 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -45,16 +45,17 @@ def test_get_running_loop_no_loop() -> None: @pytest.mark.asyncio -async def test_wait_event_or_timeout_times_out() -> None: - """Test wait_event_or_timeout will timeout.""" - test_event = asyncio.Event() - await aioutils.wait_event_or_timeout(test_event, 0.1) +async def test_wait_future_or_timeout_times_out() -> None: + """Test wait_future_or_timeout will timeout.""" + loop = asyncio.get_running_loop() + test_future = loop.create_future() + await aioutils.wait_future_or_timeout(test_future, 0.1) - task = asyncio.ensure_future(test_event.wait()) + task = asyncio.ensure_future(test_future) await asyncio.sleep(0.1) async def _async_wait_or_timeout(): - await aioutils.wait_event_or_timeout(test_event, 0.1) + await aioutils.wait_future_or_timeout(test_future, 0.1) # Test high lock contention await asyncio.gather(*[_async_wait_or_timeout() for _ in range(100)]) From 71106950f3da3f5228f91ebe58085e866960140c Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 31 Jan 2025 20:15:51 +0000 Subject: [PATCH 1193/1433] 0.143.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174b0d7e8..3a3f69935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.143.0 (2025-01-31) + +### Features + +- Eliminate async_timeout dep on python less than 3.11 + ([#1500](https://github.com/python-zeroconf/python-zeroconf/pull/1500), + [`44457be`](https://github.com/python-zeroconf/python-zeroconf/commit/44457be4571add2f851192db3b37a96d9d27b00e)) + + ## v0.142.0 (2025-01-30) ### Features diff --git a/pyproject.toml b/pyproject.toml index 7514d9a5a..72728b5ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.142.0" +version = "0.143.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 26f60cde2..b85aee743 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.142.0" +__version__ = "0.143.0" __license__ = "LGPL" From dd46325832324f16c07bd297e3dfeaa16f7e8fc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Feb 2025 16:06:01 -0600 Subject: [PATCH 1194/1433] chore(ci): bump the github-actions group with 2 updates (#1504) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e43c63b84..76043c6a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,14 +134,14 @@ jobs: # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@v9.16.1 + uses: python-semantic-release/python-semantic-release@v9.17.0 if: github.ref_name != 'master' with: root_options: --noop # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@v9.16.1 + uses: python-semantic-release/python-semantic-release@v9.17.0 id: release if: github.ref_name == 'master' with: @@ -237,7 +237,7 @@ jobs: path: dist merge-multiple: true - - uses: pypa/gh-action-pypi-publish@v1.12.3 + - uses: pypa/gh-action-pypi-publish@v1.12.4 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From e5226161f0b183d3786ecd917efaea8b36a94673 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:14:51 -0600 Subject: [PATCH 1195/1433] chore(pre-commit.ci): pre-commit autoupdate (#1507) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 246493ed7..10dee2b0f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,13 +39,13 @@ repos: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.3 + rev: v0.9.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.4.0 + rev: v2.4.1 hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 From e53f05d5ceab52f60c61b76cf6c24adc0fa78fa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:15:02 -0600 Subject: [PATCH 1196/1433] chore(deps-dev): bump pytest-asyncio from 0.25.2 to 0.25.3 (#1506) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 61 +++++------------------------------------------------ 1 file changed, 5 insertions(+), 56 deletions(-) diff --git a/poetry.lock b/poetry.lock index 962899b2b..09c1dd7e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "alabaster" @@ -6,7 +6,6 @@ version = "0.7.16" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, @@ -18,7 +17,6 @@ version = "2.16.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" -groups = ["docs"] files = [ {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, @@ -33,7 +31,6 @@ version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["docs"] files = [ {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, @@ -45,7 +42,6 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -125,7 +121,6 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["docs"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -227,8 +222,6 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "docs"] -markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -240,7 +233,6 @@ version = "7.6.10" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, @@ -318,7 +310,6 @@ version = "3.0.11" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["dev"] files = [ {file = "Cython-3.0.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:44292aae17524abb4b70a25111fe7dec1a0ad718711d47e3786a211d5408fdaa"}, {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75d45fbc20651c1b72e4111149fed3b33d270b0a4fb78328c54d965f28d55e1"}, @@ -394,7 +385,6 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -406,8 +396,6 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -422,7 +410,6 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -437,7 +424,6 @@ version = "0.2.0" description = "Cross-platform network interface and IP address enumeration library" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, @@ -449,7 +435,6 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["docs"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -461,8 +446,6 @@ version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -486,7 +469,6 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -498,7 +480,6 @@ version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["docs"] files = [ {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, @@ -516,7 +497,6 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -541,7 +521,6 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -612,7 +591,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -624,7 +602,6 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -636,7 +613,6 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -652,7 +628,6 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -664,7 +639,6 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -679,7 +653,6 @@ version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -698,14 +671,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.25.2" +version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ - {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, - {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, ] [package.dependencies] @@ -721,7 +693,6 @@ version = "3.1.2" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aed496f873670ce0ea8f980a7c1a2c6a08f415e0ebdf207bf651b2d922103374"}, {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee45b0b763f6b5fa5d74c7b91d694a9615561c428b320383660672f4471756e3"}, @@ -754,7 +725,6 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -773,7 +743,6 @@ version = "2.3.1" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, @@ -788,7 +757,6 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["docs"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -810,7 +778,6 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -830,7 +797,6 @@ version = "75.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, @@ -851,7 +817,6 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" -groups = ["docs"] files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -863,7 +828,6 @@ version = "7.4.7" description = "Python documentation generator" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, @@ -900,7 +864,6 @@ version = "3.0.2" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.8" -groups = ["docs"] files = [ {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, @@ -920,7 +883,6 @@ version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -937,7 +899,6 @@ version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -954,7 +915,6 @@ version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -971,7 +931,6 @@ version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" optional = false python-versions = ">=2.7" -groups = ["docs"] files = [ {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, @@ -986,7 +945,6 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" -groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -1001,7 +959,6 @@ version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -1018,7 +975,6 @@ version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -1035,7 +991,6 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1070,7 +1025,6 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] -markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version < \"3.11\""} [[package]] name = "typing-extensions" @@ -1078,8 +1032,6 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version < \"3.11\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1091,7 +1043,6 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, @@ -1109,8 +1060,6 @@ version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, @@ -1125,6 +1074,6 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", type = ["pytest-mypy"] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.9" content-hash = "ea903296f015035c594eb8cce08d4dedc716074e33644033938dfdb5f047d72e" From e9479237cb48e9d25ff56ce2906713b14d16d364 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:15:09 -0600 Subject: [PATCH 1197/1433] chore(deps-dev): bump pytest-codspeed from 3.1.2 to 3.2.0 (#1505) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index 09c1dd7e6..258b28f2a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -689,23 +689,23 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "3.1.2" +version = "3.2.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" files = [ - {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aed496f873670ce0ea8f980a7c1a2c6a08f415e0ebdf207bf651b2d922103374"}, - {file = "pytest_codspeed-3.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee45b0b763f6b5fa5d74c7b91d694a9615561c428b320383660672f4471756e3"}, - {file = "pytest_codspeed-3.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c84e591a7a0f67d45e2dc9fd05b276971a3aabcab7478fe43363ebefec1358f4"}, - {file = "pytest_codspeed-3.1.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6ae6d094247156407770e6b517af70b98862dd59a3c31034aede11d5f71c32c"}, - {file = "pytest_codspeed-3.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0f264991de5b5cdc118b96fc671386cca3f0f34e411482939bf2459dc599097"}, - {file = "pytest_codspeed-3.1.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0695a4bcd5ff04e8379124dba5d9795ea5e0cadf38be7a0406432fc1467b555"}, - {file = "pytest_codspeed-3.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc356c8dcaaa883af83310f397ac06c96fac9b8a1146e303d4b374b2cb46a18"}, - {file = "pytest_codspeed-3.1.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc8a5d0366322a75cf562f7d8d672d28c1cf6948695c4dddca50331e08f6b3d5"}, - {file = "pytest_codspeed-3.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c5fe7a19b72f54f217480b3b527102579547b1de9fe3acd9e66cb4629ff46c8"}, - {file = "pytest_codspeed-3.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67205755a665593f6521a98317d02a9d07d6fdc593f6634de2c94dea47a3055"}, - {file = "pytest_codspeed-3.1.2-py3-none-any.whl", hash = "sha256:5e7ed0315e33496c5c07dba262b50303b8d0bc4c3d10bf1d422a41e70783f1cb"}, - {file = "pytest_codspeed-3.1.2.tar.gz", hash = "sha256:09c1733af3aab35e94a621aa510f2d2114f65591e6f644c42ca3f67547edad4b"}, + {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34"}, + {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c"}, + {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035"}, + {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58"}, + {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b"}, + {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2"}, + {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f"}, + {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b"}, + {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c"}, + {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da"}, + {file = "pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39"}, + {file = "pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155"}, ] [package.dependencies] From ffc902098c97ff36d0da3e7bd83ac342272e3e71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Feb 2025 14:37:16 -0600 Subject: [PATCH 1198/1433] chore: migrate to Python 3.13 for benchmarks (#1508) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76043c6a2..937166fe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,10 +95,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup Python 3.12 + - name: Setup Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.13 - uses: snok/install-poetry@v1.4.1 - name: Install Dependencies run: | From 3dda6d57e005e97a4b6d7b72e6249ff8c7b9aab0 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Wed, 5 Feb 2025 00:57:08 +0100 Subject: [PATCH 1199/1433] chore: limit `prettier` to specific files (#1509) --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10dee2b0f..76171d45c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,6 +33,7 @@ repos: hooks: - id: prettier args: ["--tab-width", "2"] + files: ".(css|html|js|json|md|toml|yaml)$" - repo: https://github.com/asottile/pyupgrade rev: v3.19.1 hooks: From 468e00cc6a2b8cf6104d18a857905b9faf3a38b6 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Sun, 9 Feb 2025 17:58:23 +0100 Subject: [PATCH 1200/1433] chore: update to modern typing (#1511) --- .pre-commit-config.yaml | 8 +++--- pyproject.toml | 2 +- src/zeroconf/_cache.py | 5 ++-- src/zeroconf/_core.py | 2 +- src/zeroconf/_handlers/answers.py | 3 +-- src/zeroconf/_listener.py | 4 +-- src/zeroconf/_protocol/outgoing.py | 3 ++- src/zeroconf/_services/browser.py | 13 ++++----- src/zeroconf/_services/info.py | 10 +++---- src/zeroconf/_utils/asyncio.py | 3 ++- src/zeroconf/_utils/net.py | 11 ++++---- src/zeroconf/asyncio.py | 3 ++- tests/conftest.py | 8 +++--- tests/test_init.py | 7 ++--- tests/test_listener.py | 5 ++-- tests/test_logger.py | 42 +++++++++++++++++------------- tests/utils/test_net.py | 14 ++++++---- 17 files changed, 79 insertions(+), 64 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 76171d45c..8ea453bc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.1.1 + rev: v4.2.1 hooks: - id: commitizen stages: [commit-msg] @@ -38,9 +38,9 @@ repos: rev: v3.19.1 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 + rev: v1.15.0 hooks: - id: mypy additional_dependencies: [] diff --git a/pyproject.toml b/pyproject.toml index 72728b5ba..c9a28a93c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ sphinx = "^7.4.7 || ^8.1.3" sphinx-rtd-theme = "^3.0.2" [tool.ruff] -target-version = "py38" +target-version = "py39" line-length = 110 [tool.ruff.lint] diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 5ac43f307..c8e2686ee 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -22,8 +22,9 @@ from __future__ import annotations +from collections.abc import Iterable from heapq import heapify, heappop, heappush -from typing import Dict, Iterable, Union, cast +from typing import Union, cast from ._dns import ( DNSAddress, @@ -40,7 +41,7 @@ _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) _UniqueRecordsType = Union[DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService] -_DNSRecordCacheType = Dict[str, Dict[DNSRecord, DNSRecord]] +_DNSRecordCacheType = dict[str, dict[DNSRecord, DNSRecord]] _DNSRecord = DNSRecord _str = str _float = float diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 3f007c174..5e3a7f465 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -26,8 +26,8 @@ import logging import sys import threading +from collections.abc import Awaitable from types import TracebackType -from typing import Awaitable from ._cache import DNSCache from ._dns import DNSQuestion, DNSQuestionType diff --git a/src/zeroconf/_handlers/answers.py b/src/zeroconf/_handlers/answers.py index ec53eb842..07b0a65ab 100644 --- a/src/zeroconf/_handlers/answers.py +++ b/src/zeroconf/_handlers/answers.py @@ -23,13 +23,12 @@ from __future__ import annotations from operator import attrgetter -from typing import Dict, Set from .._dns import DNSQuestion, DNSRecord from .._protocol.outgoing import DNSOutgoing from ..const import _FLAGS_AA, _FLAGS_QR_RESPONSE -_AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] +_AnswerWithAdditionalsType = dict[DNSRecord, set[DNSRecord]] int_ = int diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 925c689e0..406273e90 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -26,7 +26,7 @@ import logging import random from functools import partial -from typing import TYPE_CHECKING, Tuple, cast +from typing import TYPE_CHECKING, cast from ._logger import QuietLogger, log from ._protocol.incoming import DNSIncoming @@ -134,7 +134,7 @@ def _process_datagram_at_time( addr, port = addrs # type: ignore addr_port = addrs if TYPE_CHECKING: - addr_port = cast(Tuple[str, int], addr_port) + addr_port = cast(tuple[str, int], addr_port) scope = None else: # https://github.com/python/mypy/issues/1178 diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index f5d098211..6837e39ab 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -24,8 +24,9 @@ import enum import logging +from collections.abc import Sequence from struct import Struct -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING from .._dns import DNSPointer, DNSQuestion, DNSRecord from .._exceptions import NamePartTooLongException diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index c2ab115b0..ab8c050d9 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -29,16 +29,13 @@ import threading import time import warnings +from collections.abc import Iterable from functools import partial from types import TracebackType # used in type hints from typing import ( TYPE_CHECKING, Any, Callable, - Dict, - Iterable, - List, - Set, cast, ) @@ -96,7 +93,7 @@ bool_ = bool str_ = str -_QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] +_QuestionWithKnownAnswers = dict[DNSQuestion, set[DNSPointer]] heappop = heapq.heappop heappush = heapq.heappush @@ -282,7 +279,7 @@ def generate_service_query( log.debug("Asking %s was suppressed by the question history", question) continue if TYPE_CHECKING: - pointer_known_answers = cast(Set[DNSPointer], known_answers) + pointer_known_answers = cast(set[DNSPointer], known_answers) else: pointer_known_answers = known_answers questions_with_known_answers[question] = pointer_known_answers @@ -618,10 +615,10 @@ def __init__( self._query_sender_task: asyncio.Task | None = None if hasattr(handlers, "add_service"): - listener = cast("ServiceListener", handlers) + listener = cast(ServiceListener, handlers) handlers = None - handlers = cast(List[Callable[..., None]], handlers or []) + handlers = cast(list[Callable[..., None]], handlers or []) if listener: handlers.append(_service_state_changed_from_listener(listener)) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 677774594..b22fc8059 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -24,7 +24,7 @@ import asyncio import random -from typing import TYPE_CHECKING, Dict, List, Optional, cast +from typing import TYPE_CHECKING, cast from .._cache import DNSCache from .._dns import ( @@ -395,7 +395,7 @@ def _set_properties(self, properties: dict[str | bytes, str | bytes | None]) -> # as-is, without decoding them, otherwise calling # self.properties will lazy decode them, which is expensive. if TYPE_CHECKING: - self._properties = cast("Dict[bytes, Optional[bytes]]", properties) + self._properties = cast(dict[bytes, bytes | None], properties) else: self._properties = properties self.text = result @@ -462,7 +462,7 @@ def _set_ipv6_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: self._ipv6_addresses = cast( - "List[ZeroconfIPv6Address]", + list[ZeroconfIPv6Address], self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_AAAA), ) else: @@ -472,7 +472,7 @@ def _set_ipv4_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None: """Set IPv4 addresses from the cache.""" if TYPE_CHECKING: self._ipv4_addresses = cast( - "List[ZeroconfIPv4Address]", + list[ZeroconfIPv4Address], self._get_ip_addresses_from_cache_lifo(zc, now, _TYPE_A), ) else: @@ -724,7 +724,7 @@ def _get_address_records_from_cache_by_type(self, zc: Zeroconf, _type: int_) -> cache = zc.cache if TYPE_CHECKING: records = cast( - "List[DNSAddress]", + list[DNSAddress], cache.get_all_by_details(self.server_key, _type, _CLASS_IN), ) else: diff --git a/src/zeroconf/_utils/asyncio.py b/src/zeroconf/_utils/asyncio.py index c92d99d56..860906017 100644 --- a/src/zeroconf/_utils/asyncio.py +++ b/src/zeroconf/_utils/asyncio.py @@ -26,7 +26,8 @@ import concurrent.futures import contextlib import sys -from typing import Any, Awaitable, Coroutine +from collections.abc import Awaitable, Coroutine +from typing import Any from .._exceptions import EventLoopBlocked from ..const import _LOADED_SYSTEM_TIMEOUT diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 3cc4336bf..3321211f0 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -28,7 +28,8 @@ import socket import struct import sys -from typing import Any, Sequence, Tuple, Union, cast +from collections.abc import Sequence +from typing import Any, Union, cast import ifaddr @@ -42,7 +43,7 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] +InterfacesType = Union[Sequence[Union[str, int, tuple[tuple[str, int, int], int]]], InterfaceChoice] @enum.unique @@ -93,7 +94,7 @@ def ip6_to_address_and_index(adapters: list[Any], ip: str) -> tuple[tuple[str, i # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: return ( - cast(Tuple[str, int, int], adapter_ip.ip), + cast(tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index), ) @@ -106,7 +107,7 @@ def interface_index_to_ip6_address(adapters: list[Any], index: int) -> tuple[str for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple): - return cast(Tuple[str, int, int], adapter_ip.ip) + return cast(tuple[str, int, int], adapter_ip.ip) raise RuntimeError(f"No adapter found for index {index}") @@ -340,7 +341,7 @@ def new_respond_socket( respond_socket = new_socket( ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p, - bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), + bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), ) if not respond_socket: return None diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index ce5a43eb9..a0f4a99db 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -24,8 +24,9 @@ import asyncio import contextlib +from collections.abc import Awaitable from types import TracebackType # used in type hints -from typing import Awaitable, Callable +from typing import Callable from ._core import Zeroconf from ._dns import DNSQuestionType diff --git a/tests/conftest.py b/tests/conftest.py index 1f323785c..531c810be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,9 +23,11 @@ def verify_threads_ended(): @pytest.fixture def run_isolated(): """Change the mDNS port to run the test in isolation.""" - with patch.object(query_handler, "_MDNS_PORT", 5454), patch.object( - _core, "_MDNS_PORT", 5454 - ), patch.object(const, "_MDNS_PORT", 5454): + with ( + patch.object(query_handler, "_MDNS_PORT", 5454), + patch.object(_core, "_MDNS_PORT", 5454), + patch.object(const, "_MDNS_PORT", 5454), + ): yield diff --git a/tests/test_init.py b/tests/test_init.py index a36ff8fd2..5ccb9ef63 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -89,9 +89,10 @@ def test_large_packet_exception_log_handling(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) - with patch("zeroconf._logger.log.warning") as mocked_log_warn, patch( - "zeroconf._logger.log.debug" - ) as mocked_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mocked_log_warn, + patch("zeroconf._logger.log.debug") as mocked_log_debug, + ): # now that we have a long packet in our possession, let's verify the # exception handling. out = r.DNSOutgoing(const._FLAGS_QR_RESPONSE | const._FLAGS_AA) diff --git a/tests/test_listener.py b/tests/test_listener.py index a55fc1435..4897eabe0 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -59,8 +59,9 @@ def test_guard_against_oversized_packets(): try: # We are patching to generate an oversized packet - with patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), patch.object( - outgoing, "_MAX_MSG_TYPICAL", 100000 + with ( + patch.object(outgoing, "_MAX_MSG_ABSOLUTE", 100000), + patch.object(outgoing, "_MAX_MSG_TYPICAL", 100000), ): over_sized_packet = generated.packets()[0] assert len(over_sized_packet) > const._MAX_MSG_ABSOLUTE diff --git a/tests/test_logger.py b/tests/test_logger.py index aa5b5382b..4e09aa3b1 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -27,17 +27,19 @@ def test_log_warning_once(): """Test we only log with warning level once.""" QuietLogger._seen_logs = {} quiet_logger = QuietLogger() - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_warning_once("the warning") assert mock_log_warning.mock_calls assert not mock_log_debug.mock_calls - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_warning_once("the warning") assert not mock_log_warning.mock_calls @@ -48,17 +50,19 @@ def test_log_exception_warning(): """Test we only log with warning level once.""" QuietLogger._seen_logs = {} quiet_logger = QuietLogger() - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_exception_warning("the exception warning") assert mock_log_warning.mock_calls assert not mock_log_debug.mock_calls - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_exception_warning("the exception warning") assert not mock_log_warning.mock_calls @@ -85,17 +89,19 @@ def test_log_exception_once(): QuietLogger._seen_logs = {} quiet_logger = QuietLogger() exc = Exception() - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_exception_once(exc, "the exceptional exception warning") assert mock_log_warning.mock_calls assert not mock_log_debug.mock_calls - with patch("zeroconf._logger.log.warning") as mock_log_warning, patch( - "zeroconf._logger.log.debug" - ) as mock_log_debug: + with ( + patch("zeroconf._logger.log.warning") as mock_log_warning, + patch("zeroconf._logger.log.debug") as mock_log_debug, + ): quiet_logger.log_exception_once(exc, "the exceptional exception warning") assert not mock_log_warning.mock_calls diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 489a6460c..f7e51c86d 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -82,9 +82,11 @@ def test_ip6_addresses_to_indexes(): def test_normalize_interface_choice_errors(): """Test we generate exception on invalid input.""" - with patch("zeroconf._utils.net.get_all_addresses", return_value=[]), patch( - "zeroconf._utils.net.get_all_addresses_v6", return_value=[] - ), pytest.raises(RuntimeError): + with ( + patch("zeroconf._utils.net.get_all_addresses", return_value=[]), + patch("zeroconf._utils.net.get_all_addresses_v6", return_value=[]), + pytest.raises(RuntimeError), + ): netutils.normalize_interface_choice(r.InterfaceChoice.All) with pytest.raises(TypeError): @@ -128,8 +130,10 @@ def _log_error(*args): errors_logged.append(args) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - with pytest.raises(OSError), patch.object(netutils.log, "error", _log_error), patch( - "socket.socket.setsockopt", side_effect=OSError + with ( + pytest.raises(OSError), + patch.object(netutils.log, "error", _log_error), + patch("socket.socket.setsockopt", side_effect=OSError), ): netutils.disable_ipv6_only_or_raise(sock) From d54da2dca6b75b2d5cf37b0f52290bac3d2b4edf Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 10 Feb 2025 17:06:12 +0100 Subject: [PATCH 1201/1433] chore: ignore c files generated by cython (#1512) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0af9ce1e1..430fbec9c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/_build/ .vscode /dist/ /zeroconf.egg-info/ +/src/**/*.c From c9aa89911a79dc661df3d887875fde57b34132f7 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 10 Feb 2025 17:20:46 +0100 Subject: [PATCH 1202/1433] chore: set cython max line length to 110 (#1513) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9a28a93c..0bb177bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,5 +198,5 @@ build-backend = "poetry.core.masonry.api" ignore-words-list = ["additionals", "HASS"] [tool.cython-lint] -max-line-length = 88 +max-line-length = 110 ignore = ['E501'] # too many to fix right now From e429d6661cd8a486575e8d7f8ac02bfc006c3e7e Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Mon, 10 Feb 2025 19:00:41 +0100 Subject: [PATCH 1203/1433] chore: add typing, enable additional mypy checks (#1514) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 10 ++++++++-- src/zeroconf/_dns.py | 18 +++++++++--------- src/zeroconf/_listener.py | 4 ++-- src/zeroconf/_utils/net.py | 8 ++++---- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ea453bc2..afeeffbf9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,7 @@ repos: rev: v1.15.0 hooks: - id: mypy - additional_dependencies: [] + additional_dependencies: [ifaddr] - repo: https://github.com/MarcoGorelli/cython-lint rev: v0.16.6 hooks: diff --git a/pyproject.toml b/pyproject.toml index 0bb177bac..3bcd954a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,15 +162,21 @@ profile = "black" known_first_party = ["zeroconf", "tests"] [tool.mypy] +warn_unused_configs = true check_untyped_defs = true disallow_any_generics = false # turn this on when we drop 3.7/3.8 support disallow_incomplete_defs = true disallow_untyped_defs = true +warn_incomplete_stub = true mypy_path = "src/" -no_implicit_optional = true show_error_codes = true +warn_redundant_casts = false # Activate for cleanup. +warn_return_any = true warn_unreachable = true -warn_unused_ignores = false +warn_unused_ignores = false # Does not always work properly, activate for cleanup. +extra_checks = true +strict_equality = true +strict_bytes = true # Will be true by default with mypy v2 release. exclude = [ 'docs/*', 'bench/*', diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index bc0a3948e..591eb0183 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -79,7 +79,7 @@ def _fast_init_entry(self, name: str, type_: _int, class_: _int) -> None: self.class_ = class_ & _CLASS_MASK self.unique = (class_ & _CLASS_UNIQUE) != 0 - def _dns_entry_matches(self, other) -> bool: # type: ignore[no-untyped-def] + def _dns_entry_matches(self, other: DNSEntry) -> bool: return self.key == other.key and self.type == other.type and self.class_ == other.class_ def __eq__(self, other: Any) -> bool: @@ -135,7 +135,7 @@ def __eq__(self, other: Any) -> bool: @property def max_size(self) -> int: """Maximum size of the question in the packet.""" - return len(self.name.encode("utf-8")) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT # type # class + return len(self.name.encode("utf-8")) + _LEN_BYTE + _LEN_SHORT + _LEN_SHORT @property def unicast(self) -> bool: @@ -199,7 +199,7 @@ def suppressed_by(self, msg: DNSIncoming) -> bool: return True return False - def _suppressed_by_answer(self, other) -> bool: # type: ignore[no-untyped-def] + def _suppressed_by_answer(self, other: DNSRecord) -> bool: """Returns true if another record has same name, type and class, and if its TTL is at least half of this record's.""" return self == other and other.ttl > (self.ttl / 2) @@ -285,7 +285,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on address""" return isinstance(other, DNSAddress) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSAddress) -> bool: return ( self.address == other.address and self.scope_id == other.scope_id @@ -344,7 +344,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on cpu and os.""" return isinstance(other, DNSHinfo) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSHinfo) -> bool: """Tests equality on cpu and os.""" return self.cpu == other.cpu and self.os == other.os and self._dns_entry_matches(other) @@ -399,7 +399,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on alias.""" return isinstance(other, DNSPointer) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSPointer) -> bool: """Tests equality on alias.""" return self.alias_key == other.alias_key and self._dns_entry_matches(other) @@ -447,7 +447,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on text.""" return isinstance(other, DNSText) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSText) -> bool: """Tests equality on text.""" return self.text == other.text and self._dns_entry_matches(other) @@ -510,7 +510,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on priority, weight, port and server""" return isinstance(other, DNSService) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSService) -> bool: """Tests equality on priority, weight, port and server.""" return ( self.priority == other.priority @@ -585,7 +585,7 @@ def __eq__(self, other: Any) -> bool: """Tests equality on next_name and rdtypes.""" return isinstance(other, DNSNsec) and self._eq(other) - def _eq(self, other) -> bool: # type: ignore[no-untyped-def] + def _eq(self, other: DNSNsec) -> bool: """Tests equality on next_name and rdtypes.""" return ( self.next_name == other.next_name diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 406273e90..ed5031698 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -131,14 +131,14 @@ def _process_datagram_at_time( if len(addrs) == 2: v6_flow_scope: tuple[()] | tuple[int, int] = () # https://github.com/python/mypy/issues/1178 - addr, port = addrs # type: ignore + addr, port = addrs addr_port = addrs if TYPE_CHECKING: addr_port = cast(tuple[str, int], addr_port) scope = None else: # https://github.com/python/mypy/issues/1178 - addr, port, flow, scope = addrs # type: ignore + addr, port, flow, scope = addrs if debug: # pragma: no branch log.debug("IPv6 scope_id %d associated to the receiving interface", scope) v6_flow_scope = (flow, scope) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 3321211f0..62033ad5a 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -74,14 +74,14 @@ def _encode_address(address: str) -> bytes: def get_all_addresses() -> list[str]: - return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) + return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) # type: ignore[misc] def get_all_addresses_v6() -> list[tuple[tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( - {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} + {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} # type: ignore[misc] ) @@ -127,9 +127,9 @@ def ip6_addresses_to_indexes( for iface in interfaces: if isinstance(iface, int): - result.append((interface_index_to_ip6_address(adapters, iface), iface)) + result.append((interface_index_to_ip6_address(adapters, iface), iface)) # type: ignore[arg-type] elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: - result.append(ip6_to_address_and_index(adapters, iface)) + result.append(ip6_to_address_and_index(adapters, iface)) # type: ignore[arg-type] return result From 00cd7368f542420658156a5e94523c19bf5031a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:00:47 -0600 Subject: [PATCH 1204/1433] chore(pre-commit.ci): pre-commit autoupdate (#1515) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index afeeffbf9..5c4754d83 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.5 + rev: v0.9.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From f377d5cd08d724282c8487785163b466f3971344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Feb 2025 19:19:10 -0600 Subject: [PATCH 1205/1433] fix: make no buffer space available when adding multicast memberships forgiving (#1516) --- src/zeroconf/_utils/net.py | 14 ++++++++++++++ tests/utils/test_net.py | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 62033ad5a..78f37641d 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -302,6 +302,20 @@ def add_multicast_member( interface, ) return False + if _errno == errno.ENOBUFS: + # https://github.com/python-zeroconf/python-zeroconf/issues/1510 + if not is_v6 and sys.platform.startswith("linux"): + log.warning( + "No buffer space available when adding %s to multicast group, " + "try increasing `net.ipv4.igmp_max_memberships` to `1024` in sysctl.conf", + interface, + ) + else: + log.warning( + "No buffer space available when adding %s to multicast group.", + interface, + ) + return False if _errno == errno.EADDRNOTAVAIL: log.info( "Address not available when adding %s to multicast " diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index f7e51c86d..a770e1ce3 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -4,6 +4,7 @@ import errno import socket +import sys import unittest from unittest.mock import MagicMock, Mock, patch @@ -181,7 +182,7 @@ def test_set_mdns_port_socket_options_for_ip_version(): netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) -def test_add_multicast_member(): +def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) interface = "127.0.0.1" @@ -221,6 +222,26 @@ def test_add_multicast_member(): with patch("socket.socket.setsockopt"): assert netutils.add_multicast_member(sock, interface) is True + # Ran out of IGMP memberships is forgiving and logs about igmp_max_memberships on linux + caplog.clear() + with ( + patch.object(sys, "platform", "linux"), + patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available")), + ): + assert netutils.add_multicast_member(sock, interface) is False + assert "No buffer space available" in caplog.text + assert "net.ipv4.igmp_max_memberships" in caplog.text + + # Ran out of IGMP memberships is forgiving and logs + caplog.clear() + with ( + patch.object(sys, "platform", "darwin"), + patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available")), + ): + assert netutils.add_multicast_member(sock, interface) is False + assert "No buffer space available" in caplog.text + assert "net.ipv4.igmp_max_memberships" not in caplog.text + def test_bind_raises_skips_address(): """Test bind failing in new_socket returns None on EADDRNOTAVAIL.""" From a15a1e01e1485a19104ff9bfcdcb7e962396ebeb Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 12 Feb 2025 01:28:59 +0000 Subject: [PATCH 1206/1433] 0.143.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3f69935..031d03da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.143.1 (2025-02-12) + +### Bug Fixes + +- Make no buffer space available when adding multicast memberships forgiving + ([#1516](https://github.com/python-zeroconf/python-zeroconf/pull/1516), + [`f377d5c`](https://github.com/python-zeroconf/python-zeroconf/commit/f377d5cd08d724282c8487785163b466f3971344)) + + ## v0.143.0 (2025-01-31) ### Features diff --git a/pyproject.toml b/pyproject.toml index 3bcd954a4..7d5ccea0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.143.0" +version = "0.143.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b85aee743..bc5d62eda 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.143.0" +__version__ = "0.143.1" __license__ = "LGPL" From 39887b80328d616e8e6f6ca9d08aecc06f7b0711 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Feb 2025 19:32:34 -0600 Subject: [PATCH 1207/1433] feat: add armv7l wheel builds (#1517) --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 937166fe5..357facaa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: needs: [release] if: needs.release.outputs.released == 'true' - name: Build wheels on ${{ matrix.os }} (${{ matrix.musl }}) + name: Build wheels on ${{ matrix.os }} (${{ matrix.musl }}) [${{ matrix.qemu }}] runs-on: ${{ matrix.os }} strategy: matrix: @@ -173,27 +173,47 @@ jobs: macos-13, macos-latest, ] - musl: ["", "musllinux"] - exclude: - - os: windows-2019 - musl: "musllinux" - - os: macos-13 - musl: "musllinux" - - os: macos-latest - musl: "musllinux" - + qemu: [''] + musl: [""] + include: + - os: ubuntu-latest + qemu: armv7l + musl: "" + - os: ubuntu-latest + qemu: armv7l + musl: musllinux + - os: ubuntu-latest + musl: musllinux + - os: ubuntu-24.04-arm + musl: musllinux steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 with: fetch-depth: 0 ref: "master" - # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" - + python-version: "3.12" + - name: Set up QEMU + if: ${{ matrix.qemu }} + uses: docker/setup-qemu-action@v3 + with: + platforms: all + # This should be temporary + # xref https://github.com/docker/setup-qemu-action/issues/188 + # xref https://github.com/tonistiigi/binfmt/issues/215 + image: tonistiigi/binfmt:qemu-v8.1.5 + id: qemu + - name: Prepare emulation + run: | + if [[ -n "${{ matrix.qemu }}" ]]; then + # Build emulated architectures only if QEMU is set, + # use default "auto" otherwise + echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV + fi - name: Install python-semantic-release run: pipx install python-semantic-release==7.34.6 @@ -208,20 +228,18 @@ jobs: ref: "${{ steps.release_tag.outputs.newest_release_tag }}" fetch-depth: 0 - - name: Build wheels ${{ matrix.musl }} + - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) uses: pypa/cibuildwheel@v2.22.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_LINUX: ${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'auto' }} - CIBW_BUILD_VERBOSITY: 3 REQUIRE_CYTHON: 1 - uses: actions/upload-artifact@v4 with: path: ./wheelhouse/*.whl - name: wheels-${{ matrix.os }}-${{ matrix.musl }} + name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }} upload_pypi: needs: [build_wheels] From bd271f30441b969a9db0bdf9cad64325b6e6b33e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 12 Feb 2025 01:47:39 +0000 Subject: [PATCH 1208/1433] 0.144.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 031d03da3..e9500c2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # CHANGELOG +## v0.144.0 (2025-02-12) + +### Features + +- Add armv7l wheel builds ([#1517](https://github.com/python-zeroconf/python-zeroconf/pull/1517), + [`39887b8`](https://github.com/python-zeroconf/python-zeroconf/commit/39887b80328d616e8e6f6ca9d08aecc06f7b0711)) + + ## v0.143.1 (2025-02-12) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 7d5ccea0b..66ce717b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.143.1" +version = "0.144.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index bc5d62eda..9f7764d27 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.143.1" +__version__ = "0.144.0" __license__ = "LGPL" From e7adac9c59fc4d0c4822c6097a4daee3d68eb4de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Feb 2025 19:54:14 -0600 Subject: [PATCH 1209/1433] fix: wheel builds failing after adding armv7l builds (#1518) --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 357facaa0..f8eaa7ec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,7 @@ jobs: image: tonistiigi/binfmt:qemu-v8.1.5 id: qemu - name: Prepare emulation + if: ${{ matrix.qemu }} run: | if [[ -n "${{ matrix.qemu }}" ]]; then # Build emulated architectures only if QEMU is set, @@ -232,7 +233,7 @@ jobs: uses: pypa/cibuildwheel@v2.22.0 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* *p39-*_aarch64 *p310-*_aarch64 pp*_aarch64 ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} + CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 From dba44e4cbc853c1fd99f7fad274afc1cba99363f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 12 Feb 2025 02:04:11 +0000 Subject: [PATCH 1210/1433] 0.144.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9500c2ba..6c8f248c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.144.1 (2025-02-12) + +### Bug Fixes + +- Wheel builds failing after adding armv7l builds + ([#1518](https://github.com/python-zeroconf/python-zeroconf/pull/1518), + [`e7adac9`](https://github.com/python-zeroconf/python-zeroconf/commit/e7adac9c59fc4d0c4822c6097a4daee3d68eb4de)) + + ## v0.144.0 (2025-02-12) ### Features diff --git a/pyproject.toml b/pyproject.toml index 66ce717b9..8ec6a85bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.144.0" +version = "0.144.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 9f7764d27..6efc9a5ca 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.144.0" +__version__ = "0.144.1" __license__ = "LGPL" From cf44289c33baee045ac84b40b218e4a92589a30f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Feb 2025 22:24:56 -0600 Subject: [PATCH 1211/1433] chore: cleanup typing in net utils (#1521) --- src/zeroconf/_utils/net.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 78f37641d..fd6e0dfff 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -85,29 +85,30 @@ def get_all_addresses_v6() -> list[tuple[tuple[str, int, int], int]]: ) -def ip6_to_address_and_index(adapters: list[Any], ip: str) -> tuple[tuple[str, int, int], int]: +def ip6_to_address_and_index(adapters: list[ifaddr.Adapter], ip: str) -> tuple[tuple[str, int, int], int]: if "%" in ip: ip = ip[: ip.index("%")] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples - if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: - return ( - cast(tuple[str, int, int], adapter_ip.ip), - cast(int, adapter.index), - ) + if ( + adapter.index is not None + and isinstance(adapter_ip.ip, tuple) + and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr + ): + return (adapter_ip.ip, adapter.index) raise RuntimeError(f"No adapter found for IP address {ip}") -def interface_index_to_ip6_address(adapters: list[Any], index: int) -> tuple[str, int, int]: +def interface_index_to_ip6_address(adapters: list[ifaddr.Adapter], index: int) -> tuple[str, int, int]: for adapter in adapters: if adapter.index == index: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples if isinstance(adapter_ip.ip, tuple): - return cast(tuple[str, int, int], adapter_ip.ip) + return adapter_ip.ip raise RuntimeError(f"No adapter found for index {index}") @@ -414,8 +415,7 @@ def create_sockets( return listen_socket, respond_sockets -def get_errno(e: Exception) -> int: - assert isinstance(e, socket.error) +def get_errno(e: OSError) -> int: return cast(int, e.args[0]) From 0ca624da07b3f5ceb158c1afd51318c3ed017ab8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Feb 2025 06:01:56 -0600 Subject: [PATCH 1212/1433] chore: enable some more ruff rules (#1522) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- build_ext.py | 1 - pyproject.toml | 77 ++++++++++++++++++++++++++++++ src/zeroconf/_history.py | 4 +- src/zeroconf/_protocol/incoming.py | 2 +- src/zeroconf/_protocol/outgoing.py | 2 +- src/zeroconf/_record_update.py | 2 +- src/zeroconf/_services/__init__.py | 6 +-- src/zeroconf/_services/info.py | 8 ++-- tests/test_handlers.py | 5 +- tests/test_history.py | 2 +- tests/test_services.py | 1 - 11 files changed, 91 insertions(+), 19 deletions(-) diff --git a/build_ext.py b/build_ext.py index 26b4eb96f..e91f6350f 100644 --- a/build_ext.py +++ b/build_ext.py @@ -54,4 +54,3 @@ def build(setup_kwargs: Any) -> None: except Exception: if os.environ.get("REQUIRE_CYTHON"): raise - pass diff --git a/pyproject.toml b/pyproject.toml index 8ec6a85bc..6404e8d3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,24 @@ line-length = 110 ignore = [ "S101", # use of assert "S104", # S104 Possible binding to all interfaces + "PLR0912", # too many to fix right now + "TC001", # too many to fix right now + "TID252", # skip + "PLR0913", # too late to make changes here + "PLR0911", # would be breaking change + "TRY003", # too many to fix + "SLF001", # design choice + "TC003", # too many to fix + "PLR2004" , # too many to fix + "PGH004", # too many to fix + "PGH003", # too many to fix + "SIM110", # this is slower + "FURB136", # this is slower for Cython + "PYI034", # enable when we drop Py3.10 + "PYI032", # breaks Cython + "PYI041", # breaks Cython + "FURB188", # usually slower + "PERF401", # Cython: closures inside cpdef functions not yet supported ] select = [ "B", # flake8-bugbear @@ -104,8 +122,67 @@ select = [ "UP", # pyupgrade "I", # isort "RUF", # ruff specific + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format , + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise , + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops ] +[tool.ruff.lint.per-file-ignores] +"tests/**/*" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "S101", + "SLF001", + "PLR2004", # too many to fix right now + "PT011", # too many to fix right now + "PT006", # too many to fix right now + "PGH003", # too many to fix right now + "PT007", # too many to fix right now + "PT027", # too many to fix right now + "PLW0603" , # too many to fix right now + "PLR0915", # too many to fix right now + "FLY002", # too many to fix right now + "PT018", # too many to fix right now + "PLR0124", # too many to fix right now + "SIM202" , # too many to fix right now + "PT012" , # too many to fix right now + "TID252", # too many to fix right now + "PLR0913", # skip this one + "SIM102" , # too many to fix right now + "SIM108", # too many to fix right now + "TC003", # too many to fix right now + "TC002", # too many to fix right now + "T201", # too many to fix right now +] +"bench/**/*" = [ + "T201", # intended +] +"examples/**/*" = [ + "T201", # intended +] +"setup.py" = ["D100"] +"conftest.py" = ["D100"] +"docs/conf.py" = ["D100"] [tool.pylint.BASIC] class-const-naming-style = "any" diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index 5bae7be04..1b6f3fadf 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -60,9 +60,7 @@ def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSR return False # The last question has more known answers than # we knew so we have to ask - if previous_known_answers - known_answers: - return False - return True + return not previous_known_answers - known_answers def async_expire(self, now: _float) -> None: """Expire the history of old questions.""" diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 7f4a8eec1..2d977b642 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -398,7 +398,7 @@ def _read_bitmap(self, end: _int) -> list[int]: bitmap_length = view[offset_plus_one] bitmap_end = offset_plus_two + bitmap_length for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]): - for bit in range(0, 8): + for bit in range(8): if byte & (0x80 >> bit): rdtypes.append(bit + window * 256 + i * 8) self.offset += 2 + bitmap_length diff --git a/src/zeroconf/_protocol/outgoing.py b/src/zeroconf/_protocol/outgoing.py index 6837e39ab..fd5e57a02 100644 --- a/src/zeroconf/_protocol/outgoing.py +++ b/src/zeroconf/_protocol/outgoing.py @@ -272,7 +272,7 @@ def write_name(self, name: str_) -> None: """ # split name into each label - if name.endswith("."): + if name and name[-1] == ".": name = name[:-1] index = self.names.get(name, 0) diff --git a/src/zeroconf/_record_update.py b/src/zeroconf/_record_update.py index 5f8175113..497ee39df 100644 --- a/src/zeroconf/_record_update.py +++ b/src/zeroconf/_record_update.py @@ -43,6 +43,6 @@ def __getitem__(self, index: int) -> DNSRecord | None: """Get the new or old record.""" if index == 0: return self.new - elif index == 1: + if index == 1: return self.old raise IndexError(index) diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index 6936aed61..b244552f1 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -38,13 +38,13 @@ class ServiceStateChange(enum.Enum): class ServiceListener: def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: - raise NotImplementedError() + raise NotImplementedError def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: - raise NotImplementedError() + raise NotImplementedError def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: - raise NotImplementedError() + raise NotImplementedError class Signal: diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index b22fc8059..9cd8df163 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -378,13 +378,13 @@ def _set_properties(self, properties: dict[str | bytes, str | bytes | None]) -> result = b"" for key, value in properties.items(): if isinstance(key, str): - key = key.encode("utf-8") + key = key.encode("utf-8") # noqa: PLW2901 properties_contain_str = True record = key if value is not None: if not isinstance(value, bytes): - value = str(value).encode("utf-8") + value = str(value).encode("utf-8") # noqa: PLW2901 properties_contain_str = True record += b"=" + value list_.append(record) @@ -524,7 +524,7 @@ def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float # since by default IPv4Address.__eq__ compares the # the addresses on version and int which more than # we need here since we know the version is 4. - elif ip_addr.zc_integer != ipv4_addresses[0].zc_integer: + if ip_addr.zc_integer != ipv4_addresses[0].zc_integer: ipv4_addresses.remove(ip_addr) ipv4_addresses.insert(0, ip_addr) @@ -540,7 +540,7 @@ def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float # since by default IPv6Address.__eq__ compares the # the addresses on version and int which more than # we need here since we know the version is 6. - elif ip_addr.zc_integer != self._ipv6_addresses[0].zc_integer: + if ip_addr.zc_integer != self._ipv6_addresses[0].zc_integer: ipv6_addresses.remove(ip_addr) ipv6_addresses.insert(0, ip_addr) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index fd0e689c4..ffa4ff88c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -67,10 +67,9 @@ def test_ttl(self): def get_ttl(record_type): if expected_ttl is not None: return expected_ttl - elif record_type in [const._TYPE_A, const._TYPE_SRV, const._TYPE_NSEC]: + if record_type in [const._TYPE_A, const._TYPE_SRV, const._TYPE_NSEC]: return const._DNS_HOST_TTL - else: - return const._DNS_OTHER_TTL + return const._DNS_OTHER_TTL def _process_outgoing_packet(out): """Sends an outgoing packet.""" diff --git a/tests/test_history.py b/tests/test_history.py index 4c9836ce6..e9254168e 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -3,7 +3,7 @@ from __future__ import annotations import zeroconf as r -import zeroconf.const as const +from zeroconf import const from zeroconf._history import QuestionHistory diff --git a/tests/test_services.py b/tests/test_services.py index e93174cc7..7d7c3fc7d 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -71,7 +71,6 @@ def update_service(self, zeroconf, type, name): class MySubListener(r.ServiceListener): def add_service(self, zeroconf, type, name): sub_service_added.set() - pass def remove_service(self, zeroconf, type, name): pass From 8fbbe419a069c3ee946a4185e14bd6de660aa67b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 11:45:11 -0600 Subject: [PATCH 1213/1433] chore: split up armv7l wheels to speed up release (#1524) --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8eaa7ec1..5c57e3b59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,19 +173,58 @@ jobs: macos-13, macos-latest, ] - qemu: [''] + qemu: [""] musl: [""] + pyver: [""] include: + - os: ubuntu-latest + musl: "musllinux" + - os: ubuntu-24.04-arm + musl: "musllinux" + # qemu is slow, make a single + # runner per Python version + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp39 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp310 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp311 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp312 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp313 + # qemu is slow, make a single + # runner per Python version - os: ubuntu-latest qemu: armv7l musl: "" + pyver: cp39 - os: ubuntu-latest qemu: armv7l - musl: musllinux + musl: "" + pyver: cp310 - os: ubuntu-latest - musl: musllinux - - os: ubuntu-24.04-arm - musl: musllinux + qemu: armv7l + musl: "" + pyver: cp311 + - os: ubuntu-latest + qemu: armv7l + musl: "" + pyver: cp312 + - os: ubuntu-latest + qemu: armv7l + musl: "" + pyver: cp313 steps: - name: Checkout uses: actions/checkout@v4 @@ -215,6 +254,13 @@ jobs: # use default "auto" otherwise echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV fi + - name: Limit to a specific Python version on slow QEMU + if: ${{ matrix.pyver }} + run: | + if [[ -n "${{ matrix.pyver }}" ]]; then + echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV + fi + - name: Install python-semantic-release run: pipx install python-semantic-release==7.34.6 From f5e55ffafedbcf8296afe4920b59d00f74ac2abb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 12:07:56 -0600 Subject: [PATCH 1214/1433] chore: improve wheel build runner names in the CI (#1525) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c57e3b59..27977693e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: needs: [release] if: needs.release.outputs.released == 'true' - name: Build wheels on ${{ matrix.os }} (${{ matrix.musl }}) [${{ matrix.qemu }}] + name: Wheels for ${{ matrix.os }} (${{ matrix.musl == 'musllinux' && 'musllinux' || 'manylinux' }}) ${{ matrix.qemu }} ${{ matrix.pyver }} runs-on: ${{ matrix.os }} strategy: matrix: From 48dbb7190a4f5126e39dbcdb87e34380d4562cd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 12:21:31 -0600 Subject: [PATCH 1215/1433] fix: add a helpful hint for when EADDRINUSE happens during startup (#1526) --- src/zeroconf/_utils/net.py | 19 +++++++++++++++++++ tests/utils/test_net.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index fd6e0dfff..c2312e01f 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -262,6 +262,25 @@ def new_socket( bind_tup, ) return None + if ex.errno == errno.EADDRINUSE: + if sys.platform.startswith("darwin") or sys.platform.startswith("freebsd"): + log.error( + "Address in use when binding to %s; " + "On BSD based systems sharing the same port with another " + "stack may require processes to run with the same UID; " + "When using avahi, make sure disallow-other-stacks is set" + " to no in avahi-daemon.conf", + bind_tup, + ) + else: + log.error( + "Address in use when binding to %s; " + "When using avahi, make sure disallow-other-stacks is set" + " to no in avahi-daemon.conf", + bind_tup, + ) + # This is still a fatal error as its not going to work + # if we can't hear the traffic coming in. raise log.debug("Created socket %s", s) return s diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index a770e1ce3..f763b655c 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -260,6 +260,40 @@ def _mock_socket(*args, **kwargs): netutils.new_socket(("0.0.0.0", 0)) # type: ignore[arg-type] +def test_bind_raises_address_in_use(caplog: pytest.LogCaptureFixture) -> None: + """Test bind failing in new_socket returns None on EADDRINUSE.""" + + def _mock_socket(*args, **kwargs): + sock = MagicMock() + sock.bind = MagicMock(side_effect=OSError(errno.EADDRINUSE, f"Error: {errno.EADDRINUSE}")) + return sock + + with ( + pytest.raises(OSError), + patch.object(sys, "platform", "darwin"), + patch("socket.socket", _mock_socket), + ): + netutils.new_socket(("0.0.0.0", 0)) # type: ignore[arg-type] + assert ( + "On BSD based systems sharing the same port with " + "another stack may require processes to run with the same UID" + ) in caplog.text + assert ( + "When using avahi, make sure disallow-other-stacks is set to no in avahi-daemon.conf" in caplog.text + ) + + caplog.clear() + with pytest.raises(OSError), patch.object(sys, "platform", "linux"), patch("socket.socket", _mock_socket): + netutils.new_socket(("0.0.0.0", 0)) # type: ignore[arg-type] + assert ( + "On BSD based systems sharing the same port with " + "another stack may require processes to run with the same UID" + ) not in caplog.text + assert ( + "When using avahi, make sure disallow-other-stacks is set to no in avahi-daemon.conf" in caplog.text + ) + + def test_new_respond_socket_new_socket_returns_none(): """Test new_respond_socket returns None if new_socket returns None.""" with patch.object(netutils, "new_socket", return_value=None): From 450f5a568a0321fd09a7eb0e3f7bb251ad35c4a0 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 14 Feb 2025 18:33:24 +0000 Subject: [PATCH 1216/1433] 0.144.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8f248c3..f986d8108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.144.2 (2025-02-14) + +### Bug Fixes + +- Add a helpful hint for when EADDRINUSE happens during startup + ([#1526](https://github.com/python-zeroconf/python-zeroconf/pull/1526), + [`48dbb71`](https://github.com/python-zeroconf/python-zeroconf/commit/48dbb7190a4f5126e39dbcdb87e34380d4562cd0)) + + ## v0.144.1 (2025-02-12) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 6404e8d3b..c87ef7c95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.144.1" +version = "0.144.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 6efc9a5ca..7ec3c120f 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.144.1" +__version__ = "0.144.2" __license__ = "LGPL" From 43136fa418d4d7826415e1d0f7761b198347ced7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 13:12:51 -0600 Subject: [PATCH 1217/1433] fix: non unique name during wheel upload (#1527) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27977693e..578fe76ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,7 +286,7 @@ jobs: - uses: actions/upload-artifact@v4 with: path: ./wheelhouse/*.whl - name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }} + name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} upload_pypi: needs: [build_wheels] From 53cc868b77e98e9b3f938a1ad905b3be20fce9ac Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 14 Feb 2025 19:26:13 +0000 Subject: [PATCH 1218/1433] 0.144.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f986d8108..19b36d46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.144.3 (2025-02-14) + +### Bug Fixes + +- Non unique name during wheel upload + ([#1527](https://github.com/python-zeroconf/python-zeroconf/pull/1527), + [`43136fa`](https://github.com/python-zeroconf/python-zeroconf/commit/43136fa418d4d7826415e1d0f7761b198347ced7)) + + ## v0.144.2 (2025-02-14) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index c87ef7c95..4015b88f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.144.2" +version = "0.144.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 7ec3c120f..59052702e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.144.2" +__version__ = "0.144.3" __license__ = "LGPL" From 8c913e11ea59d59d4905defa53aa1e963695c47c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 14:32:11 -0800 Subject: [PATCH 1219/1433] chore: enable ASYNC ruff rules (#1528) --- examples/async_apple_scanner.py | 3 +-- examples/async_browser.py | 3 +-- examples/async_registration.py | 3 +-- pyproject.toml | 1 + 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/async_apple_scanner.py b/examples/async_apple_scanner.py index 19691662d..00744b5c5 100755 --- a/examples/async_apple_scanner.py +++ b/examples/async_apple_scanner.py @@ -96,8 +96,7 @@ async def async_run(self) -> None: ALL_SERVICES, **kwargs, # type: ignore[arg-type] ) - while True: - await asyncio.sleep(1) + await asyncio.Event().wait() async def async_close(self) -> None: assert self.aiozc is not None diff --git a/examples/async_browser.py b/examples/async_browser.py index d86cfc5e7..58193f705 100755 --- a/examples/async_browser.py +++ b/examples/async_browser.py @@ -74,8 +74,7 @@ async def async_run(self) -> None: self.aiobrowser = AsyncServiceBrowser( self.aiozc.zeroconf, services, handlers=[async_on_service_state_change] ) - while True: - await asyncio.sleep(1) + await asyncio.Event().wait() async def async_close(self) -> None: assert self.aiozc is not None diff --git a/examples/async_registration.py b/examples/async_registration.py index d01b15e16..5c774cadb 100755 --- a/examples/async_registration.py +++ b/examples/async_registration.py @@ -24,8 +24,7 @@ async def register_services(self, infos: list[AsyncServiceInfo]) -> None: background_tasks = await asyncio.gather(*tasks) await asyncio.gather(*background_tasks) print("Finished registration, press Ctrl-C to exit...") - while True: - await asyncio.sleep(1) + await asyncio.Event().wait() async def unregister_services(self, infos: list[AsyncServiceInfo]) -> None: assert self.aiozc is not None diff --git a/pyproject.toml b/pyproject.toml index 4015b88f6..eb4ead925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ ignore = [ "PERF401", # Cython: closures inside cpdef functions not yet supported ] select = [ + "ASYNC", # async rules "B", # flake8-bugbear "C4", # flake8-comprehensions "S", # flake8-bandit From 1c7f3548b6cbddf73dbb9d69cd8987c8ad32c705 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Sat, 15 Feb 2025 23:47:51 +0100 Subject: [PATCH 1220/1433] feat(docs): enable link to source code (#1529) --- docs/conf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index c3bce6715..11a0f2d43 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,9 +33,14 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ + "sphinx.ext.todo", # Allow todo comments. + "sphinx.ext.viewcode", # Link to source code. "sphinx.ext.autodoc", "zeroconfautodocfix", # Must be after "sphinx.ext.autodoc" "sphinx.ext.intersphinx", + "sphinx.ext.coverage", # Enable the overage report. + "sphinx.ext.duration", # Show build duration at the end. + "sphinx_rtd_theme", # Required for theme. ] templates_path = ["_templates"] @@ -53,6 +58,11 @@ "**": ("localtoc.html", "relations.html", "sourcelink.html", "searchbox.html"), } +# -- Options for RTD theme --------------------------------------------------- +# https://sphinx-rtd-theme.readthedocs.io/en/stable/configuring.html + +# html_theme_options = {} + # -- Options for HTML help output -------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-help-output From d4506fd4dc391fb366d1f7dfb5ec77933221a2eb Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 15 Feb 2025 22:57:36 +0000 Subject: [PATCH 1221/1433] 0.145.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b36d46b..243772b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.145.0 (2025-02-15) + +### Features + +- **docs**: Enable link to source code + ([#1529](https://github.com/python-zeroconf/python-zeroconf/pull/1529), + [`1c7f354`](https://github.com/python-zeroconf/python-zeroconf/commit/1c7f3548b6cbddf73dbb9d69cd8987c8ad32c705)) + + ## v0.144.3 (2025-02-14) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index eb4ead925..78b100d22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.144.3" +version = "0.145.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 59052702e..2cccd05d0 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.144.3" +__version__ = "0.145.0" __license__ = "LGPL" From aab566f1d9a5d6e2c73ba459daed85a4ed81d721 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 10:52:54 -0600 Subject: [PATCH 1222/1433] chore(deps-dev): bump cython from 3.0.11 to 3.0.12 (#1531) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 132 ++++++++++++++++++++++++++-------------------------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/poetry.lock b/poetry.lock index 258b28f2a..9caaac173 100644 --- a/poetry.lock +++ b/poetry.lock @@ -306,77 +306,75 @@ toml = ["tomli"] [[package]] name = "cython" -version = "3.0.11" +version = "3.0.12" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" files = [ - {file = "Cython-3.0.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:44292aae17524abb4b70a25111fe7dec1a0ad718711d47e3786a211d5408fdaa"}, - {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75d45fbc20651c1b72e4111149fed3b33d270b0a4fb78328c54d965f28d55e1"}, - {file = "Cython-3.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d89a82937ce4037f092e9848a7bbcc65bc8e9fc9aef2bb74f5c15e7d21a73080"}, - {file = "Cython-3.0.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a8ea2e7e2d3bc0d8630dafe6c4a5a89485598ff8a61885b74f8ed882597efd5"}, - {file = "Cython-3.0.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cee29846471ce60226b18e931d8c1c66a158db94853e3e79bc2da9bd22345008"}, - {file = "Cython-3.0.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eeb6860b0f4bfa402de8929833fe5370fa34069c7ebacb2d543cb017f21fb891"}, - {file = "Cython-3.0.11-cp310-cp310-win32.whl", hash = "sha256:3699391125ab344d8d25438074d1097d9ba0fb674d0320599316cfe7cf5f002a"}, - {file = "Cython-3.0.11-cp310-cp310-win_amd64.whl", hash = "sha256:d02f4ebe15aac7cdacce1a628e556c1983f26d140fd2e0ac5e0a090e605a2d38"}, - {file = "Cython-3.0.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75ba1c70b6deeaffbac123856b8d35f253da13552207aa969078611c197377e4"}, - {file = "Cython-3.0.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af91497dc098718e634d6ec8f91b182aea6bb3690f333fc9a7777bc70abe8810"}, - {file = "Cython-3.0.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3999fb52d3328a6a5e8c63122b0a8bd110dfcdb98dda585a3def1426b991cba7"}, - {file = "Cython-3.0.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d566a4e09b8979be8ab9f843bac0dd216c81f5e5f45661a9b25cd162ed80508c"}, - {file = "Cython-3.0.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:46aec30f217bdf096175a1a639203d44ac73a36fe7fa3dd06bd012e8f39eca0f"}, - {file = "Cython-3.0.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd1fe25af330f4e003421636746a546474e4ccd8f239f55d2898d80983d20ed"}, - {file = "Cython-3.0.11-cp311-cp311-win32.whl", hash = "sha256:221de0b48bf387f209003508e602ce839a80463522fc6f583ad3c8d5c890d2c1"}, - {file = "Cython-3.0.11-cp311-cp311-win_amd64.whl", hash = "sha256:3ff8ac1f0ecd4f505db4ab051e58e4531f5d098b6ac03b91c3b902e8d10c67b3"}, - {file = "Cython-3.0.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:11996c40c32abf843ba652a6d53cb15944c88d91f91fc4e6f0028f5df8a8f8a1"}, - {file = "Cython-3.0.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63f2c892e9f9c1698ecfee78205541623eb31cd3a1b682668be7ac12de94aa8e"}, - {file = "Cython-3.0.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b14c24f1dc4c4c9d997cca8d1b7fb01187a218aab932328247dcf5694a10102"}, - {file = "Cython-3.0.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8eed5c015685106db15dd103fd040948ddca9197b1dd02222711815ea782a27"}, - {file = "Cython-3.0.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780f89c95b8aec1e403005b3bf2f0a2afa060b3eba168c86830f079339adad89"}, - {file = "Cython-3.0.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a690f2ff460682ea985e8d38ec541be97e0977fa0544aadc21efc116ff8d7579"}, - {file = "Cython-3.0.11-cp312-cp312-win32.whl", hash = "sha256:2252b5aa57621848e310fe7fa6f7dce5f73aa452884a183d201a8bcebfa05a00"}, - {file = "Cython-3.0.11-cp312-cp312-win_amd64.whl", hash = "sha256:da394654c6da15c1d37f0b7ec5afd325c69a15ceafee2afba14b67a5df8a82c8"}, - {file = "Cython-3.0.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4341d6a64d47112884e0bcf31e6c075268220ee4cd02223047182d4dda94d637"}, - {file = "Cython-3.0.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351955559b37e6c98b48aecb178894c311be9d731b297782f2b78d111f0c9015"}, - {file = "Cython-3.0.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c02361af9bfa10ff1ccf967fc75159e56b1c8093caf565739ed77a559c1f29f"}, - {file = "Cython-3.0.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6823aef13669a32caf18bbb036de56065c485d9f558551a9b55061acf9c4c27f"}, - {file = "Cython-3.0.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6fb68cef33684f8cc97987bee6ae919eee7e18ee6a3ad7ed9516b8386ef95ae6"}, - {file = "Cython-3.0.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790263b74432cb997740d73665f4d8d00b9cd1cecbdd981d93591ddf993d4f12"}, - {file = "Cython-3.0.11-cp313-cp313-win32.whl", hash = "sha256:e6dd395d1a704e34a9fac00b25f0036dce6654c6b898be6f872ac2bb4f2eda48"}, - {file = "Cython-3.0.11-cp313-cp313-win_amd64.whl", hash = "sha256:52186101d51497519e99b60d955fd5cb3bf747c67f00d742e70ab913f1e42d31"}, - {file = "Cython-3.0.11-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c69d5cad51388522b98a99b4be1b77316de85b0c0523fa865e0ea58bbb622e0a"}, - {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8acdc87e9009110adbceb7569765eb0980129055cc954c62f99fe9f094c9505e"}, - {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dd47865f4c0a224da73acf83d113f93488d17624e2457dce1753acdfb1cc40c"}, - {file = "Cython-3.0.11-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:301bde949b4f312a1c70e214b0c3bc51a3f955d466010d2f68eb042df36447b0"}, - {file = "Cython-3.0.11-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:f3953d2f504176f929862e5579cfc421860c33e9707f585d70d24e1096accdf7"}, - {file = "Cython-3.0.11-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:3f2b062f6df67e8a56c75e500ca330cf62c85ac26dd7fd006f07ef0f83aebfa3"}, - {file = "Cython-3.0.11-cp36-cp36m-win32.whl", hash = "sha256:c3d68751668c66c7a140b6023dba5d5d507f72063407bb609d3a5b0f3b8dfbe4"}, - {file = "Cython-3.0.11-cp36-cp36m-win_amd64.whl", hash = "sha256:bcd29945fafd12484cf37b1d84f12f0e7a33ba3eac5836531c6bd5283a6b3a0c"}, - {file = "Cython-3.0.11-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4e9a8d92978b15a0c7ca7f98447c6c578dc8923a0941d9d172d0b077cb69c576"}, - {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:421017466e9260aca86823974e26e158e6358622f27c0f4da9c682f3b6d2e624"}, - {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80a7232938d523c1a12f6b1794ab5efb1ae77ad3fde79de4bb558d8ab261619"}, - {file = "Cython-3.0.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfa550d9ae39e827a6e7198076df763571cb53397084974a6948af558355e028"}, - {file = "Cython-3.0.11-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:aedceb6090a60854b31bf9571dc55f642a3fa5b91f11b62bcef167c52cac93d8"}, - {file = "Cython-3.0.11-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:473d35681d9f93ce380e6a7c8feb2d65fc6333bd7117fbc62989e404e241dbb0"}, - {file = "Cython-3.0.11-cp37-cp37m-win32.whl", hash = "sha256:3379c6521e25aa6cd7703bb7d635eaca75c0f9c7f1b0fdd6dd15a03bfac5f68d"}, - {file = "Cython-3.0.11-cp37-cp37m-win_amd64.whl", hash = "sha256:14701edb3107a5d9305a82d9d646c4f28bfecbba74b26cc1ee2f4be08f602057"}, - {file = "Cython-3.0.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:598699165cfa7c6d69513ee1bffc9e1fdd63b00b624409174c388538aa217975"}, - {file = "Cython-3.0.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0583076c4152b417a3a8a5d81ec02f58c09b67d3f22d5857e64c8734ceada8c"}, - {file = "Cython-3.0.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52205347e916dd65d2400b977df4c697390c3aae0e96275a438cc4ae85dadc08"}, - {file = "Cython-3.0.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:989899a85f0d9a57cebb508bd1f194cb52f0e3f7e22ac259f33d148d6422375c"}, - {file = "Cython-3.0.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:53b6072a89049a991d07f42060f65398448365c59c9cb515c5925b9bdc9d71f8"}, - {file = "Cython-3.0.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f988f7f8164a6079c705c39e2d75dbe9967e3dacafe041420d9af7b9ee424162"}, - {file = "Cython-3.0.11-cp38-cp38-win32.whl", hash = "sha256:a1f4cbc70f6b7f0c939522118820e708e0d490edca42d852fa8004ec16780be2"}, - {file = "Cython-3.0.11-cp38-cp38-win_amd64.whl", hash = "sha256:187685e25e037320cae513b8cc4bf9dbc4465c037051aede509cbbf207524de2"}, - {file = "Cython-3.0.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0fc6fdd6fa493be7bdda22355689d5446ac944cd71286f6f44a14b0d67ee3ff5"}, - {file = "Cython-3.0.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b1d1f6f94cc5d42a4591f6d60d616786b9cd15576b112bc92a23131fcf38020"}, - {file = "Cython-3.0.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ab2b92a3e6ed552adbe9350fd2ef3aa0cc7853cf91569f9dbed0c0699bbeab"}, - {file = "Cython-3.0.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:104d6f2f2c827ccc5e9e42c80ef6773a6aa94752fe6bc5b24a4eab4306fb7f07"}, - {file = "Cython-3.0.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13062ce556a1e98d2821f7a0253b50569fdc98c36efd6653a65b21e3f8bbbf5f"}, - {file = "Cython-3.0.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:525d09b3405534763fa73bd78c8e51ac8264036ce4c16d37dfd1555a7da6d3a7"}, - {file = "Cython-3.0.11-cp39-cp39-win32.whl", hash = "sha256:b8c7e514075696ca0f60c337f9e416e61d7ccbc1aa879a56c39181ed90ec3059"}, - {file = "Cython-3.0.11-cp39-cp39-win_amd64.whl", hash = "sha256:8948802e1f5677a673ea5d22a1e7e273ca5f83e7a452786ca286eebf97cee67c"}, - {file = "Cython-3.0.11-py2.py3-none-any.whl", hash = "sha256:0e25f6425ad4a700d7f77cd468da9161e63658837d1bc34861a9861a4ef6346d"}, - {file = "cython-3.0.11.tar.gz", hash = "sha256:7146dd2af8682b4ca61331851e6aebce9fe5158e75300343f80c07ca80b1faff"}, + {file = "Cython-3.0.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba67eee9413b66dd9fbacd33f0bc2e028a2a120991d77b5fd4b19d0b1e4039b9"}, + {file = "Cython-3.0.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee2717e5b5f7d966d0c6e27d2efe3698c357aa4d61bb3201997c7a4f9fe485a"}, + {file = "Cython-3.0.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cffc3464f641c8d0dda942c7c53015291beea11ec4d32421bed2f13b386b819"}, + {file = "Cython-3.0.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d3a8f81980ffbd74e52f9186d8f1654e347d0c44bfea6b5997028977f481a179"}, + {file = "Cython-3.0.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8d32856716c369d01f2385ad9177cdd1a11079ac89ea0932dc4882de1aa19174"}, + {file = "Cython-3.0.12-cp310-cp310-win32.whl", hash = "sha256:712c3f31adec140dc60d064a7f84741f50e2c25a8edd7ae746d5eb4d3ef7072a"}, + {file = "Cython-3.0.12-cp310-cp310-win_amd64.whl", hash = "sha256:d6945694c5b9170cfbd5f2c0d00ef7487a2de7aba83713a64ee4ebce7fad9e05"}, + {file = "Cython-3.0.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feb86122a823937cc06e4c029d80ff69f082ebb0b959ab52a5af6cdd271c5dc3"}, + {file = "Cython-3.0.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfdbea486e702c328338314adb8e80f5f9741f06a0ae83aaec7463bc166d12e8"}, + {file = "Cython-3.0.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563de1728c8e48869d2380a1b76bbc1b1b1d01aba948480d68c1d05e52d20c92"}, + {file = "Cython-3.0.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d4576c1e1f6316282aa0b4a55139254fbed965cba7813e6d9900d3092b128"}, + {file = "Cython-3.0.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e5eadef80143026944ea8f9904715a008f5108d1d644a89f63094cc37351e73"}, + {file = "Cython-3.0.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a93cbda00a5451175b97dea5a9440a3fcee9e54b4cba7a7dbcba9a764b22aec"}, + {file = "Cython-3.0.12-cp311-cp311-win32.whl", hash = "sha256:3109e1d44425a2639e9a677b66cd7711721a5b606b65867cb2d8ef7a97e2237b"}, + {file = "Cython-3.0.12-cp311-cp311-win_amd64.whl", hash = "sha256:d4b70fc339adba1e2111b074ee6119fe9fd6072c957d8597bce9a0dd1c3c6784"}, + {file = "Cython-3.0.12-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fe030d4a00afb2844f5f70896b7f2a1a0d7da09bf3aa3d884cbe5f73fff5d310"}, + {file = "Cython-3.0.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7fec4f052b8fe173fe70eae75091389955b9a23d5cec3d576d21c5913b49d47"}, + {file = "Cython-3.0.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0faa5e39e5c8cdf6f9c3b1c3f24972826e45911e7f5b99cf99453fca5432f45e"}, + {file = "Cython-3.0.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d53de996ed340e9ab0fc85a88aaa8932f2591a2746e1ab1c06e262bd4ec4be7"}, + {file = "Cython-3.0.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea3a0e19ab77266c738aa110684a753a04da4e709472cadeff487133354d6ab8"}, + {file = "Cython-3.0.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c151082884be468f2f405645858a857298ac7f7592729e5b54788b5c572717ba"}, + {file = "Cython-3.0.12-cp312-cp312-win32.whl", hash = "sha256:3083465749911ac3b2ce001b6bf17f404ac9dd35d8b08469d19dc7e717f5877a"}, + {file = "Cython-3.0.12-cp312-cp312-win_amd64.whl", hash = "sha256:c0b91c7ebace030dd558ea28730de8c580680b50768e5af66db2904a3716c3e3"}, + {file = "Cython-3.0.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4ee6f1ea1bead8e6cbc4e64571505b5d8dbdb3b58e679d31f3a84160cebf1a1a"}, + {file = "Cython-3.0.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57aefa6d3341109e46ec1a13e3a763aaa2cbeb14e82af2485b318194be1d9170"}, + {file = "Cython-3.0.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:879ae9023958d63c0675015369384642d0afb9c9d1f3473df9186c42f7a9d265"}, + {file = "Cython-3.0.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36fcd584dae547de6f095500a380f4a0cce72b7a7e409e9ff03cb9beed6ac7a1"}, + {file = "Cython-3.0.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62b79dcc0de49efe9e84b9d0e2ae0a6fc9b14691a65565da727aa2e2e63c6a28"}, + {file = "Cython-3.0.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4aa255781b093a8401109d8f2104bbb2e52de7639d5896aefafddc85c30e0894"}, + {file = "Cython-3.0.12-cp313-cp313-win32.whl", hash = "sha256:77d48f2d4bab9fe1236eb753d18f03e8b2619af5b6f05d51df0532a92dfb38ab"}, + {file = "Cython-3.0.12-cp313-cp313-win_amd64.whl", hash = "sha256:86c304b20bd57c727c7357e90d5ba1a2b6f1c45492de2373814d7745ef2e63b4"}, + {file = "Cython-3.0.12-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ff5c0b6a65b08117d0534941d404833d516dac422eee88c6b4fd55feb409a5ed"}, + {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680f1d6ed4436ae94805db264d6155ed076d2835d84f20dcb31a7a3ad7f8668c"}, + {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc24609613fa06d0d896309f7164ba168f7e8d71c1e490ed2a08d23351c3f41"}, + {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1879c073e2b34924ce9b7ca64c212705dcc416af4337c45f371242b2e5f6d32"}, + {file = "Cython-3.0.12-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:bfb75123dd4ff767baa37d7036da0de2dfb6781ff256eef69b11b88b9a0691d1"}, + {file = "Cython-3.0.12-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:f39640f8df0400cde6882e23c734f15bb8196de0a008ae5dc6c8d1ec5957d7c8"}, + {file = "Cython-3.0.12-cp36-cp36m-win32.whl", hash = "sha256:8c9efe9a0895abee3cadfdad4130b30f7b5e57f6e6a51ef2a44f9fc66a913880"}, + {file = "Cython-3.0.12-cp36-cp36m-win_amd64.whl", hash = "sha256:63d840f2975e44d74512f8f34f1f7cb8121c9428e26a3f6116ff273deb5e60a2"}, + {file = "Cython-3.0.12-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:75c5acd40b97cff16fadcf6901a91586cbca5dcdba81f738efaf1f4c6bc8dccb"}, + {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62564457851db1c40399bd95a5346b9bb99e17a819bf583b362f418d8f3457a"}, + {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ccd1228cc203b1f1b8a3d403f5a20ad1c40e5879b3fbf5851ce09d948982f2c"}, + {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25529ee948f44d9a165ff960c49d4903267c20b5edf2df79b45924802e4cca6e"}, + {file = "Cython-3.0.12-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:90cf599372c5a22120609f7d3a963f17814799335d56dd0dcf8fe615980a8ae1"}, + {file = "Cython-3.0.12-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9f8c48748a9c94ea5d59c26ab49ad0fad514d36f894985879cf3c3ca0e600bf4"}, + {file = "Cython-3.0.12-cp37-cp37m-win32.whl", hash = "sha256:3e4fa855d98bc7bd6a2049e0c7dc0dcf595e2e7f571a26e808f3efd84d2db374"}, + {file = "Cython-3.0.12-cp37-cp37m-win_amd64.whl", hash = "sha256:120681093772bf3600caddb296a65b352a0d3556e962b9b147efcfb8e8c9801b"}, + {file = "Cython-3.0.12-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:731d719423e041242c9303c80cae4327467299b90ffe62d4cc407e11e9ea3160"}, + {file = "Cython-3.0.12-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3238a29f37999e27494d120983eca90d14896b2887a0bd858a381204549137a"}, + {file = "Cython-3.0.12-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b588c0a089a9f4dd316d2f9275230bad4a7271e5af04e1dc41d2707c816be44b"}, + {file = "Cython-3.0.12-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab9f5198af74eb16502cc143cdde9ca1cbbf66ea2912e67440dd18a36e3b5fa"}, + {file = "Cython-3.0.12-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8ee841c0e114efa1e849c281ac9b8df8aa189af10b4a103b1c5fd71cbb799679"}, + {file = "Cython-3.0.12-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:43c48b5789398b228ea97499f5b864843ba9b1ab837562a9227c6f58d16ede8b"}, + {file = "Cython-3.0.12-cp38-cp38-win32.whl", hash = "sha256:5e5f17c48a4f41557fbcc7ee660ccfebe4536a34c557f553b6893c1b3c83df2d"}, + {file = "Cython-3.0.12-cp38-cp38-win_amd64.whl", hash = "sha256:309c081057930bb79dc9ea3061a1af5086c679c968206e9c9c2ec90ab7cb471a"}, + {file = "Cython-3.0.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54115fcc126840926ff3b53cfd2152eae17b3522ae7f74888f8a41413bd32f25"}, + {file = "Cython-3.0.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:629db614b9c364596d7c975fa3fb3978e8c5349524353dbe11429896a783fc1e"}, + {file = "Cython-3.0.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af081838b0f9e12a83ec4c3809a00a64c817f489f7c512b0e3ecaf5f90a2a816"}, + {file = "Cython-3.0.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ce459808f7d8d5d4007bc5486fe50532529096b43957af6cbffcb4d9cc5c8d"}, + {file = "Cython-3.0.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d6c6cd6a75c8393e6805d17f7126b96a894f310a1a9ea91c47d141fb9341bfa8"}, + {file = "Cython-3.0.12-cp39-cp39-win32.whl", hash = "sha256:a4032e48d4734d2df68235d21920c715c451ac9de15fa14c71b378e8986b83be"}, + {file = "Cython-3.0.12-cp39-cp39-win_amd64.whl", hash = "sha256:dcdc3e5d4ce0e7a4af6903ed580833015641e968d18d528d8371e2435a34132c"}, + {file = "Cython-3.0.12-py2.py3-none-any.whl", hash = "sha256:0038c9bae46c459669390e53a1ec115f8096b2e4647ae007ff1bf4e6dee92806"}, + {file = "cython-3.0.12.tar.gz", hash = "sha256:b988bb297ce76c671e28c97d017b95411010f7c77fa6623dd0bb47eed1aee1bc"}, ] [[package]] From 777c379ea9208ed662fdfd2f79f94e3bb138378b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 10:53:14 -0600 Subject: [PATCH 1223/1433] chore(pre-commit.ci): pre-commit autoupdate (#1532) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c4754d83..0c9667438 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.1.2 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy From d4e6f25754c15417b8bd9839dc8636b2cff717c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Feb 2025 22:33:00 -0600 Subject: [PATCH 1224/1433] fix: hold a strong reference to the AsyncEngine setup task (#1533) --- src/zeroconf/_engine.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 8c800a33a..8a371e1e2 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -50,6 +50,7 @@ class AsyncEngine: "_cleanup_timer", "_listen_socket", "_respond_sockets", + "_setup_task", "loop", "protocols", "readers", @@ -73,6 +74,7 @@ def __init__( self._listen_socket = listen_socket self._respond_sockets = respond_sockets self._cleanup_timer: asyncio.TimerHandle | None = None + self._setup_task: asyncio.Task[None] | None = None def setup( self, @@ -82,14 +84,15 @@ def setup( """Set up the instance.""" self.loop = loop self.running_future = loop.create_future() - self.loop.create_task(self._async_setup(loop_thread_ready)) + self._setup_task = self.loop.create_task(self._async_setup(loop_thread_ready)) async def _async_setup(self, loop_thread_ready: threading.Event | None) -> None: """Set up the instance.""" self._async_schedule_next_cache_cleanup() await self._async_create_endpoints() assert self.running_future is not None - self.running_future.set_result(True) + if not self.running_future.done(): + self.running_future.set_result(True) if loop_thread_ready: loop_thread_ready.set() @@ -135,6 +138,8 @@ def _async_schedule_next_cache_cleanup(self) -> None: async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" + assert self._setup_task is not None + await self._setup_task self._async_shutdown() await asyncio.sleep(0) # flush out any call soons assert self._cleanup_timer is not None From 91a58dd67d55835b1d74e5cd31ff7b0323805c63 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 18 Feb 2025 04:42:38 +0000 Subject: [PATCH 1225/1433] 0.145.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243772b09..e770a88f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.145.1 (2025-02-18) + +### Bug Fixes + +- Hold a strong reference to the AsyncEngine setup task + ([#1533](https://github.com/python-zeroconf/python-zeroconf/pull/1533), + [`d4e6f25`](https://github.com/python-zeroconf/python-zeroconf/commit/d4e6f25754c15417b8bd9839dc8636b2cff717c8)) + + ## v0.145.0 (2025-02-15) ### Features diff --git a/pyproject.toml b/pyproject.toml index 78b100d22..e6aa0efe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.145.0" +version = "0.145.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2cccd05d0..d2235d5cd 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.145.0" +__version__ = "0.145.1" __license__ = "LGPL" From 6c02b1fe6e1a6b86194e7e90e0039c058ff4f8e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:21:23 +0000 Subject: [PATCH 1226/1433] chore(pre-commit.ci): pre-commit autoupdate (#1534) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c9667438..f0d1bec6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.2.1 + rev: v4.2.2 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.6 + rev: v0.9.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From a06df79660093e7a59bc88c125c459aa7ec5df1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 23:21:18 -0600 Subject: [PATCH 1227/1433] chore(ci): bump python-semantic-release/python-semantic-release from 9.17.0 to 9.21.0 in the github-actions group (#1535) chore(ci): bump python-semantic-release/python-semantic-release Bumps the github-actions group with 1 update: [python-semantic-release/python-semantic-release](https://github.com/python-semantic-release/python-semantic-release). Updates `python-semantic-release/python-semantic-release` from 9.17.0 to 9.21.0 - [Release notes](https://github.com/python-semantic-release/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst) - [Commits](https://github.com/python-semantic-release/python-semantic-release/compare/v9.17.0...v9.21.0) --- updated-dependencies: - dependency-name: python-semantic-release/python-semantic-release dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578fe76ab..5a1a1720c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,14 +134,14 @@ jobs: # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@v9.17.0 + uses: python-semantic-release/python-semantic-release@v9.21.0 if: github.ref_name != 'master' with: root_options: --noop # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@v9.17.0 + uses: python-semantic-release/python-semantic-release@v9.21.0 id: release if: github.ref_name == 'master' with: From c2ac47e2df6d8ee92d51fc7a72d1d18f88e0132f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 22:21:43 -0700 Subject: [PATCH 1228/1433] chore(deps-dev): bump pytest from 8.3.4 to 8.3.5 (#1536) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9caaac173..6b66bcd11 100644 --- a/poetry.lock +++ b/poetry.lock @@ -647,13 +647,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.3.4" +version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [package.dependencies] From 558c0607fd1bf4a2ecc9bb5d84bcd8824c7fc922 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Mar 2025 22:22:02 -0700 Subject: [PATCH 1229/1433] chore(deps-dev): bump setuptools from 75.8.0 to 75.8.2 (#1537) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6b66bcd11..e0d488227 100644 --- a/poetry.lock +++ b/poetry.lock @@ -791,13 +791,13 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "75.8.0" +version = "75.8.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, - {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, + {file = "setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f"}, + {file = "setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2"}, ] [package.extras] From 25454648221ab659b48b58f7cfb2f6199fadea1c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:00:53 -1000 Subject: [PATCH 1230/1433] chore(pre-commit.ci): pre-commit autoupdate (#1538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/commitizen-tools/commitizen: v4.2.2 → v4.4.1](https://github.com/commitizen-tools/commitizen/compare/v4.2.2...v4.4.1) - [github.com/astral-sh/ruff-pre-commit: v0.9.7 → v0.9.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.7...v0.9.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0d1bec6c..265f703e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.2.2 + rev: v4.4.1 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 7d0768f7622e3af9e7cdb7335bb2ddbbe493b4bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 14:01:17 -1000 Subject: [PATCH 1231/1433] chore: update process to get relase tag from PSR in release workflow (#1539) fixes #1201 --- .github/workflows/ci.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1a1720c..457b4e1d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,7 @@ jobs: contents: write outputs: released: ${{ steps.release.outputs.released }} + newest_release_tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@v4 @@ -261,22 +262,13 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - name: Install python-semantic-release - run: pipx install python-semantic-release==7.34.6 - - - name: Get Release Tag - id: release_tag - shell: bash - run: | - echo "::set-output name=newest_release_tag::$(semantic-release print-version --current)" - - uses: actions/checkout@v4 with: - ref: "${{ steps.release_tag.outputs.newest_release_tag }}" + ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@v2.22.0 + uses: pypa/cibuildwheel@v2.23.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} From dea233c1e0e80584263090727ce07648755964af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 15:36:34 -1000 Subject: [PATCH 1232/1433] feat: reduce size of wheels (#1540) feat: reduce size of binaries --- build_ext.py | 57 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/build_ext.py b/build_ext.py index e91f6350f..7faa607f8 100644 --- a/build_ext.py +++ b/build_ext.py @@ -5,8 +5,44 @@ from distutils.command.build_ext import build_ext from typing import Any +try: + from setuptools import Extension +except ImportError: + from distutils.core import Extension + _LOGGER = logging.getLogger(__name__) +TO_CYTHONIZE = [ + "src/zeroconf/_dns.py", + "src/zeroconf/_cache.py", + "src/zeroconf/_history.py", + "src/zeroconf/_record_update.py", + "src/zeroconf/_listener.py", + "src/zeroconf/_protocol/incoming.py", + "src/zeroconf/_protocol/outgoing.py", + "src/zeroconf/_handlers/answers.py", + "src/zeroconf/_handlers/record_manager.py", + "src/zeroconf/_handlers/multicast_outgoing_queue.py", + "src/zeroconf/_handlers/query_handler.py", + "src/zeroconf/_services/__init__.py", + "src/zeroconf/_services/browser.py", + "src/zeroconf/_services/info.py", + "src/zeroconf/_services/registry.py", + "src/zeroconf/_updates.py", + "src/zeroconf/_utils/ipaddress.py", + "src/zeroconf/_utils/time.py", +] + +EXTENSIONS = [ + Extension( + ext.removeprefix("src/").removesuffix(".py").replace("/", "."), + [ext], + language="c", + extra_compile_args=["-O3", "-g0"], + ) + for ext in TO_CYTHONIZE +] + class BuildExt(build_ext): def build_extensions(self) -> None: @@ -25,26 +61,7 @@ def build(setup_kwargs: Any) -> None: setup_kwargs.update( { "ext_modules": cythonize( - [ - "src/zeroconf/_dns.py", - "src/zeroconf/_cache.py", - "src/zeroconf/_history.py", - "src/zeroconf/_record_update.py", - "src/zeroconf/_listener.py", - "src/zeroconf/_protocol/incoming.py", - "src/zeroconf/_protocol/outgoing.py", - "src/zeroconf/_handlers/answers.py", - "src/zeroconf/_handlers/record_manager.py", - "src/zeroconf/_handlers/multicast_outgoing_queue.py", - "src/zeroconf/_handlers/query_handler.py", - "src/zeroconf/_services/__init__.py", - "src/zeroconf/_services/browser.py", - "src/zeroconf/_services/info.py", - "src/zeroconf/_services/registry.py", - "src/zeroconf/_updates.py", - "src/zeroconf/_utils/ipaddress.py", - "src/zeroconf/_utils/time.py", - ], + EXTENSIONS, compiler_directives={"language_level": "3"}, # Python 3 ), "cmdclass": {"build_ext": BuildExt}, From c9ef9ee527767d3e2fae0dd0d7df1b5ed156ea26 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 5 Mar 2025 01:44:48 +0000 Subject: [PATCH 1233/1433] 0.146.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e770a88f3..580dffb01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ # CHANGELOG +## v0.146.0 (2025-03-05) + +### Features + +- Reduce size of wheels ([#1540](https://github.com/python-zeroconf/python-zeroconf/pull/1540), + [`dea233c`](https://github.com/python-zeroconf/python-zeroconf/commit/dea233c1e0e80584263090727ce07648755964af)) + +feat: reduce size of binaries + + ## v0.145.1 (2025-02-18) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index e6aa0efe5..2b94783e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.145.1" +version = "0.146.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index d2235d5cd..68e7213d6 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.145.1" +__version__ = "0.146.0" __license__ = "LGPL" From fa65cc8791a6f4c53bc29088cb60b83f420b1ae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:06:33 -1000 Subject: [PATCH 1234/1433] fix: use trusted publishing for uploading wheels (#1541) --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 457b4e1d2..2fb9b06fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,19 +284,19 @@ jobs: needs: [build_wheels] runs-on: ubuntu-latest environment: release + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/download-artifact@v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir - pattern: wheels-* path: dist + pattern: wheels-* merge-multiple: true - - uses: pypa/gh-action-pypi-publish@v1.12.4 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} + - uses: + pypa/gh-action-pypi-publish@v1.12.4 # To test: repository_url: https://test.pypi.org/legacy/ From ea6905b1a0e6122e1baa0cbb39db1cb91ec0f310 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 5 Mar 2025 03:16:07 +0000 Subject: [PATCH 1235/1433] 0.146.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 580dffb01..f28b00223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.146.1 (2025-03-05) + +### Bug Fixes + +- Use trusted publishing for uploading wheels + ([#1541](https://github.com/python-zeroconf/python-zeroconf/pull/1541), + [`fa65cc8`](https://github.com/python-zeroconf/python-zeroconf/commit/fa65cc8791a6f4c53bc29088cb60b83f420b1ae6)) + + ## v0.146.0 (2025-03-05) ### Features diff --git a/pyproject.toml b/pyproject.toml index 2b94783e9..4e056cd79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.0" +version = "0.146.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 68e7213d6..b915b8d72 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.0" +__version__ = "0.146.1" __license__ = "LGPL" From 080462ed7bfd49311010a3c06d600e77bcc5fb8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Mar 2025 19:08:36 -1000 Subject: [PATCH 1236/1433] chore(deps-dev): bump setuptools from 75.8.2 to 76.0.0 (#1543) --- poetry.lock | 83 ++++++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 2 +- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index e0d488227..75863563c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -6,6 +6,7 @@ version = "0.7.16" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, @@ -17,6 +18,7 @@ version = "2.16.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, @@ -31,6 +33,7 @@ version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, @@ -42,6 +45,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -121,6 +125,7 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -222,6 +227,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev", "docs"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -233,6 +240,7 @@ version = "7.6.10" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, @@ -302,7 +310,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" @@ -310,6 +318,7 @@ version = "3.0.12" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["dev"] files = [ {file = "Cython-3.0.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba67eee9413b66dd9fbacd33f0bc2e028a2a120991d77b5fd4b19d0b1e4039b9"}, {file = "Cython-3.0.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee2717e5b5f7d966d0c6e27d2efe3698c357aa4d61bb3201997c7a4f9fe485a"}, @@ -383,6 +392,7 @@ version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, @@ -394,6 +404,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -408,6 +420,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -422,6 +435,7 @@ version = "0.2.0" description = "Cross-platform network interface and IP address enumeration library" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748"}, {file = "ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"}, @@ -433,6 +447,7 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -444,6 +459,8 @@ version = "8.5.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] +markers = "python_version < \"3.10\"" files = [ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, @@ -453,12 +470,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -467,6 +484,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -478,6 +496,7 @@ version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, @@ -495,6 +514,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -519,6 +539,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -589,6 +610,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -600,6 +622,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -611,6 +634,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -626,6 +650,7 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -637,6 +662,7 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -651,6 +677,7 @@ version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, @@ -673,6 +700,7 @@ version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, @@ -691,6 +719,7 @@ version = "3.2.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34"}, {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c"}, @@ -723,6 +752,7 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -741,6 +771,7 @@ version = "2.3.1" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, @@ -755,6 +786,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -776,6 +808,7 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -791,23 +824,24 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "75.8.2" +version = "76.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f"}, - {file = "setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2"}, + {file = "setuptools-76.0.0-py3-none-any.whl", hash = "sha256:199466a166ff664970d0ee145839f5582cb9bca7a0a3a2e795b6a9cb2308e9c6"}, + {file = "setuptools-76.0.0.tar.gz", hash = "sha256:43b4ee60e10b0d0ee98ad11918e114c70701bc6051662a9a675a0496c1a158f4"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] -core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "snowballstemmer" @@ -815,6 +849,7 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -826,6 +861,7 @@ version = "7.4.7" description = "Python documentation generator" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, @@ -862,6 +898,7 @@ version = "3.0.2" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, @@ -881,6 +918,7 @@ version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, @@ -897,6 +935,7 @@ version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, @@ -913,6 +952,7 @@ version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, @@ -929,6 +969,7 @@ version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" optional = false python-versions = ">=2.7" +groups = ["docs"] files = [ {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, @@ -943,6 +984,7 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -957,6 +999,7 @@ version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, @@ -973,6 +1016,7 @@ version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, @@ -989,6 +1033,7 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev", "docs"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1023,6 +1068,7 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version < \"3.11\""} [[package]] name = "typing-extensions" @@ -1030,6 +1076,8 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1041,13 +1089,14 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["docs"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1058,20 +1107,22 @@ version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["dev", "docs"] +markers = "python_version < \"3.10\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.9" -content-hash = "ea903296f015035c594eb8cce08d4dedc716074e33644033938dfdb5f047d72e" +content-hash = "f866b539caf6f0140faba8aa19f4e1fae2013a48fc3346747f104dfe62ef290b" diff --git a/pyproject.toml b/pyproject.toml index 4e056cd79..9d38dc557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.26.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<76.0.0" +setuptools = ">=65.6.3,<77.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" From 89e3cbd4ad7ceab07925bfeb8814d3d1163d810f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 06:47:31 -1000 Subject: [PATCH 1237/1433] chore(pre-commit.ci): pre-commit autoupdate (#1544) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 265f703e7..633a2c35b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.9 + rev: v0.9.10 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 019c641dc22c1fb30f7764525ab9777eaa98b388 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 12:40:28 -1000 Subject: [PATCH 1238/1433] chore: upgrade to ruff 0.1.0 (#1547) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 9 +--- src/zeroconf/_services/browser.py | 2 +- tests/__init__.py | 1 - tests/benchmarks/test_cache.py | 1 - tests/benchmarks/test_incoming.py | 1 - tests/benchmarks/test_outgoing.py | 1 - tests/benchmarks/test_send.py | 3 +- tests/benchmarks/test_txt_properties.py | 1 - tests/conftest.py | 5 +-- tests/services/test_browser.py | 19 ++++---- tests/services/test_info.py | 33 +++++++------- tests/test_asyncio.py | 59 ++++++++++++------------- tests/test_cache.py | 7 ++- tests/test_circular_imports.py | 2 +- tests/test_core.py | 7 ++- tests/test_dns.py | 1 - tests/test_engine.py | 5 +-- tests/test_handlers.py | 33 +++++++------- tests/test_protocol.py | 1 - tests/test_services.py | 1 - tests/test_updates.py | 1 - tests/utils/test_asyncio.py | 9 ++-- tests/utils/test_name.py | 1 - tests/utils/test_net.py | 1 - 25 files changed, 89 insertions(+), 117 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 633a2c35b..a38eaca6f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.1.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index 9d38dc557..9c92f3621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,22 +94,18 @@ ignore = [ "S101", # use of assert "S104", # S104 Possible binding to all interfaces "PLR0912", # too many to fix right now - "TC001", # too many to fix right now "TID252", # skip "PLR0913", # too late to make changes here "PLR0911", # would be breaking change "TRY003", # too many to fix "SLF001", # design choice - "TC003", # too many to fix "PLR2004" , # too many to fix "PGH004", # too many to fix "PGH003", # too many to fix "SIM110", # this is slower - "FURB136", # this is slower for Cython "PYI034", # enable when we drop Py3.10 "PYI032", # breaks Cython "PYI041", # breaks Cython - "FURB188", # usually slower "PERF401", # Cython: closures inside cpdef functions not yet supported ] select = [ @@ -124,7 +120,6 @@ select = [ "I", # isort "RUF", # ruff specific "FLY", # flynt - "FURB", # refurb "G", # flake8-logging-format , "PERF", # Perflint "PGH", # pygrep-hooks @@ -140,7 +135,6 @@ select = [ "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print - "TC", # flake8-type-checking "TID", # Tidy imports "TRY", # tryceratops ] @@ -171,9 +165,8 @@ select = [ "PLR0913", # skip this one "SIM102" , # too many to fix right now "SIM108", # too many to fix right now - "TC003", # too many to fix right now - "TC002", # too many to fix right now "T201", # too many to fix right now + "PT004", # nice to have ] "bench/**/*" = [ "T201", # intended diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index ab8c050d9..6bf3f0f47 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -278,7 +278,7 @@ def generate_service_query( if not qu_question and question_history.suppresses(question, now_millis, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue - if TYPE_CHECKING: + if TYPE_CHECKING: # noqa: SIM108 pointer_known_answers = cast(set[DNSPointer], known_answers) else: pointer_known_answers = known_answers diff --git a/tests/__init__.py b/tests/__init__.py index a70cca600..3df098191 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,7 +29,6 @@ from unittest import mock import ifaddr - from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf from zeroconf._history import QuestionHistory diff --git a/tests/benchmarks/test_cache.py b/tests/benchmarks/test_cache.py index 7813f6798..e32abda07 100644 --- a/tests/benchmarks/test_cache.py +++ b/tests/benchmarks/test_cache.py @@ -1,7 +1,6 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture - from zeroconf import DNSCache, DNSPointer, current_time_millis from zeroconf.const import _CLASS_IN, _TYPE_PTR diff --git a/tests/benchmarks/test_incoming.py b/tests/benchmarks/test_incoming.py index 6d31e51e5..672e5c783 100644 --- a/tests/benchmarks/test_incoming.py +++ b/tests/benchmarks/test_incoming.py @@ -5,7 +5,6 @@ import socket from pytest_codspeed import BenchmarkFixture - from zeroconf import ( DNSAddress, DNSIncoming, diff --git a/tests/benchmarks/test_outgoing.py b/tests/benchmarks/test_outgoing.py index a8db4d6f8..cc2f3f421 100644 --- a/tests/benchmarks/test_outgoing.py +++ b/tests/benchmarks/test_outgoing.py @@ -3,7 +3,6 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture - from zeroconf._protocol.outgoing import State from .helpers import generate_packets diff --git a/tests/benchmarks/test_send.py b/tests/benchmarks/test_send.py index 596662a2b..d931b48ba 100644 --- a/tests/benchmarks/test_send.py +++ b/tests/benchmarks/test_send.py @@ -4,13 +4,12 @@ import pytest from pytest_codspeed import BenchmarkFixture - from zeroconf.asyncio import AsyncZeroconf from .helpers import generate_packets -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_sending_packets(benchmark: BenchmarkFixture) -> None: """Benchmark sending packets.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/benchmarks/test_txt_properties.py b/tests/benchmarks/test_txt_properties.py index 72afa0b65..b7b0e7675 100644 --- a/tests/benchmarks/test_txt_properties.py +++ b/tests/benchmarks/test_txt_properties.py @@ -1,7 +1,6 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture - from zeroconf import ServiceInfo info = ServiceInfo( diff --git a/tests/conftest.py b/tests/conftest.py index 531c810be..3d891ec46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest - from zeroconf import _core, const from zeroconf._handlers import query_handler @@ -20,7 +19,7 @@ def verify_threads_ended(): assert not threads -@pytest.fixture +@pytest.fixture() def run_isolated(): """Change the mDNS port to run the test in isolation.""" with ( @@ -31,7 +30,7 @@ def run_isolated(): yield -@pytest.fixture +@pytest.fixture() def disable_duplicate_packet_suppression(): """Disable duplicate packet suppress. diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 986df64eb..f5237365e 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -14,7 +14,6 @@ from unittest.mock import patch import pytest - import zeroconf as r import zeroconf._services.browser as _services_browser from zeroconf import ( @@ -556,7 +555,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_asking_default_is_asking_qm_questions_after_the_first_qu(): """Verify the service browser's first questions are QU and refresh queries are QM.""" service_added = asyncio.Event() @@ -658,7 +657,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_ttl_refresh_cancelled_rescue_query(): """Verify seeing a name again cancels the rescue query.""" service_added = asyncio.Event() @@ -768,7 +767,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_asking_qm_questions(): """Verify explicitly asking QM questions.""" type_ = "_quservice._tcp.local." @@ -807,7 +806,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_asking_qu_questions(): """Verify the service browser can ask QU questions.""" type_ = "_quservice._tcp.local." @@ -1139,7 +1138,7 @@ def test_group_ptr_queries_with_known_answers(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_generate_service_query_suppress_duplicate_questions(): """Generate a service query for sending with zeroconf.send.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1192,7 +1191,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_query_scheduler(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} @@ -1285,7 +1284,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_query_scheduler_rescue_records(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} @@ -1580,7 +1579,7 @@ def test_scheduled_ptr_query_dunder_methods(): assert query75 >= other # type: ignore[operator] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_close_zeroconf_without_browser_before_start_up_queries(): """Test that we stop sending startup queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() @@ -1648,7 +1647,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await browser.async_cancel() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_close_zeroconf_without_browser_after_start_up_queries(): """Test that we stop sending rescue queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 3d4c53028..8b912bea0 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -14,7 +14,6 @@ from unittest.mock import patch import pytest - import zeroconf as r from zeroconf import DNSAddress, RecordUpdate, const from zeroconf._services import info @@ -828,7 +827,7 @@ def test_scoped_addresses_from_cache(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_multiple_a_addresses_newest_address_first(): """Test that info.addresses returns the newest seen address first.""" type_ = "_http._tcp.local." @@ -848,7 +847,7 @@ async def test_multiple_a_addresses_newest_address_first(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_invalid_a_addresses(caplog): type_ = "_http._tcp.local." registration_name = f"multiarec.{type_}" @@ -1057,7 +1056,7 @@ def test_request_timeout(): assert (end_time - start_time) < 3000 + 1000 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_we_try_four_times_with_random_delay(): """Verify we try four times even with the random delay.""" type_ = "_typethatisnothere._tcp.local." @@ -1080,7 +1079,7 @@ def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): assert request_count == 4 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_release_wait_when_new_recorded_added(): """Test that async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." @@ -1145,7 +1144,7 @@ async def test_release_wait_when_new_recorded_added(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_port_changes_are_seen(): """Test that port changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1228,7 +1227,7 @@ async def test_port_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_port_changes_are_seen_with_directed_request(): """Test that port changes are seen by async_request with a directed request.""" type_ = "_http._tcp.local." @@ -1311,7 +1310,7 @@ async def test_port_changes_are_seen_with_directed_request(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_ipv4_changes_are_seen(): """Test that ipv4 changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1399,7 +1398,7 @@ async def test_ipv4_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_ipv6_changes_are_seen(): """Test that ipv6 changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1494,7 +1493,7 @@ async def test_ipv6_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_bad_ip_addresses_ignored_in_cache(): """Test that bad ip address in the cache are ignored async_request.""" type_ = "_http._tcp.local." @@ -1548,7 +1547,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_name_change_as_seen_has_ip_in_cache(): """Test that service name changes are seen by async_request when the ip is in the cache.""" type_ = "_http._tcp.local." @@ -1630,7 +1629,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_name_change_as_seen_ip_not_in_cache(): """Test that service name changes are seen by async_request when the ip is not in the cache.""" type_ = "_http._tcp.local." @@ -1712,7 +1711,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() @patch.object(info, "_LISTENER_TIME", 10000000) async def test_release_wait_when_new_recorded_added_concurrency(): """Test that concurrent async_request returns as soon as new matching records are added to the cache.""" @@ -1784,7 +1783,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_info_nsec_records(): """Test we can generate nsec records from ServiceInfo.""" type_ = "_http._tcp.local." @@ -1799,7 +1798,7 @@ async def test_service_info_nsec_records(): assert nsec_record.rdtypes == [const._TYPE_A, const._TYPE_AAAA] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_address_resolver(): """Test that the address resolver works.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1823,7 +1822,7 @@ async def test_address_resolver(): assert resolver.addresses == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_address_resolver_ipv4(): """Test that the IPv4 address resolver works.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1847,7 +1846,7 @@ async def test_address_resolver_ipv4(): assert resolver.addresses == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio +@pytest.mark.asyncio() @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") async def test_address_resolver_ipv6(): diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 40ecf8162..e31025075 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -11,7 +11,6 @@ from unittest.mock import ANY, call, patch import pytest - import zeroconf._services.browser as _services_browser from zeroconf import ( DNSAddress, @@ -79,14 +78,14 @@ def verify_threads_ended(): assert not threads -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_close_twice() -> None: """Test we can close twice.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -94,7 +93,7 @@ async def test_async_close_twice() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_with_sync_passed_in() -> None: """Test we can create and close the instance when passing in a sync Zeroconf.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -103,7 +102,7 @@ async def test_async_with_sync_passed_in() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_with_sync_passed_in_closed_in_async() -> None: """Test caller closes the sync version in async.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -113,7 +112,7 @@ async def test_async_with_sync_passed_in_closed_in_async() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_sync_within_event_loop_executor() -> None: """Test sync version still works from an executor within an event loop.""" @@ -125,7 +124,7 @@ def sync_code(): await asyncio.get_event_loop().run_in_executor(None, sync_code) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -192,7 +191,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_with_server_missing() -> None: """Test registering a service with the server not specified. @@ -259,7 +258,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_same_server_different_ports() -> None: """Test registering services with the same server with different srv records.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -326,7 +325,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_same_server_same_ports() -> None: """Test registering services with the same server with the exact same srv record.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -393,7 +392,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_name_conflict() -> None: """Test registering services throws on name conflict.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -441,7 +440,7 @@ async def test_async_service_registration_name_conflict() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_name_does_not_match_type() -> None: """Test registering services throws when the name does not match the type.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -467,7 +466,7 @@ async def test_async_service_registration_name_does_not_match_type() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_registration_name_strict_check() -> None: """Test registering services throws when the name does not comply.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -502,7 +501,7 @@ async def test_async_service_registration_name_strict_check() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_tasks() -> None: """Test awaiting broadcast tasks""" @@ -568,7 +567,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_wait_unblocks_on_update() -> None: """Test async_wait will unblock on update.""" @@ -604,7 +603,7 @@ async def test_async_wait_unblocks_on_update() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_info_async_request() -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" if not has_working_ipv6() or os.environ.get("SKIP_IPV6"): @@ -713,7 +712,7 @@ async def test_service_info_async_request() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_service_browser() -> None: """Test AsyncServiceBrowser.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -773,7 +772,7 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_context_manager() -> None: """Test using an async context manager.""" type_ = "_test10-sr-type._tcp.local." @@ -797,7 +796,7 @@ async def test_async_context_manager() -> None: assert aiosinfo is not None -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_browser_cancel_async_context_manager(): """Test we can cancel an AsyncServiceBrowser with it being used as an async context manager.""" @@ -823,7 +822,7 @@ class MyServiceListener(ServiceListener): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_unregister_all_services() -> None: """Test unregistering all services.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -882,7 +881,7 @@ async def test_async_unregister_all_services() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_zeroconf_service_types(): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" @@ -916,7 +915,7 @@ async def test_async_zeroconf_service_types(): await zeroconf_registrar.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_guard_against_running_serviceinfo_request_event_loop() -> None: """Test that running ServiceInfo.request from the event loop throws.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -927,7 +926,7 @@ async def test_guard_against_running_serviceinfo_request_event_loop() -> None: await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_browser_instantiation_generates_add_events_from_cache(): """Test that the ServiceBrowser will generate Add events with the existing cache when starting.""" @@ -976,7 +975,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_integration(): service_added = asyncio.Event() service_removed = asyncio.Event() @@ -1124,7 +1123,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): """Verify the service info first question is QU and subsequent ones are QM questions.""" type_ = "_quservice._tcp.local." @@ -1178,7 +1177,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_service_browser_ignores_unrelated_updates(): """Test that the ServiceBrowser ignores unrelated updates.""" @@ -1275,7 +1274,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1289,7 +1288,7 @@ async def test_async_request_timeout(): assert (end_time - start_time) < 3000 + 1000 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_request_non_running_instance(): """Test that the async_request throws when zeroconf is not running.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1298,7 +1297,7 @@ async def test_async_request_non_running_instance(): await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_legacy_unicast_response(run_isolated): """Verify legacy unicast responses include questions and correct id.""" type_ = "_mservice._tcp.local." @@ -1339,7 +1338,7 @@ async def test_legacy_unicast_response(run_isolated): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_update_with_uppercase_names(run_isolated): """Test an ip update from a shelly which uses uppercase names.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_cache.py b/tests/test_cache.py index 9d55435d5..5bd6a8695 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,7 +7,6 @@ from heapq import heapify, heappop import pytest - import zeroconf as r from zeroconf import const @@ -364,7 +363,7 @@ def test_async_get_unique_returns_newest_record(): assert record is record2 -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_cache_heap_cleanup() -> None: """Test that the heap gets cleaned up when there are many old expirations.""" cache = r.DNSCache() @@ -416,7 +415,7 @@ async def test_cache_heap_cleanup() -> None: assert not cache.async_entries_with_name(name), cache._expire_heap -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_cache_heap_multi_name_cleanup() -> None: """Test cleanup with multiple names.""" cache = r.DNSCache() @@ -452,7 +451,7 @@ async def test_cache_heap_multi_name_cleanup() -> None: assert not cache.async_entries_with_name(name), cache._expire_heap -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_cache_heap_pops_order() -> None: """Test cache heap is popped in order.""" cache = r.DNSCache() diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index 74ed1f124..79d58ae17 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -8,7 +8,7 @@ import pytest -@pytest.mark.asyncio +@pytest.mark.asyncio() @pytest.mark.timeout(30) # cloud can take > 9s @pytest.mark.parametrize( "module", diff --git a/tests/test_core.py b/tests/test_core.py index fcfdf4249..1dfb98066 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,7 +15,6 @@ from unittest.mock import AsyncMock, Mock, patch import pytest - import zeroconf as r from zeroconf import NotRunningException, Zeroconf, const, current_time_millis from zeroconf._listener import AsyncListener, _WrappedTransport @@ -665,7 +664,7 @@ def test_tc_bit_defers_last_response_missing(): zc.close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_open_close_twice_from_async() -> None: """Test we can close twice from a coroutine when using Zeroconf. @@ -685,7 +684,7 @@ async def test_open_close_twice_from_async() -> None: await asyncio.sleep(0) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_multiple_sync_instances_stared_from_async_close(): """Test we can shutdown multiple sync instances from async.""" @@ -741,7 +740,7 @@ def _background_register(): bgthread.join() -@pytest.mark.asyncio +@pytest.mark.asyncio() @patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): diff --git a/tests/test_dns.py b/tests/test_dns.py index 246c8dcfb..5928338cb 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -8,7 +8,6 @@ import unittest.mock import pytest - import zeroconf as r from zeroconf import DNSHinfo, DNSText, ServiceInfo, const, current_time_millis from zeroconf._dns import DNSRRSet diff --git a/tests/test_engine.py b/tests/test_engine.py index b7a94c866..5f2448047 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,7 +8,6 @@ from unittest.mock import patch import pytest - import zeroconf as r from zeroconf import _engine, const from zeroconf.asyncio import AsyncZeroconf @@ -30,7 +29,7 @@ def teardown_module(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_reaper(): with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -65,7 +64,7 @@ async def test_reaper(): assert record_with_1s_ttl not in entries -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_reaper_aborts_when_done(): """Ensure cache cleanup stops when zeroconf is done.""" with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ffa4ff88c..58f8ecb1a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -13,7 +13,6 @@ from unittest.mock import patch import pytest - import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, const, current_time_millis from zeroconf._handlers.multicast_outgoing_queue import ( @@ -493,7 +492,7 @@ def test_unicast_response(): zc.close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_probe_answered_immediately(): """Verify probes are responded to immediately.""" # instantiate a zeroconf instance @@ -544,7 +543,7 @@ async def test_probe_answered_immediately(): zc.close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_probe_answered_immediately_with_uppercase_name(): """Verify probes are responded to immediately with an uppercase name.""" # instantiate a zeroconf instance @@ -1092,7 +1091,7 @@ def test_enumeration_query_with_no_registered_services(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_qu_response_only_sends_additionals_if_sends_answer(): """Test that a QU response does not send additionals unless it sends the answer as well.""" # instantiate a zeroconf instance @@ -1258,7 +1257,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_cache_flush_bit(): """Test that the cache flush bit sets the TTL to one for matching records.""" # instantiate a zeroconf instance @@ -1361,7 +1360,7 @@ async def test_cache_flush_bit(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_record_update_manager_add_listener_callsback_existing_records(): """Test that the RecordUpdateManager will callback existing records.""" @@ -1415,7 +1414,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_questions_query_handler_populates_the_question_history_from_qm_questions(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf @@ -1461,7 +1460,7 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_questions_query_handler_does_not_put_qu_questions_in_history(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf @@ -1504,7 +1503,7 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_guard_against_low_ptr_ttl(): """Ensure we enforce a min for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. @@ -1555,7 +1554,7 @@ async def test_guard_against_low_ptr_ttl(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_duplicate_goodbye_answers_in_packet(): """Ensure we do not throw an exception when there are duplicate goodbye records in a packet.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1587,7 +1586,7 @@ async def test_duplicate_goodbye_answers_in_packet(): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_response_aggregation_timings(run_isolated): """Verify multicast responses are aggregated.""" type_ = "_mservice._tcp.local." @@ -1709,7 +1708,7 @@ async def test_response_aggregation_timings(run_isolated): await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): """Verify multicast responses that are aggregated do not take longer than 620ms to send. @@ -1791,7 +1790,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert info2.dns_pointer() in incoming.answers() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_response_aggregation_random_delay(): """Verify the random delay for outgoing multicast will coalesce into a single group @@ -1899,7 +1898,7 @@ async def test_response_aggregation_random_delay(): assert info5.dns_pointer() in outgoing_queue.queue[1].answers -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_future_answers_are_removed_on_send(): """Verify any future answers scheduled to be sent are removed when we send.""" type_ = "_mservice._tcp.local." @@ -1963,7 +1962,7 @@ async def test_future_answers_are_removed_on_send(): assert info2.dns_pointer() in outgoing_queue.queue[0].answers -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_add_listener_warns_when_not_using_record_update_listener(caplog): """Log when a listener is added that is not using RecordUpdateListener as a base class.""" @@ -1988,7 +1987,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_updates_iteration_safe(): """Ensure we can safely iterate over the async_updates.""" @@ -2032,7 +2031,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_updates_complete_iteration_safe(): """Ensure we can safely iterate over the async_updates_complete.""" diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 08d7e600a..78fed0e03 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -11,7 +11,6 @@ from typing import cast import pytest - import zeroconf as r from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis diff --git a/tests/test_services.py b/tests/test_services.py index 7d7c3fc7d..d192c6529 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -11,7 +11,6 @@ from typing import Any import pytest - import zeroconf as r from zeroconf import Zeroconf from zeroconf._services.info import ServiceInfo diff --git a/tests/test_updates.py b/tests/test_updates.py index a057486cc..376082e73 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -7,7 +7,6 @@ import time import pytest - import zeroconf as r from zeroconf import Zeroconf, const from zeroconf._record_update import RecordUpdate diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 7989a82cf..4d2ee0ec6 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -10,14 +10,13 @@ from unittest.mock import patch import pytest - from zeroconf import EventLoopBlocked from zeroconf._engine import _CLOSE_TIMEOUT from zeroconf._utils import asyncio as aioutils from zeroconf.const import _LOADED_SYSTEM_TIMEOUT -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_async_get_all_tasks() -> None: """Test we can get all tasks in the event loop. @@ -33,7 +32,7 @@ async def test_async_get_all_tasks() -> None: await aioutils._async_get_all_tasks(loop) -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_get_running_loop_from_async() -> None: """Test we can get the event loop.""" assert isinstance(aioutils.get_running_loop(), asyncio.AbstractEventLoop) @@ -44,7 +43,7 @@ def test_get_running_loop_no_loop() -> None: assert aioutils.get_running_loop() is None -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_wait_future_or_timeout_times_out() -> None: """Test wait_future_or_timeout will timeout.""" loop = asyncio.get_running_loop() @@ -118,7 +117,7 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT -@pytest.mark.asyncio +@pytest.mark.asyncio() async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 1feb77131..3b70c7d40 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -5,7 +5,6 @@ import socket import pytest - from zeroconf import BadTypeInNameException from zeroconf._services.info import ServiceInfo, instance_name_from_service_info from zeroconf._utils import name as nameutils diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index f763b655c..17ff61969 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -10,7 +10,6 @@ import ifaddr import pytest - import zeroconf as r from zeroconf._utils import net as netutils From 806e3678c0a6552f9b2f43d38eb673d509006d51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 13:11:53 -1000 Subject: [PATCH 1239/1433] chore: update deps (#1548) dependabot is still having issues so lets do this manually for now - Updating certifi (2024.12.14 -> 2025.1.31) - Updating jinja2 (3.1.5 -> 3.1.6) - Updating babel (2.16.0 -> 2.17.0) - Updating coverage (7.6.10 -> 7.6.12) - Updating pytest (8.3.4 -> 8.3.5) - Updating cython (3.0.11 -> 3.0.12) - Updating setuptools (75.8.0 -> 76.0.0) --- poetry.lock | 177 ++++++++++++++++++++++++++-------------------------- 1 file changed, 89 insertions(+), 88 deletions(-) diff --git a/poetry.lock b/poetry.lock index 75863563c..8c4713e8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -14,29 +14,29 @@ files = [ [[package]] name = "babel" -version = "2.16.0" +version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, - {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] [package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["docs"] files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] [[package]] @@ -236,81 +236,82 @@ files = [ [[package]] name = "coverage" -version = "7.6.10" +version = "7.6.12" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, - {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"}, - {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"}, - {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"}, - {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"}, - {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"}, - {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"}, - {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"}, - {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"}, - {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"}, - {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"}, - {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"}, - {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"}, - {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"}, - {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"}, - {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"}, - {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"}, - {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"}, - {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"}, - {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"}, - {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"}, - {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"}, - {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"}, - {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"}, - {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"}, - {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, + {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, + {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, + {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, + {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, + {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, + {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, + {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, + {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, + {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, + {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, + {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, + {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, + {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, + {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, + {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "cython" @@ -455,27 +456,27 @@ files = [ [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.6.1" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev", "docs"] markers = "python_version < \"3.10\"" files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, + {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, + {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, ] [package.dependencies] zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -492,14 +493,14 @@ files = [ [[package]] name = "jinja2" -version = "3.1.5" +version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ - {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, - {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] @@ -835,13 +836,13 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "snowballstemmer" @@ -1096,7 +1097,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1115,11 +1116,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] From 5915e5b417be7443e98e869f4fc9ba1ae68414d8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 11:01:55 -1000 Subject: [PATCH 1240/1433] chore(pre-commit.ci): pre-commit autoupdate (#1549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(pre-commit.ci): pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.0 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.0...v0.11.0) * chore(pre-commit.ci): auto fixes * chore: fix violations --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- build_ext.py | 2 +- src/zeroconf/_services/browser.py | 2 +- tests/__init__.py | 1 + tests/benchmarks/test_cache.py | 1 + tests/benchmarks/test_incoming.py | 1 + tests/benchmarks/test_outgoing.py | 1 + tests/benchmarks/test_send.py | 3 +- tests/benchmarks/test_txt_properties.py | 1 + tests/conftest.py | 5 ++- tests/services/test_browser.py | 21 ++++----- tests/services/test_info.py | 33 +++++++------- tests/test_asyncio.py | 59 +++++++++++++------------ tests/test_cache.py | 7 +-- tests/test_circular_imports.py | 2 +- tests/test_core.py | 7 +-- tests/test_dns.py | 1 + tests/test_engine.py | 5 ++- tests/test_handlers.py | 33 +++++++------- tests/test_protocol.py | 1 + tests/test_services.py | 1 + tests/test_updates.py | 3 +- tests/utils/test_asyncio.py | 9 ++-- tests/utils/test_name.py | 1 + tests/utils/test_net.py | 1 + 25 files changed, 112 insertions(+), 91 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a38eaca6f..5d03fcde4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.0 + rev: v0.11.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/build_ext.py b/build_ext.py index 7faa607f8..ff088f830 100644 --- a/build_ext.py +++ b/build_ext.py @@ -53,7 +53,7 @@ def build_extensions(self) -> None: def build(setup_kwargs: Any) -> None: - if os.environ.get("SKIP_CYTHON", False): + if os.environ.get("SKIP_CYTHON"): return try: from Cython.Build import cythonize diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 6bf3f0f47..ab8c050d9 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -278,7 +278,7 @@ def generate_service_query( if not qu_question and question_history.suppresses(question, now_millis, known_answers): log.debug("Asking %s was suppressed by the question history", question) continue - if TYPE_CHECKING: # noqa: SIM108 + if TYPE_CHECKING: pointer_known_answers = cast(set[DNSPointer], known_answers) else: pointer_known_answers = known_answers diff --git a/tests/__init__.py b/tests/__init__.py index 3df098191..a70cca600 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,7 @@ from unittest import mock import ifaddr + from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf from zeroconf._history import QuestionHistory diff --git a/tests/benchmarks/test_cache.py b/tests/benchmarks/test_cache.py index e32abda07..7813f6798 100644 --- a/tests/benchmarks/test_cache.py +++ b/tests/benchmarks/test_cache.py @@ -1,6 +1,7 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture + from zeroconf import DNSCache, DNSPointer, current_time_millis from zeroconf.const import _CLASS_IN, _TYPE_PTR diff --git a/tests/benchmarks/test_incoming.py b/tests/benchmarks/test_incoming.py index 672e5c783..6d31e51e5 100644 --- a/tests/benchmarks/test_incoming.py +++ b/tests/benchmarks/test_incoming.py @@ -5,6 +5,7 @@ import socket from pytest_codspeed import BenchmarkFixture + from zeroconf import ( DNSAddress, DNSIncoming, diff --git a/tests/benchmarks/test_outgoing.py b/tests/benchmarks/test_outgoing.py index cc2f3f421..a8db4d6f8 100644 --- a/tests/benchmarks/test_outgoing.py +++ b/tests/benchmarks/test_outgoing.py @@ -3,6 +3,7 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture + from zeroconf._protocol.outgoing import State from .helpers import generate_packets diff --git a/tests/benchmarks/test_send.py b/tests/benchmarks/test_send.py index d931b48ba..596662a2b 100644 --- a/tests/benchmarks/test_send.py +++ b/tests/benchmarks/test_send.py @@ -4,12 +4,13 @@ import pytest from pytest_codspeed import BenchmarkFixture + from zeroconf.asyncio import AsyncZeroconf from .helpers import generate_packets -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_sending_packets(benchmark: BenchmarkFixture) -> None: """Benchmark sending packets.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/benchmarks/test_txt_properties.py b/tests/benchmarks/test_txt_properties.py index b7b0e7675..72afa0b65 100644 --- a/tests/benchmarks/test_txt_properties.py +++ b/tests/benchmarks/test_txt_properties.py @@ -1,6 +1,7 @@ from __future__ import annotations from pytest_codspeed import BenchmarkFixture + from zeroconf import ServiceInfo info = ServiceInfo( diff --git a/tests/conftest.py b/tests/conftest.py index 3d891ec46..531c810be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest + from zeroconf import _core, const from zeroconf._handlers import query_handler @@ -19,7 +20,7 @@ def verify_threads_ended(): assert not threads -@pytest.fixture() +@pytest.fixture def run_isolated(): """Change the mDNS port to run the test in isolation.""" with ( @@ -30,7 +31,7 @@ def run_isolated(): yield -@pytest.fixture() +@pytest.fixture def disable_duplicate_packet_suppression(): """Disable duplicate packet suppress. diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index f5237365e..d57568f40 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -14,6 +14,7 @@ from unittest.mock import patch import pytest + import zeroconf as r import zeroconf._services.browser as _services_browser from zeroconf import ( @@ -555,7 +556,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_asking_default_is_asking_qm_questions_after_the_first_qu(): """Verify the service browser's first questions are QU and refresh queries are QM.""" service_added = asyncio.Event() @@ -657,7 +658,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_ttl_refresh_cancelled_rescue_query(): """Verify seeing a name again cancels the rescue query.""" service_added = asyncio.Event() @@ -767,7 +768,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_asking_qm_questions(): """Verify explicitly asking QM questions.""" type_ = "_quservice._tcp.local." @@ -806,7 +807,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_asking_qu_questions(): """Verify the service browser can ask QU questions.""" type_ = "_quservice._tcp.local." @@ -898,7 +899,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() - assert len(updates) + assert updates assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 zc.remove_listener(listener) @@ -1138,7 +1139,7 @@ def test_group_ptr_queries_with_known_answers(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_generate_service_query_suppress_duplicate_questions(): """Generate a service query for sending with zeroconf.send.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1191,7 +1192,7 @@ async def test_generate_service_query_suppress_duplicate_questions(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_query_scheduler(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} @@ -1284,7 +1285,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_query_scheduler_rescue_records(): delay = const._BROWSER_TIME types_ = {"_hap._tcp.local.", "_http._tcp.local."} @@ -1579,7 +1580,7 @@ def test_scheduled_ptr_query_dunder_methods(): assert query75 >= other # type: ignore[operator] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_close_zeroconf_without_browser_before_start_up_queries(): """Test that we stop sending startup queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() @@ -1647,7 +1648,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await browser.async_cancel() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_close_zeroconf_without_browser_after_start_up_queries(): """Test that we stop sending rescue queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 8b912bea0..3d4c53028 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -14,6 +14,7 @@ from unittest.mock import patch import pytest + import zeroconf as r from zeroconf import DNSAddress, RecordUpdate, const from zeroconf._services import info @@ -827,7 +828,7 @@ def test_scoped_addresses_from_cache(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_multiple_a_addresses_newest_address_first(): """Test that info.addresses returns the newest seen address first.""" type_ = "_http._tcp.local." @@ -847,7 +848,7 @@ async def test_multiple_a_addresses_newest_address_first(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_invalid_a_addresses(caplog): type_ = "_http._tcp.local." registration_name = f"multiarec.{type_}" @@ -1056,7 +1057,7 @@ def test_request_timeout(): assert (end_time - start_time) < 3000 + 1000 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_we_try_four_times_with_random_delay(): """Verify we try four times even with the random delay.""" type_ = "_typethatisnothere._tcp.local." @@ -1079,7 +1080,7 @@ def async_send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): assert request_count == 4 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_release_wait_when_new_recorded_added(): """Test that async_request returns as soon as new matching records are added to the cache.""" type_ = "_http._tcp.local." @@ -1144,7 +1145,7 @@ async def test_release_wait_when_new_recorded_added(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_port_changes_are_seen(): """Test that port changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1227,7 +1228,7 @@ async def test_port_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_port_changes_are_seen_with_directed_request(): """Test that port changes are seen by async_request with a directed request.""" type_ = "_http._tcp.local." @@ -1310,7 +1311,7 @@ async def test_port_changes_are_seen_with_directed_request(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_ipv4_changes_are_seen(): """Test that ipv4 changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1398,7 +1399,7 @@ async def test_ipv4_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_ipv6_changes_are_seen(): """Test that ipv6 changes are seen by async_request.""" type_ = "_http._tcp.local." @@ -1493,7 +1494,7 @@ async def test_ipv6_changes_are_seen(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_bad_ip_addresses_ignored_in_cache(): """Test that bad ip address in the cache are ignored async_request.""" type_ = "_http._tcp.local." @@ -1547,7 +1548,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_name_change_as_seen_has_ip_in_cache(): """Test that service name changes are seen by async_request when the ip is in the cache.""" type_ = "_http._tcp.local." @@ -1629,7 +1630,7 @@ async def test_service_name_change_as_seen_has_ip_in_cache(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_name_change_as_seen_ip_not_in_cache(): """Test that service name changes are seen by async_request when the ip is not in the cache.""" type_ = "_http._tcp.local." @@ -1711,7 +1712,7 @@ async def test_service_name_change_as_seen_ip_not_in_cache(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio @patch.object(info, "_LISTENER_TIME", 10000000) async def test_release_wait_when_new_recorded_added_concurrency(): """Test that concurrent async_request returns as soon as new matching records are added to the cache.""" @@ -1783,7 +1784,7 @@ async def test_release_wait_when_new_recorded_added_concurrency(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_info_nsec_records(): """Test we can generate nsec records from ServiceInfo.""" type_ = "_http._tcp.local." @@ -1798,7 +1799,7 @@ async def test_service_info_nsec_records(): assert nsec_record.rdtypes == [const._TYPE_A, const._TYPE_AAAA] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_address_resolver(): """Test that the address resolver works.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1822,7 +1823,7 @@ async def test_address_resolver(): assert resolver.addresses == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_address_resolver_ipv4(): """Test that the IPv4 address resolver works.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1846,7 +1847,7 @@ async def test_address_resolver_ipv4(): assert resolver.addresses == [b"\x7f\x00\x00\x01"] -@pytest.mark.asyncio() +@pytest.mark.asyncio @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") async def test_address_resolver_ipv6(): diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e31025075..40ecf8162 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -11,6 +11,7 @@ from unittest.mock import ANY, call, patch import pytest + import zeroconf._services.browser as _services_browser from zeroconf import ( DNSAddress, @@ -78,14 +79,14 @@ def verify_threads_ended(): assert not threads -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_basic_usage() -> None: """Test we can create and close the instance.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_close_twice() -> None: """Test we can close twice.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -93,7 +94,7 @@ async def test_async_close_twice() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_with_sync_passed_in() -> None: """Test we can create and close the instance when passing in a sync Zeroconf.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -102,7 +103,7 @@ async def test_async_with_sync_passed_in() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_with_sync_passed_in_closed_in_async() -> None: """Test caller closes the sync version in async.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -112,7 +113,7 @@ async def test_async_with_sync_passed_in_closed_in_async() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_sync_within_event_loop_executor() -> None: """Test sync version still works from an executor within an event loop.""" @@ -124,7 +125,7 @@ def sync_code(): await asyncio.get_event_loop().run_in_executor(None, sync_code) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration() -> None: """Test registering services broadcasts the registration by default.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -191,7 +192,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_with_server_missing() -> None: """Test registering a service with the server not specified. @@ -258,7 +259,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_same_server_different_ports() -> None: """Test registering services with the same server with different srv records.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -325,7 +326,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_same_server_same_ports() -> None: """Test registering services with the same server with the exact same srv record.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -392,7 +393,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_name_conflict() -> None: """Test registering services throws on name conflict.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -440,7 +441,7 @@ async def test_async_service_registration_name_conflict() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_name_does_not_match_type() -> None: """Test registering services throws when the name does not match the type.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -466,7 +467,7 @@ async def test_async_service_registration_name_does_not_match_type() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_registration_name_strict_check() -> None: """Test registering services throws when the name does not comply.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -501,7 +502,7 @@ async def test_async_service_registration_name_strict_check() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_tasks() -> None: """Test awaiting broadcast tasks""" @@ -567,7 +568,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_wait_unblocks_on_update() -> None: """Test async_wait will unblock on update.""" @@ -603,7 +604,7 @@ async def test_async_wait_unblocks_on_update() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_info_async_request() -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" if not has_working_ipv6() or os.environ.get("SKIP_IPV6"): @@ -712,7 +713,7 @@ async def test_service_info_async_request() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_service_browser() -> None: """Test AsyncServiceBrowser.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -772,7 +773,7 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: ] -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_context_manager() -> None: """Test using an async context manager.""" type_ = "_test10-sr-type._tcp.local." @@ -796,7 +797,7 @@ async def test_async_context_manager() -> None: assert aiosinfo is not None -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_browser_cancel_async_context_manager(): """Test we can cancel an AsyncServiceBrowser with it being used as an async context manager.""" @@ -822,7 +823,7 @@ class MyServiceListener(ServiceListener): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_unregister_all_services() -> None: """Test unregistering all services.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -881,7 +882,7 @@ async def test_async_unregister_all_services() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_zeroconf_service_types(): type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" @@ -915,7 +916,7 @@ async def test_async_zeroconf_service_types(): await zeroconf_registrar.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_guard_against_running_serviceinfo_request_event_loop() -> None: """Test that running ServiceInfo.request from the event loop throws.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -926,7 +927,7 @@ async def test_guard_against_running_serviceinfo_request_event_loop() -> None: await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_browser_instantiation_generates_add_events_from_cache(): """Test that the ServiceBrowser will generate Add events with the existing cache when starting.""" @@ -975,7 +976,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_integration(): service_added = asyncio.Event() service_removed = asyncio.Event() @@ -1123,7 +1124,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): """Verify the service info first question is QU and subsequent ones are QM questions.""" type_ = "_quservice._tcp.local." @@ -1177,7 +1178,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_service_browser_ignores_unrelated_updates(): """Test that the ServiceBrowser ignores unrelated updates.""" @@ -1274,7 +1275,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1288,7 +1289,7 @@ async def test_async_request_timeout(): assert (end_time - start_time) < 3000 + 1000 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_request_non_running_instance(): """Test that the async_request throws when zeroconf is not running.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1297,7 +1298,7 @@ async def test_async_request_non_running_instance(): await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_legacy_unicast_response(run_isolated): """Verify legacy unicast responses include questions and correct id.""" type_ = "_mservice._tcp.local." @@ -1338,7 +1339,7 @@ async def test_legacy_unicast_response(run_isolated): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_update_with_uppercase_names(run_isolated): """Test an ip update from a shelly which uses uppercase names.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_cache.py b/tests/test_cache.py index 5bd6a8695..9d55435d5 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,6 +7,7 @@ from heapq import heapify, heappop import pytest + import zeroconf as r from zeroconf import const @@ -363,7 +364,7 @@ def test_async_get_unique_returns_newest_record(): assert record is record2 -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_cache_heap_cleanup() -> None: """Test that the heap gets cleaned up when there are many old expirations.""" cache = r.DNSCache() @@ -415,7 +416,7 @@ async def test_cache_heap_cleanup() -> None: assert not cache.async_entries_with_name(name), cache._expire_heap -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_cache_heap_multi_name_cleanup() -> None: """Test cleanup with multiple names.""" cache = r.DNSCache() @@ -451,7 +452,7 @@ async def test_cache_heap_multi_name_cleanup() -> None: assert not cache.async_entries_with_name(name), cache._expire_heap -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_cache_heap_pops_order() -> None: """Test cache heap is popped in order.""" cache = r.DNSCache() diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index 79d58ae17..74ed1f124 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -8,7 +8,7 @@ import pytest -@pytest.mark.asyncio() +@pytest.mark.asyncio @pytest.mark.timeout(30) # cloud can take > 9s @pytest.mark.parametrize( "module", diff --git a/tests/test_core.py b/tests/test_core.py index 1dfb98066..fcfdf4249 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest + import zeroconf as r from zeroconf import NotRunningException, Zeroconf, const, current_time_millis from zeroconf._listener import AsyncListener, _WrappedTransport @@ -664,7 +665,7 @@ def test_tc_bit_defers_last_response_missing(): zc.close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_open_close_twice_from_async() -> None: """Test we can close twice from a coroutine when using Zeroconf. @@ -684,7 +685,7 @@ async def test_open_close_twice_from_async() -> None: await asyncio.sleep(0) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_multiple_sync_instances_stared_from_async_close(): """Test we can shutdown multiple sync instances from async.""" @@ -740,7 +741,7 @@ def _background_register(): bgthread.join() -@pytest.mark.asyncio() +@pytest.mark.asyncio @patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): diff --git a/tests/test_dns.py b/tests/test_dns.py index 5928338cb..246c8dcfb 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -8,6 +8,7 @@ import unittest.mock import pytest + import zeroconf as r from zeroconf import DNSHinfo, DNSText, ServiceInfo, const, current_time_millis from zeroconf._dns import DNSRRSet diff --git a/tests/test_engine.py b/tests/test_engine.py index 5f2448047..b7a94c866 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest + import zeroconf as r from zeroconf import _engine, const from zeroconf.asyncio import AsyncZeroconf @@ -29,7 +30,7 @@ def teardown_module(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_reaper(): with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -64,7 +65,7 @@ async def test_reaper(): assert record_with_1s_ttl not in entries -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_reaper_aborts_when_done(): """Ensure cache cleanup stops when zeroconf is done.""" with patch.object(_engine, "_CACHE_CLEANUP_INTERVAL", 0.01): diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 58f8ecb1a..ffa4ff88c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -13,6 +13,7 @@ from unittest.mock import patch import pytest + import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, const, current_time_millis from zeroconf._handlers.multicast_outgoing_queue import ( @@ -492,7 +493,7 @@ def test_unicast_response(): zc.close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_probe_answered_immediately(): """Verify probes are responded to immediately.""" # instantiate a zeroconf instance @@ -543,7 +544,7 @@ async def test_probe_answered_immediately(): zc.close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_probe_answered_immediately_with_uppercase_name(): """Verify probes are responded to immediately with an uppercase name.""" # instantiate a zeroconf instance @@ -1091,7 +1092,7 @@ def test_enumeration_query_with_no_registered_services(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_qu_response_only_sends_additionals_if_sends_answer(): """Test that a QU response does not send additionals unless it sends the answer as well.""" # instantiate a zeroconf instance @@ -1257,7 +1258,7 @@ async def test_qu_response_only_sends_additionals_if_sends_answer(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_cache_flush_bit(): """Test that the cache flush bit sets the TTL to one for matching records.""" # instantiate a zeroconf instance @@ -1360,7 +1361,7 @@ async def test_cache_flush_bit(): # This test uses asyncio because it needs to access the cache directly # which is not threadsafe -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_record_update_manager_add_listener_callsback_existing_records(): """Test that the RecordUpdateManager will callback existing records.""" @@ -1414,7 +1415,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_questions_query_handler_populates_the_question_history_from_qm_questions(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf @@ -1460,7 +1461,7 @@ async def test_questions_query_handler_populates_the_question_history_from_qm_qu await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_questions_query_handler_does_not_put_qu_questions_in_history(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zc = aiozc.zeroconf @@ -1503,7 +1504,7 @@ async def test_questions_query_handler_does_not_put_qu_questions_in_history(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_guard_against_low_ptr_ttl(): """Ensure we enforce a min for PTR record ttls to avoid excessive refresh queries from ServiceBrowsers. @@ -1554,7 +1555,7 @@ async def test_guard_against_low_ptr_ttl(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_duplicate_goodbye_answers_in_packet(): """Ensure we do not throw an exception when there are duplicate goodbye records in a packet.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1586,7 +1587,7 @@ async def test_duplicate_goodbye_answers_in_packet(): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_response_aggregation_timings(run_isolated): """Verify multicast responses are aggregated.""" type_ = "_mservice._tcp.local." @@ -1708,7 +1709,7 @@ async def test_response_aggregation_timings(run_isolated): await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): """Verify multicast responses that are aggregated do not take longer than 620ms to send. @@ -1790,7 +1791,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli assert info2.dns_pointer() in incoming.answers() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_response_aggregation_random_delay(): """Verify the random delay for outgoing multicast will coalesce into a single group @@ -1898,7 +1899,7 @@ async def test_response_aggregation_random_delay(): assert info5.dns_pointer() in outgoing_queue.queue[1].answers -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_future_answers_are_removed_on_send(): """Verify any future answers scheduled to be sent are removed when we send.""" type_ = "_mservice._tcp.local." @@ -1962,7 +1963,7 @@ async def test_future_answers_are_removed_on_send(): assert info2.dns_pointer() in outgoing_queue.queue[0].answers -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_add_listener_warns_when_not_using_record_update_listener(caplog): """Log when a listener is added that is not using RecordUpdateListener as a base class.""" @@ -1987,7 +1988,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_updates_iteration_safe(): """Ensure we can safely iterate over the async_updates.""" @@ -2031,7 +2032,7 @@ def async_update_records(self, zc: Zeroconf, now: float, records: list[r.RecordU await aiozc.async_close() -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_updates_complete_iteration_safe(): """Ensure we can safely iterate over the async_updates_complete.""" diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 78fed0e03..08d7e600a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -11,6 +11,7 @@ from typing import cast import pytest + import zeroconf as r from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis diff --git a/tests/test_services.py b/tests/test_services.py index d192c6529..7d7c3fc7d 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -11,6 +11,7 @@ from typing import Any import pytest + import zeroconf as r from zeroconf import Zeroconf from zeroconf._services.info import ServiceInfo diff --git a/tests/test_updates.py b/tests/test_updates.py index 376082e73..ec1296f74 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -7,6 +7,7 @@ import time import pytest + import zeroconf as r from zeroconf import Zeroconf, const from zeroconf._record_update import RecordUpdate @@ -80,7 +81,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): browser.cancel() - assert len(updates) + assert updates assert len([isinstance(update, r.DNSPointer) and update.name == type_ for update in updates]) >= 1 zc.remove_listener(listener) diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 4d2ee0ec6..7989a82cf 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -10,13 +10,14 @@ from unittest.mock import patch import pytest + from zeroconf import EventLoopBlocked from zeroconf._engine import _CLOSE_TIMEOUT from zeroconf._utils import asyncio as aioutils from zeroconf.const import _LOADED_SYSTEM_TIMEOUT -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_async_get_all_tasks() -> None: """Test we can get all tasks in the event loop. @@ -32,7 +33,7 @@ async def test_async_get_all_tasks() -> None: await aioutils._async_get_all_tasks(loop) -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_get_running_loop_from_async() -> None: """Test we can get the event loop.""" assert isinstance(aioutils.get_running_loop(), asyncio.AbstractEventLoop) @@ -43,7 +44,7 @@ def test_get_running_loop_no_loop() -> None: assert aioutils.get_running_loop() is None -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_wait_future_or_timeout_times_out() -> None: """Test wait_future_or_timeout will timeout.""" loop = asyncio.get_running_loop() @@ -117,7 +118,7 @@ def test_cumulative_timeouts_less_than_close_plus_buffer(): ) < 1 + _CLOSE_TIMEOUT + _LOADED_SYSTEM_TIMEOUT -@pytest.mark.asyncio() +@pytest.mark.asyncio async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 3b70c7d40..1feb77131 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -5,6 +5,7 @@ import socket import pytest + from zeroconf import BadTypeInNameException from zeroconf._services.info import ServiceInfo, instance_name_from_service_info from zeroconf._utils import name as nameutils diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 17ff61969..f763b655c 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -10,6 +10,7 @@ import ifaddr import pytest + import zeroconf as r from zeroconf._utils import net as netutils From 33bf0e4ef2e3468b7c9df1a53709ea0d9e35f32c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:13:09 -1000 Subject: [PATCH 1241/1433] chore(deps-dev): bump setuptools from 76.0.0 to 77.0.3 (#1550) --- poetry.lock | 32 ++++++++++++++++---------------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8c4713e8d..ec600ab62 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -25,7 +25,7 @@ files = [ ] [package.extras] -dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "certifi" @@ -311,7 +311,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" @@ -471,12 +471,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -825,24 +825,24 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "76.0.0" +version = "77.0.3" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-76.0.0-py3-none-any.whl", hash = "sha256:199466a166ff664970d0ee145839f5582cb9bca7a0a3a2e795b6a9cb2308e9c6"}, - {file = "setuptools-76.0.0.tar.gz", hash = "sha256:43b4ee60e10b0d0ee98ad11918e114c70701bc6051662a9a675a0496c1a158f4"}, + {file = "setuptools-77.0.3-py3-none-any.whl", hash = "sha256:67122e78221da5cf550ddd04cf8742c8fe12094483749a792d56cd669d6cf58c"}, + {file = "setuptools-77.0.3.tar.gz", hash = "sha256:583b361c8da8de57403743e756609670de6fb2345920e36dc5c2d914c319c945"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] -core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "snowballstemmer" @@ -1097,7 +1097,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1116,14 +1116,14 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "f866b539caf6f0140faba8aa19f4e1fae2013a48fc3346747f104dfe62ef290b" +content-hash = "6185b531e93844e1dbd399c197c9376fc7d2efa2cbff6bdb7585484dc6dbfb86" diff --git a/pyproject.toml b/pyproject.toml index 9c92f3621..198605f97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.26.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<77.0.0" +setuptools = ">=65.6.3,<78.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" From f05b0127774ac69db5a6a7ba02ecdf57e46b4f9b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 10:02:29 -1000 Subject: [PATCH 1242/1433] chore(pre-commit.ci): pre-commit autoupdate (#1551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.0 → v0.11.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.0...v0.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d03fcde4..cf19bfa2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.0 + rev: v0.11.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 741b6ef3639334cb558b16ce568b33bf308e6688 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Mar 2025 19:10:38 -1000 Subject: [PATCH 1243/1433] chore(deps-dev): bump setuptools from 77.0.3 to 78.1.0 (#1552) --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index ec600ab62..b16f7e87e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -825,14 +825,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "77.0.3" +version = "78.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-77.0.3-py3-none-any.whl", hash = "sha256:67122e78221da5cf550ddd04cf8742c8fe12094483749a792d56cd669d6cf58c"}, - {file = "setuptools-77.0.3.tar.gz", hash = "sha256:583b361c8da8de57403743e756609670de6fb2345920e36dc5c2d914c319c945"}, + {file = "setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8"}, + {file = "setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54"}, ] [package.extras] @@ -1126,4 +1126,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "6185b531e93844e1dbd399c197c9376fc7d2efa2cbff6bdb7585484dc6dbfb86" +content-hash = "94e87573380ca1c563c3af5fbd6363399a4c333c9f697c1b2191835714d1ffaa" diff --git a/pyproject.toml b/pyproject.toml index 198605f97..608b849bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.26.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<78.0.0" +setuptools = ">=65.6.3,<79.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" From 0fe79d7a53789719225509bce8c124950aed6237 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Mar 2025 19:21:52 -1000 Subject: [PATCH 1244/1433] chore(deps-dev): bump pytest-asyncio from 0.25.3 to 0.26.0 (#1553) Bumps [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) from 0.25.3 to 0.26.0. - [Release notes](https://github.com/pytest-dev/pytest-asyncio/releases) - [Commits](https://github.com/pytest-dev/pytest-asyncio/compare/v0.25.3...v0.26.0) --- updated-dependencies: - dependency-name: pytest-asyncio dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 9 +++++---- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index b16f7e87e..845974d6f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -697,18 +697,19 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "0.26.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, - {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, + {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, + {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, ] [package.dependencies] pytest = ">=8.2,<9" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -1126,4 +1127,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "94e87573380ca1c563c3af5fbd6363399a4c333c9f697c1b2191835714d1ffaa" +content-hash = "e3c96e694e9c149b96323081d51675d7a9d5ad8243f4338ff149e643a65417cb" diff --git a/pyproject.toml b/pyproject.toml index 608b849bb..569fe9773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" -pytest-asyncio = ">=0.20.3,<0.26.0" +pytest-asyncio = ">=0.20.3,<0.27.0" cython = "^3.0.5" setuptools = ">=65.6.3,<79.0.0" pytest-timeout = "^2.1.0" From 34043735e13ba254cb5e31e03b6d672447ba6e57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Mar 2025 19:29:47 -1000 Subject: [PATCH 1245/1433] chore: pin GitHub actions to SHAs to mitigate supply chain attacks (#1554) * chore: pin GitHub actions to SHAs to mitigate supply chain attacks * chore: pin GitHub actions to SHAs to mitigate supply chain attacks --- .github/workflows/ci.yml | 52 +++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fb9b06fe..b61e5e45c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: "3.12" - - uses: pre-commit/action@v3.0.1 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 # Make sure commit messages follow the conventional commits convention: # https://www.conventionalcommits.org @@ -26,10 +26,10 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@v6 + - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 test: strategy: @@ -65,11 +65,11 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" @@ -87,25 +87,25 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Setup Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: 3.13 - - uses: snok/install-poetry@v1.4.1 + - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 - name: Install Dependencies run: | REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@v3 + uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -128,32 +128,32 @@ jobs: newest_release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@v9.21.0 + uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 if: github.ref_name != 'master' with: root_options: --noop # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@v9.21.0 + uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 id: release if: github.ref_name == 'master' with: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1 if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases - uses: python-semantic-release/upload-to-gh-release@main + uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -228,18 +228,18 @@ jobs: pyver: cp313 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 ref: "master" # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: "3.12" - name: Set up QEMU if: ${{ matrix.qemu }} - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 with: platforms: all # This should be temporary @@ -262,20 +262,20 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@v2.23.0 + uses: pypa/cibuildwheel@6cccd09a31908ffd175b012fb8bf4e1dbda3bc6c # v2.23.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} @@ -288,7 +288,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir @@ -297,6 +297,4 @@ jobs: merge-multiple: true - uses: - pypa/gh-action-pypi-publish@v1.12.4 - - # To test: repository_url: https://test.pypi.org/legacy/ + pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 From 54eb3830dc794d78b8419153f8233713e1dff840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 19:19:24 -1000 Subject: [PATCH 1246/1433] chore(ci): bump pypa/cibuildwheel from 2.23.0 to 2.23.2 in the github-actions group (#1556) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b61e5e45c..ffe20f82a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,7 +268,7 @@ jobs: fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@6cccd09a31908ffd175b012fb8bf4e1dbda3bc6c # v2.23.0 + uses: pypa/cibuildwheel@d04cacbc9866d432033b1d09142936e6a0e2121a # v2.23.2 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} From b757ddf98d7d04c366281a4281a449c5c2cb897d Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 1 Apr 2025 21:11:16 +0200 Subject: [PATCH 1247/1433] fix: create listener socket with specific IP version (#1557) * fix: create listener socket with specific IP version Create listener sockets when using unicast with specific IP version as well, just like in `new_respond_socket()`. * chore(tests): add unit test for socket creation with unicast addressing --- src/zeroconf/_utils/net.py | 5 +++-- tests/utils/test_net.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index c2312e01f..b4f3ef778 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -421,11 +421,12 @@ def create_sockets( else: respond_socket = None else: + is_v6 = isinstance(i, tuple) respond_socket = new_socket( port=0, - ip_version=ip_version, + ip_version=IPVersion.V6Only if is_v6 else IPVersion.V4Only, apple_p2p=apple_p2p, - bind_addr=i[0] if isinstance(i, tuple) else (i,), + bind_addr=cast(tuple[tuple[str, int, int], int], i)[0] if is_v6 else (cast(str, i),), ) if respond_socket is not None: diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index f763b655c..ad8648de5 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -298,3 +298,35 @@ def test_new_respond_socket_new_socket_returns_none(): """Test new_respond_socket returns None if new_socket returns None.""" with patch.object(netutils, "new_socket", return_value=None): assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] + + +def test_create_sockets(): + """Test create_sockets with unicast and IPv4.""" + + with ( + patch("zeroconf._utils.net.new_socket") as mock_new_socket, + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + ): + mock_socket = Mock(spec=socket.socket) + mock_new_socket.return_value = mock_socket + + listen_socket, respond_sockets = r.create_sockets( + interfaces=r.InterfaceChoice.All, unicast=True, ip_version=r.IPVersion.All + ) + + assert listen_socket is None + mock_new_socket.assert_any_call( + port=0, + ip_version=r.IPVersion.V6Only, + apple_p2p=False, + bind_addr=("2001:db8::", 1, 1), + ) + mock_new_socket.assert_any_call( + port=0, + ip_version=r.IPVersion.V4Only, + apple_p2p=False, + bind_addr=("192.168.1.5",), + ) From 94620b084addfff6d7b73dd5d7ed69c1a213415e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 1 Apr 2025 19:20:01 +0000 Subject: [PATCH 1248/1433] 0.146.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f28b00223..0ffa0f63f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # CHANGELOG +## v0.146.2 (2025-04-01) + +### Bug Fixes + +- Create listener socket with specific IP version + ([#1557](https://github.com/python-zeroconf/python-zeroconf/pull/1557), + [`b757ddf`](https://github.com/python-zeroconf/python-zeroconf/commit/b757ddf98d7d04c366281a4281a449c5c2cb897d)) + +* fix: create listener socket with specific IP version + +Create listener sockets when using unicast with specific IP version as well, just like in + `new_respond_socket()`. + +* chore(tests): add unit test for socket creation with unicast addressing + + ## v0.146.1 (2025-03-05) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 569fe9773..b28501136 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.1" +version = "0.146.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b915b8d72..01496e22d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.1" +__version__ = "0.146.2" __license__ = "LGPL" From bd643a227bc4d6a949d558850ad1431bc2940d74 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 2 Apr 2025 19:41:16 +0200 Subject: [PATCH 1249/1433] fix: correctly override question type flag for requests (#1558) * fix: correctly override question type flag for requests Currently even when setting the explicit question type flag, the implementation ignores it for subsequent queries. This commit ensures that all queries respect the explicit question type flag. * chore(tests): add test for explicit question type flag Add unit test to validate that the explicit question type flag is set correctly in outgoing requests. --- src/zeroconf/_services/info.py | 2 +- tests/services/test_info.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 9cd8df163..fff9e1257 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -859,7 +859,7 @@ async def async_request( if last <= now: return False if next_ <= now: - this_question_type = question_type or QU_QUESTION if first_request else QM_QUESTION + this_question_type = question_type or (QU_QUESTION if first_request else QM_QUESTION) out = self._generate_request_query(zc, now, this_question_type) first_request = False if out.questions: diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 3d4c53028..660b56d29 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -17,6 +17,7 @@ import zeroconf as r from zeroconf import DNSAddress, RecordUpdate, const +from zeroconf._protocol.outgoing import DNSOutgoing from zeroconf._services import info from zeroconf._services.info import ServiceInfo from zeroconf._utils.net import IPVersion @@ -1871,3 +1872,23 @@ async def test_address_resolver_ipv6(): aiozc.zeroconf.async_send(outgoing) assert await resolve_task assert resolver.ip_addresses_by_version(IPVersion.All) == [ip_address("fe80::52e:c2f2:bc5f:e9c6")] + + +@pytest.mark.asyncio +async def test_unicast_flag_if_requested() -> None: + """Verify we try four times even with the random delay.""" + type_ = "_typethatisnothere._tcp.local." + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + + def async_send(out: DNSOutgoing, addr: str | None = None, port: int = const._MDNS_PORT) -> None: + """Sends an outgoing packet.""" + for question in out.questions: + assert question.unicast + + # patch the zeroconf send + with patch.object(aiozc.zeroconf, "async_send", async_send): + await aiozc.async_get_service_info( + f"willnotbefound.{type_}", type_, question_type=r.DNSQuestionType.QU + ) + + await aiozc.async_close() From 16c257c0ca2772a024c6e9920df2375436bfc73c Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 2 Apr 2025 17:51:12 +0000 Subject: [PATCH 1250/1433] 0.146.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffa0f63f..ccb6bdd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ # CHANGELOG +## v0.146.3 (2025-04-02) + +### Bug Fixes + +- Correctly override question type flag for requests + ([#1558](https://github.com/python-zeroconf/python-zeroconf/pull/1558), + [`bd643a2`](https://github.com/python-zeroconf/python-zeroconf/commit/bd643a227bc4d6a949d558850ad1431bc2940d74)) + +* fix: correctly override question type flag for requests + +Currently even when setting the explicit question type flag, the implementation ignores it for + subsequent queries. This commit ensures that all queries respect the explicit question type flag. + +* chore(tests): add test for explicit question type flag + +Add unit test to validate that the explicit question type flag is set correctly in outgoing + requests. + + ## v0.146.2 (2025-04-01) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index b28501136..7e21a38f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.2" +version = "0.146.3" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 01496e22d..c266c318a 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.2" +__version__ = "0.146.3" __license__ = "LGPL" From b044d2af9c3d357a49c010380f49471e92684f7e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:33:10 -1000 Subject: [PATCH 1251/1433] chore(pre-commit.ci): pre-commit autoupdate (#1555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(pre-commit.ci): pre-commit autoupdate updates: - [github.com/PyCQA/flake8: 7.1.2 → 7.2.0](https://github.com/PyCQA/flake8/compare/7.1.2...7.2.0) * chore: remove useless nonlocal statements --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- tests/services/test_browser.py | 13 ------------- tests/test_asyncio.py | 9 --------- tests/test_updates.py | 1 - tests/utils/test_net.py | 1 - 5 files changed, 1 insertion(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf19bfa2d..985d54b6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 - rev: 7.1.2 + rev: 7.2.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index d57568f40..e9135bb60 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -866,7 +866,6 @@ class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" def update_record(self, zc: Zeroconf, now: float, record: r.DNSRecord) -> None: - nonlocal updates updates.append(record) listener = LegacyRecordUpdateListener() @@ -923,7 +922,6 @@ def test_service_browser_is_aware_of_port_changes(): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): """Dummy callback.""" - nonlocal callbacks if name == registration_name: callbacks.append((service_type, state_change, name)) @@ -985,17 +983,14 @@ def test_service_browser_listeners_update_service(): class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1050,12 +1045,10 @@ def test_service_browser_listeners_no_update_service(): class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) @@ -1374,17 +1367,14 @@ def test_service_browser_matching(): class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1465,17 +1455,14 @@ def test_service_browser_expire_callbacks(): class MyServiceListener(r.ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 40ecf8162..b6e124aad 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -940,17 +940,14 @@ async def test_service_browser_instantiation_generates_add_events_from_cache(): class MyServiceListener(ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1191,17 +1188,14 @@ async def test_service_browser_ignores_unrelated_updates(): class MyServiceListener(ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks if name == registration_name: callbacks.append(("update", type_, name)) @@ -1349,15 +1343,12 @@ async def test_update_with_uppercase_names(run_isolated): class MyServiceListener(ServiceListener): def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks callbacks.append(("add", type_, name)) def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks callbacks.append(("remove", type_, name)) def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] - nonlocal callbacks callbacks.append(("update", type_, name)) listener = MyServiceListener() diff --git a/tests/test_updates.py b/tests/test_updates.py index ec1296f74..d8b160835 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -48,7 +48,6 @@ class LegacyRecordUpdateListener(r.RecordUpdateListener): """A RecordUpdateListener that does not implement update_records.""" def update_record(self, zc: Zeroconf, now: float, record: r.DNSRecord) -> None: - nonlocal updates updates.append(record) listener = LegacyRecordUpdateListener() diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index ad8648de5..6bdafb37a 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -127,7 +127,6 @@ def test_disable_ipv6_only_or_raise(): errors_logged = [] def _log_error(*args): - nonlocal errors_logged errors_logged.append(args) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) From f89a90e610094b721ec536f9b0ddee41592838fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 6 Apr 2025 18:43:34 -1000 Subject: [PATCH 1252/1433] chore(deps-dev): bump pytest-cov from 6.0.0 to 6.1.1 (#1560) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 845974d6f..367374ed6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -750,14 +750,14 @@ test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" -version = "6.0.0" +version = "6.1.1" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, - {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, + {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, + {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, ] [package.dependencies] From 389a8a2724d3f6d328fee0bef38d7addc29d19c4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 08:47:19 -1000 Subject: [PATCH 1253/1433] chore(pre-commit.ci): pre-commit autoupdate (#1561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/commitizen-tools/commitizen: v4.4.1 → v4.5.0](https://github.com/commitizen-tools/commitizen/compare/v4.4.1...v4.5.0) - [github.com/astral-sh/ruff-pre-commit: v0.11.2 → v0.11.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.2...v0.11.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 985d54b6e..1faee0105 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.4.1 + rev: v4.5.0 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.2 + rev: v0.11.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 83594887521507cf77bfc0a397becabaaab287c2 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 14 Apr 2025 11:33:33 +0200 Subject: [PATCH 1254/1433] fix: avoid loading adapter list twice (#1564) --- src/zeroconf/_utils/net.py | 40 +++++++++++++++++++++++++++++--------- tests/utils/test_net.py | 40 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index b4f3ef778..e687ab60e 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -28,7 +28,8 @@ import socket import struct import sys -from collections.abc import Sequence +import warnings +from collections.abc import Iterable, Sequence from typing import Any, Union, cast import ifaddr @@ -73,19 +74,39 @@ def _encode_address(address: str) -> bytes: return socket.inet_pton(address_family, address) -def get_all_addresses() -> list[str]: - return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4}) # type: ignore[misc] +def get_all_addresses_ipv4(adapters: Iterable[ifaddr.Adapter]) -> list[str]: + return list({addr.ip for iface in adapters for addr in iface.ips if addr.is_IPv4}) # type: ignore[misc] -def get_all_addresses_v6() -> list[tuple[tuple[str, int, int], int]]: +def get_all_addresses_ipv6(adapters: Iterable[ifaddr.Adapter]) -> list[tuple[tuple[str, int, int], int]]: # IPv6 multicast uses positive indexes for interfaces # TODO: What about multi-address interfaces? return list( - {(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6} # type: ignore[misc] + {(addr.ip, iface.index) for iface in adapters for addr in iface.ips if addr.is_IPv6} # type: ignore[misc] + ) + + +def get_all_addresses() -> list[str]: + warnings.warn( + "get_all_addresses is deprecated, and will be removed in a future version. Use ifaddr" + "directly instead to get a list of adapters.", + DeprecationWarning, + stacklevel=2, + ) + return get_all_addresses_ipv4(ifaddr.get_adapters()) + + +def get_all_addresses_v6() -> list[tuple[tuple[str, int, int], int]]: + warnings.warn( + "get_all_addresses_v6 is deprecated, and will be removed in a future version. Use ifaddr" + "directly instead to get a list of adapters.", + DeprecationWarning, + stacklevel=2, ) + return get_all_addresses_ipv6(ifaddr.get_adapters()) -def ip6_to_address_and_index(adapters: list[ifaddr.Adapter], ip: str) -> tuple[tuple[str, int, int], int]: +def ip6_to_address_and_index(adapters: Iterable[ifaddr.Adapter], ip: str) -> tuple[tuple[str, int, int], int]: if "%" in ip: ip = ip[: ip.index("%")] # Strip scope_id. ipaddr = ipaddress.ip_address(ip) @@ -102,7 +123,7 @@ def ip6_to_address_and_index(adapters: list[ifaddr.Adapter], ip: str) -> tuple[t raise RuntimeError(f"No adapter found for IP address {ip}") -def interface_index_to_ip6_address(adapters: list[ifaddr.Adapter], index: int) -> tuple[str, int, int]: +def interface_index_to_ip6_address(adapters: Iterable[ifaddr.Adapter], index: int) -> tuple[str, int, int]: for adapter in adapters: if adapter.index == index: for adapter_ip in adapter.ips: @@ -152,10 +173,11 @@ def normalize_interface_choice( if ip_version != IPVersion.V6Only: result.append("0.0.0.0") elif choice is InterfaceChoice.All: + adapters = ifaddr.get_adapters() if ip_version != IPVersion.V4Only: - result.extend(get_all_addresses_v6()) + result.extend(get_all_addresses_ipv6(adapters)) if ip_version != IPVersion.V6Only: - result.extend(get_all_addresses()) + result.extend(get_all_addresses_ipv4(adapters)) if not result: raise RuntimeError( f"No interfaces to listen on, check that any interfaces have IP version {ip_version}" diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 6bdafb37a..eff2befd9 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -6,12 +6,14 @@ import socket import sys import unittest +import warnings from unittest.mock import MagicMock, Mock, patch import ifaddr import pytest import zeroconf as r +from zeroconf import get_all_addresses, get_all_addresses_v6 from zeroconf._utils import net as netutils @@ -35,6 +37,40 @@ def _generate_mock_adapters(): return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] +def test_get_all_addresses() -> None: + """Test public get_all_addresses API.""" + with ( + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + warnings.catch_warnings(record=True) as warned, + ): + addresses = get_all_addresses() + assert isinstance(addresses, list) + assert len(addresses) == 3 + assert len(warned) == 1 + first_warning = warned[0] + assert "get_all_addresses is deprecated" in str(first_warning.message) + + +def test_get_all_addresses_v6() -> None: + """Test public get_all_addresses_v6 API.""" + with ( + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + warnings.catch_warnings(record=True) as warned, + ): + addresses = get_all_addresses_v6() + assert isinstance(addresses, list) + assert len(addresses) == 1 + assert len(warned) == 1 + first_warning = warned[0] + assert "get_all_addresses_v6 is deprecated" in str(first_warning.message) + + def test_ip6_to_address_and_index(): """Test we can extract from mocked adapters.""" adapters = _generate_mock_adapters() @@ -84,8 +120,8 @@ def test_ip6_addresses_to_indexes(): def test_normalize_interface_choice_errors(): """Test we generate exception on invalid input.""" with ( - patch("zeroconf._utils.net.get_all_addresses", return_value=[]), - patch("zeroconf._utils.net.get_all_addresses_v6", return_value=[]), + patch("zeroconf._utils.net.get_all_addresses_ipv4", return_value=[]), + patch("zeroconf._utils.net.get_all_addresses_ipv6", return_value=[]), pytest.raises(RuntimeError), ): netutils.normalize_interface_choice(r.InterfaceChoice.All) From 79016f12055272e700d0f1aca38e9bcd2f89aa3e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 14 Apr 2025 09:45:11 +0000 Subject: [PATCH 1255/1433] 0.146.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb6bdd79..3c56284d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.146.4 (2025-04-14) + +### Bug Fixes + +- Avoid loading adapter list twice + ([#1564](https://github.com/python-zeroconf/python-zeroconf/pull/1564), + [`8359488`](https://github.com/python-zeroconf/python-zeroconf/commit/83594887521507cf77bfc0a397becabaaab287c2)) + + ## v0.146.3 (2025-04-02) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 7e21a38f9..e4de325f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.3" +version = "0.146.4" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c266c318a..89b622c2c 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.3" +__version__ = "0.146.4" __license__ = "LGPL" From 77a6717e0f2185ff8da090b6442404bb3c8a9919 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 14 Apr 2025 11:54:26 +0200 Subject: [PATCH 1256/1433] chore(test): fix resource warnings in test_net.py (#1565) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/utils/test_net.py | 175 ++++++++++++++++++++++------------------ 1 file changed, 96 insertions(+), 79 deletions(-) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index eff2befd9..2ed0c6f28 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -165,8 +165,8 @@ def test_disable_ipv6_only_or_raise(): def _log_error(*args): errors_logged.append(args) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) with ( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock, pytest.raises(OSError), patch.object(netutils.log, "error", _log_error), patch("socket.socket.setsockopt", side_effect=OSError), @@ -182,19 +182,21 @@ def _log_error(*args): @pytest.mark.skipif(not hasattr(socket, "SO_REUSEPORT"), reason="System does not have SO_REUSEPORT") def test_set_so_reuseport_if_available_is_present(): """Test that setting socket.SO_REUSEPORT only OSError errno.ENOPROTOOPT is trapped.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError): - netutils.set_so_reuseport_if_available(sock) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError): + netutils.set_so_reuseport_if_available(sock) - with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): - netutils.set_so_reuseport_if_available(sock) + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + netutils.set_so_reuseport_if_available(sock) @pytest.mark.skipif(hasattr(socket, "SO_REUSEPORT"), reason="System has SO_REUSEPORT") def test_set_so_reuseport_if_available_not_present(): """Test that we do not try to set SO_REUSEPORT if it is not present.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - with patch("socket.socket.setsockopt", side_effect=OSError): + with ( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock, + patch("socket.socket.setsockopt", side_effect=OSError), + ): netutils.set_so_reuseport_if_available(sock) @@ -202,80 +204,95 @@ def test_set_mdns_port_socket_options_for_ip_version(): """Test OSError with errno with EINVAL and bind address ''. from setsockopt IP_MULTICAST_TTL does not raise.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - # Should raise on EPERM always - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) - - # Should raise on EINVAL always when bind address is not '' - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only) - - # Should not raise on EINVAL when bind address is '' - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + # Should raise on EPERM always + with ( + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)), + ): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) + + # Should raise on EINVAL always when bind address is not '' + with ( + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)), + ): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only) + + # Should not raise on EINVAL when bind address is '' + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - interface = "127.0.0.1" - - # EPERM should always raise - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)): - netutils.add_multicast_member(sock, interface) - - # EADDRINUSE should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRINUSE, None)): - assert netutils.add_multicast_member(sock, interface) is False - - # EADDRNOTAVAIL should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None)): - assert netutils.add_multicast_member(sock, interface) is False - - # EINVAL should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - assert netutils.add_multicast_member(sock, interface) is False - - # ENOPROTOOPT should return False - with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): - assert netutils.add_multicast_member(sock, interface) is False - - # ENODEV should raise for ipv4 - with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): - assert netutils.add_multicast_member(sock, interface) is False - - # ENODEV should return False for ipv6 - with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): - assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] - - # No IPv6 support should return False for IPv6 - with patch("socket.inet_pton", side_effect=OSError()): - assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] - - # No error should return True - with patch("socket.socket.setsockopt"): - assert netutils.add_multicast_member(sock, interface) is True - - # Ran out of IGMP memberships is forgiving and logs about igmp_max_memberships on linux - caplog.clear() - with ( - patch.object(sys, "platform", "linux"), - patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available")), - ): - assert netutils.add_multicast_member(sock, interface) is False - assert "No buffer space available" in caplog.text - assert "net.ipv4.igmp_max_memberships" in caplog.text - - # Ran out of IGMP memberships is forgiving and logs - caplog.clear() - with ( - patch.object(sys, "platform", "darwin"), - patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available")), - ): - assert netutils.add_multicast_member(sock, interface) is False - assert "No buffer space available" in caplog.text - assert "net.ipv4.igmp_max_memberships" not in caplog.text + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + interface = "127.0.0.1" + + # EPERM should always raise + with ( + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)), + ): + netutils.add_multicast_member(sock, interface) + + # EADDRINUSE should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRINUSE, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # EADDRNOTAVAIL should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EADDRNOTAVAIL, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # EINVAL should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # ENOPROTOOPT should return False + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENOPROTOOPT, None)): + assert netutils.add_multicast_member(sock, interface) is False + + # ENODEV should raise for ipv4 + with ( + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)), + ): + assert netutils.add_multicast_member(sock, interface) is False + + # ENODEV should return False for ipv6 + with patch("socket.socket.setsockopt", side_effect=OSError(errno.ENODEV, None)): + assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] + + # No IPv6 support should return False for IPv6 + with patch("socket.inet_pton", side_effect=OSError()): + assert netutils.add_multicast_member(sock, ("2001:db8::", 1, 1)) is False # type: ignore[arg-type] + + # No error should return True + with patch("socket.socket.setsockopt"): + assert netutils.add_multicast_member(sock, interface) is True + + # Ran out of IGMP memberships is forgiving and logs about igmp_max_memberships on linux + caplog.clear() + with ( + patch.object(sys, "platform", "linux"), + patch( + "socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available") + ), + ): + assert netutils.add_multicast_member(sock, interface) is False + assert "No buffer space available" in caplog.text + assert "net.ipv4.igmp_max_memberships" in caplog.text + + # Ran out of IGMP memberships is forgiving and logs + caplog.clear() + with ( + patch.object(sys, "platform", "darwin"), + patch( + "socket.socket.setsockopt", side_effect=OSError(errno.ENOBUFS, "No buffer space available") + ), + ): + assert netutils.add_multicast_member(sock, interface) is False + assert "No buffer space available" in caplog.text + assert "net.ipv4.igmp_max_memberships" not in caplog.text def test_bind_raises_skips_address(): From cb2f5b15403df8d4f8abb6f7dcac6d867756fb9a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 07:42:27 -1000 Subject: [PATCH 1257/1433] chore(pre-commit.ci): pre-commit autoupdate (#1566) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1faee0105..19efa1c7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.5.0 + rev: v4.6.0 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.4 + rev: v0.11.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From cc0f8350c30c82409b1a9bfecb19ff9b3368d6a7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 14 Apr 2025 23:10:36 +0200 Subject: [PATCH 1258/1433] fix: address non-working socket configuration (#1563) Co-authored-by: J. Nick Koston --- src/zeroconf/_utils/net.py | 97 ++++++++++++++++++++++--------------- tests/test_core.py | 19 ++++++-- tests/utils/test_net.py | 99 +++++++++++++++++++++++++++++--------- 3 files changed, 148 insertions(+), 67 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index e687ab60e..e67edf787 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -168,8 +168,17 @@ def normalize_interface_choice( result: list[str | tuple[tuple[str, int, int], int]] = [] if choice is InterfaceChoice.Default: if ip_version != IPVersion.V4Only: - # IPv6 multicast uses interface 0 to mean the default - result.append((("", 0, 0), 0)) + # IPv6 multicast uses interface 0 to mean the default. However, + # the default interface can't be used for outgoing IPv6 multicast + # requests. In a way, interface choice default isn't really working + # with IPv6. Inform the user accordingly. + message = ( + "IPv6 multicast requests can't be sent using default interface. " + "Use V4Only, InterfaceChoice.All or an explicit list of interfaces." + ) + log.error(message) + warnings.warn(message, DeprecationWarning, stacklevel=2) + result.append((("::", 0, 0), 0)) if ip_version != IPVersion.V6Only: result.append("0.0.0.0") elif choice is InterfaceChoice.All: @@ -220,28 +229,33 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: raise -def set_mdns_port_socket_options_for_ip_version( +def set_respond_socket_multicast_options( s: socket.socket, - bind_addr: tuple[str] | tuple[str, int, int], ip_version: IPVersion, ) -> None: - """Set ttl/hops and loop for mdns port.""" - if ip_version != IPVersion.V6Only: - ttl = struct.pack(b"B", 255) - loop = struct.pack(b"B", 1) + """Set ttl/hops and loop for mDNS respond socket.""" + if ip_version == IPVersion.V4Only: # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and # IP_MULTICAST_LOOP socket options as an unsigned char. - try: - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) - s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) - except OSError as e: - if bind_addr[0] != "" or get_errno(e) != errno.EINVAL: # Fails to set on MacOS - raise - - if ip_version != IPVersion.V4Only: + ttl = struct.pack(b"B", 255) + loop = struct.pack(b"B", 1) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop) + elif ip_version == IPVersion.V6Only: # However, char doesn't work here (at least on Linux) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True) + else: + # A shared sender socket is not really possible, especially with link-local + # multicast addresses (ff02::/16), the kernel needs to know which interface + # to use for routing. + # + # It seems that macOS even refuses to take IPv4 socket options if this is an + # AF_INET6 socket. + # + # In theory we could reconfigure the socket on each send, but that is not + # really practical for Python Zerconf. + raise RuntimeError("Dual-stack responder socket not supported") def new_socket( @@ -266,14 +280,12 @@ def new_socket( s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) set_so_reuseport_if_available(s) - if port == _MDNS_PORT: - set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version) - if apple_p2p: # SO_RECV_ANYIF = 0x1104 # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) + # Bind expects (address, port) for AF_INET and (address, port, flowinfo, scope_id) for AF_INET6 bind_tup = (bind_addr[0], port, *bind_addr[1:]) try: s.bind(bind_tup) @@ -392,15 +404,27 @@ def add_multicast_member( def new_respond_socket( interface: str | tuple[tuple[str, int, int], int], apple_p2p: bool = False, + unicast: bool = False, ) -> socket.socket | None: + """Create interface specific socket for responding to multicast queries.""" is_v6 = isinstance(interface, tuple) + + # For response sockets: + # - Bind explicitly to the interface address + # - Use ephemeral ports if in unicast mode + # - Create socket according to the interface IP type (IPv4 or IPv6) respond_socket = new_socket( + bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), + port=0 if unicast else _MDNS_PORT, ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), apple_p2p=apple_p2p, - bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), ) + if unicast: + return respond_socket + if not respond_socket: return None + log.debug("Configuring socket %s with multicast interface %s", respond_socket, interface) if is_v6: iface_bin = struct.pack("@I", cast(int, interface[1])) @@ -411,6 +435,7 @@ def new_respond_socket( socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface)), ) + set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only) return respond_socket @@ -423,33 +448,27 @@ def create_sockets( if unicast: listen_socket = None else: - listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",)) + listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) - # If we are using InterfaceChoice.Default we can use + # If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use # a single socket to listen and respond. - if not unicast and interfaces is InterfaceChoice.Default: - for i in normalized_interfaces: - add_multicast_member(cast(socket.socket, listen_socket), i) + if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All: + for interface in normalized_interfaces: + add_multicast_member(cast(socket.socket, listen_socket), interface) + # Sent responder socket options to the dual-use listen socket + set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version) return listen_socket, [cast(socket.socket, listen_socket)] respond_sockets = [] - for i in normalized_interfaces: - if not unicast: - if add_multicast_member(cast(socket.socket, listen_socket), i): - respond_socket = new_respond_socket(i, apple_p2p=apple_p2p) - else: - respond_socket = None - else: - is_v6 = isinstance(i, tuple) - respond_socket = new_socket( - port=0, - ip_version=IPVersion.V6Only if is_v6 else IPVersion.V4Only, - apple_p2p=apple_p2p, - bind_addr=cast(tuple[tuple[str, int, int], int], i)[0] if is_v6 else (cast(str, i),), - ) + for interface in normalized_interfaces: + # Only create response socket if unicast or becoming multicast member was successful + if not unicast and not add_multicast_member(cast(socket.socket, listen_socket), interface): + continue + + respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=unicast) if respond_socket is not None: respond_sockets.append(respond_socket) diff --git a/tests/test_core.py b/tests/test_core.py index fcfdf4249..8c53d2070 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -11,6 +11,7 @@ import time import unittest import unittest.mock +import warnings from typing import cast from unittest.mock import AsyncMock, Mock, patch @@ -87,16 +88,26 @@ def test_close_multiple_times(self): def test_launch_and_close_v4_v6(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) - rv.close() + with warnings.catch_warnings(record=True) as warned: + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) + rv.close() + first_warning = warned[0] + assert "IPv6 multicast requests can't be sent using default interface" in str( + first_warning.message + ) @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v6_only(self): rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() - rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) - rv.close() + with warnings.catch_warnings(record=True) as warned: + rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) + rv.close() + first_warning = warned[0] + assert "IPv6 multicast requests can't be sent using default interface" in str( + first_warning.message + ) @unittest.skipIf(sys.platform == "darwin", reason="apple_p2p failure path not testable on mac") def test_launch_and_close_apple_p2p_not_mac(self): diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 2ed0c6f28..7de106618 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -7,7 +7,7 @@ import sys import unittest import warnings -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import ifaddr import pytest @@ -20,11 +20,11 @@ def _generate_mock_adapters(): mock_lo0 = Mock(spec=ifaddr.Adapter) mock_lo0.nice_name = "lo0" - mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] + mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0"), ifaddr.IP(("::1", 0, 0), 128, "lo")] mock_lo0.index = 0 mock_eth0 = Mock(spec=ifaddr.Adapter) mock_eth0.nice_name = "eth0" - mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0"), ifaddr.IP(("fd00:db8::", 1, 1), 8, "eth0")] mock_eth0.index = 1 mock_eth1 = Mock(spec=ifaddr.Adapter) mock_eth1.nice_name = "eth1" @@ -65,7 +65,7 @@ def test_get_all_addresses_v6() -> None: ): addresses = get_all_addresses_v6() assert isinstance(addresses, list) - assert len(addresses) == 1 + assert len(addresses) == 3 assert len(warned) == 1 first_warning = warned[0] assert "get_all_addresses_v6 is deprecated" in str(first_warning.message) @@ -200,28 +200,20 @@ def test_set_so_reuseport_if_available_not_present(): netutils.set_so_reuseport_if_available(sock) -def test_set_mdns_port_socket_options_for_ip_version(): +def test_set_respond_socket_multicast_options(): """Test OSError with errno with EINVAL and bind address ''. from setsockopt IP_MULTICAST_TTL does not raise.""" - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - # Should raise on EPERM always - with ( - pytest.raises(OSError), - patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)), - ): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) - - # Should raise on EINVAL always when bind address is not '' - with ( - pytest.raises(OSError), - patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)), - ): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only) + # Should raise on EINVAL always + with ( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock, + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)), + ): + netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only) - # Should not raise on EINVAL when bind address is '' - with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)): - netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only) + with pytest.raises(RuntimeError): + netutils.set_respond_socket_multicast_options(sock, r.IPVersion.All) def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None: @@ -352,8 +344,8 @@ def test_new_respond_socket_new_socket_returns_none(): assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] -def test_create_sockets(): - """Test create_sockets with unicast and IPv4.""" +def test_create_sockets_interfaces_all_unicast(): + """Test create_sockets with unicast.""" with ( patch("zeroconf._utils.net.new_socket") as mock_new_socket, @@ -382,3 +374,62 @@ def test_create_sockets(): apple_p2p=False, bind_addr=("192.168.1.5",), ) + + +def test_create_sockets_interfaces_all() -> None: + """Test create_sockets with all interfaces. + + Tests if a responder socket is created for every successful multicast + join. + """ + adapters = _generate_mock_adapters() + + # Additional IPv6 addresses usually fail to add membership + failure_interface = ("fd00:db8::", 1, 1) + + expected_calls = [] + for adapter in adapters: + for ip in adapter.ips: + if ip.ip == failure_interface: + continue + + if ip.is_IPv4: + bind_addr = (ip.ip,) + ip_version = r.IPVersion.V4Only + else: + bind_addr = ip.ip + ip_version = r.IPVersion.V6Only + + expected_calls.append( + call( + port=5353, + ip_version=ip_version, + apple_p2p=False, + bind_addr=bind_addr, + ) + ) + + def _patched_add_multicast_member(sock, interface): + return interface[0] != failure_interface + + with ( + patch("zeroconf._utils.net.new_socket") as mock_new_socket, + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=adapters, + ), + patch("zeroconf._utils.net.add_multicast_member", side_effect=_patched_add_multicast_member), + ): + mock_socket = Mock(spec=socket.socket) + mock_new_socket.return_value = mock_socket + + r.create_sockets(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) + + def call_to_tuple(c): + return (c.args, tuple(sorted(c.kwargs.items()))) + + # Exclude first new_socket call as this is the listen socket + actual_calls_set = {call_to_tuple(c) for c in mock_new_socket.call_args_list[1:]} + expected_calls_set = {call_to_tuple(c) for c in expected_calls} + + assert actual_calls_set == expected_calls_set From d2517387dccf8b55b71bbbc62919ded55c8359d2 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 14 Apr 2025 21:20:26 +0000 Subject: [PATCH 1259/1433] 0.146.5 Automatically generated by python-semantic-release --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c56284d2..6d107aa50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # CHANGELOG +## v0.146.5 (2025-04-14) + +### Bug Fixes + +- Address non-working socket configuration + ([#1563](https://github.com/python-zeroconf/python-zeroconf/pull/1563), + [`cc0f835`](https://github.com/python-zeroconf/python-zeroconf/commit/cc0f8350c30c82409b1a9bfecb19ff9b3368d6a7)) + +Co-authored-by: J. Nick Koston + + ## v0.146.4 (2025-04-14) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index e4de325f4..189d9ddcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.4" +version = "0.146.5" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 89b622c2c..2449e835d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.4" +__version__ = "0.146.5" __license__ = "LGPL" From a11abc45fe2d9ebc5574092f9d4b3048ff3833fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Apr 2025 08:37:59 -1000 Subject: [PATCH 1260/1433] chore(pre-commit.ci): pre-commit autoupdate (#1570) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 19efa1c7f..cc85aa90b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.5 + rev: v0.11.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From b6d5aa36444cb30c87a17903021f041b4dbbe252 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Apr 2025 08:38:12 -1000 Subject: [PATCH 1261/1433] chore(deps-dev): bump setuptools from 78.1.0 to 79.0.0 (#1569) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 367374ed6..6fa6edceb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -826,14 +826,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "78.1.0" +version = "79.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8"}, - {file = "setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54"}, + {file = "setuptools-79.0.0-py3-none-any.whl", hash = "sha256:b9ab3a104bedb292323f53797b00864e10e434a3ab3906813a7169e4745b912a"}, + {file = "setuptools-79.0.0.tar.gz", hash = "sha256:9828422e7541213b0aacb6e10bbf9dd8febeaa45a48570e09b6d100e063fc9f9"}, ] [package.extras] @@ -1127,4 +1127,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "e3c96e694e9c149b96323081d51675d7a9d5ad8243f4338ff149e643a65417cb" +content-hash = "bcb9007a7aedbd388c0e4a757d21ccb2443fe58d07e8bc57493ee4d5f54eb998" diff --git a/pyproject.toml b/pyproject.toml index 189d9ddcb..96c0aec05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.27.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<79.0.0" +setuptools = ">=65.6.3,<80.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" From 2874924c27d822fd6eaea12126e071b60effb6fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 27 Apr 2025 23:31:18 -0500 Subject: [PATCH 1262/1433] chore(deps-dev): bump setuptools from 79.0.0 to 80.0.0 (#1571) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6fa6edceb..13e12b6ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -826,14 +826,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "79.0.0" +version = "80.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-79.0.0-py3-none-any.whl", hash = "sha256:b9ab3a104bedb292323f53797b00864e10e434a3ab3906813a7169e4745b912a"}, - {file = "setuptools-79.0.0.tar.gz", hash = "sha256:9828422e7541213b0aacb6e10bbf9dd8febeaa45a48570e09b6d100e063fc9f9"}, + {file = "setuptools-80.0.0-py3-none-any.whl", hash = "sha256:a38f898dcd6e5380f4da4381a87ec90bd0a7eec23d204a5552e80ee3cab6bd27"}, + {file = "setuptools-80.0.0.tar.gz", hash = "sha256:c40a5b3729d58dd749c0f08f1a07d134fb8a0a3d7f87dc33e7c5e1f762138650"}, ] [package.extras] @@ -1127,4 +1127,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "bcb9007a7aedbd388c0e4a757d21ccb2443fe58d07e8bc57493ee4d5f54eb998" +content-hash = "972988da838067a7f2d12b8212ce54ba946cb38a4f63576a520dd1ed40ac3e9b" diff --git a/pyproject.toml b/pyproject.toml index 96c0aec05..a13905020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" pytest-asyncio = ">=0.20.3,<0.27.0" cython = "^3.0.5" -setuptools = ">=65.6.3,<80.0.0" +setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = "^3.1.0" From cb54a65cd1b9a80bf0c19c3e274adf20703cd783 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:46:38 -0400 Subject: [PATCH 1263/1433] chore(pre-commit.ci): pre-commit autoupdate (#1572) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc85aa90b..8ad48a33e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.6 + rev: v0.11.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From f5c15e9bc412936a6fc943771ea0d66cba73e050 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 09:49:23 +0200 Subject: [PATCH 1264/1433] chore(ci): bump the github-actions group with 4 updates (#1573) --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe20f82a..5e8d1ef03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -69,7 +69,7 @@ jobs: - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" @@ -87,7 +87,7 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5 + uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -96,7 +96,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Setup Python 3.13 - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.13 - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 @@ -234,7 +234,7 @@ jobs: ref: "master" # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Set up QEMU @@ -268,7 +268,7 @@ jobs: fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@d04cacbc9866d432033b1d09142936e6a0e2121a # v2.23.2 + uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} @@ -288,7 +288,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir From 02eef34ca5df803b05ff337a9103d7994458988d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Sat, 3 May 2025 16:50:43 +0200 Subject: [PATCH 1265/1433] chore: some Cython 3.1.0rc1 build failures (#1574) --- src/zeroconf/_listener.pxd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 20084b47c..4cbc5d007 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -50,7 +50,7 @@ cdef class AsyncListener: cpdef _respond_query( self, - object msg, + DNSIncoming msg, object addr, object port, object transport, From 66b673cb768eaa15581ea60a8de590382806937c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 May 2025 10:03:16 -0500 Subject: [PATCH 1266/1433] chore: make zeroconf._services.info compatible with Cython 3.1 (#1576) --- src/zeroconf/_services/info.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fff9e1257..9b38de9d8 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -577,7 +577,7 @@ def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float def dns_addresses( self, - override_ttl: int | None = None, + override_ttl: int_ | None = None, version: IPVersion = IPVersion.All, ) -> list[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" @@ -585,7 +585,7 @@ def dns_addresses( def _dns_addresses( self, - override_ttl: int | None, + override_ttl: int_ | None, version: IPVersion, ) -> list[DNSAddress]: """Return matching DNSAddress from ServiceInfo.""" @@ -611,11 +611,11 @@ def _dns_addresses( self._dns_address_cache = records return records - def dns_pointer(self, override_ttl: int | None = None) -> DNSPointer: + def dns_pointer(self, override_ttl: int_ | None = None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" return self._dns_pointer(override_ttl) - def _dns_pointer(self, override_ttl: int | None) -> DNSPointer: + def _dns_pointer(self, override_ttl: int_ | None) -> DNSPointer: """Return DNSPointer from ServiceInfo.""" cacheable = override_ttl is None if self._dns_pointer_cache is not None and cacheable: @@ -632,11 +632,11 @@ def _dns_pointer(self, override_ttl: int | None) -> DNSPointer: self._dns_pointer_cache = record return record - def dns_service(self, override_ttl: int | None = None) -> DNSService: + def dns_service(self, override_ttl: int_ | None = None) -> DNSService: """Return DNSService from ServiceInfo.""" return self._dns_service(override_ttl) - def _dns_service(self, override_ttl: int | None) -> DNSService: + def _dns_service(self, override_ttl: int_ | None) -> DNSService: """Return DNSService from ServiceInfo.""" cacheable = override_ttl is None if self._dns_service_cache is not None and cacheable: @@ -659,11 +659,11 @@ def _dns_service(self, override_ttl: int | None) -> DNSService: self._dns_service_cache = record return record - def dns_text(self, override_ttl: int | None = None) -> DNSText: + def dns_text(self, override_ttl: int_ | None = None) -> DNSText: """Return DNSText from ServiceInfo.""" return self._dns_text(override_ttl) - def _dns_text(self, override_ttl: int | None) -> DNSText: + def _dns_text(self, override_ttl: int_ | None) -> DNSText: """Return DNSText from ServiceInfo.""" cacheable = override_ttl is None if self._dns_text_cache is not None and cacheable: @@ -680,11 +680,11 @@ def _dns_text(self, override_ttl: int | None) -> DNSText: self._dns_text_cache = record return record - def dns_nsec(self, missing_types: list[int], override_ttl: int | None = None) -> DNSNsec: + def dns_nsec(self, missing_types: list[int], override_ttl: int_ | None = None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return self._dns_nsec(missing_types, override_ttl) - def _dns_nsec(self, missing_types: list[int], override_ttl: int | None) -> DNSNsec: + def _dns_nsec(self, missing_types: list[int], override_ttl: int_ | None) -> DNSNsec: """Return DNSNsec from ServiceInfo.""" return DNSNsec( self._name, @@ -696,11 +696,11 @@ def _dns_nsec(self, missing_types: list[int], override_ttl: int | None) -> DNSNs 0.0, ) - def get_address_and_nsec_records(self, override_ttl: int | None = None) -> set[DNSRecord]: + def get_address_and_nsec_records(self, override_ttl: int_ | None = None) -> set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" return self._get_address_and_nsec_records(override_ttl) - def _get_address_and_nsec_records(self, override_ttl: int | None) -> set[DNSRecord]: + def _get_address_and_nsec_records(self, override_ttl: int_ | None) -> set[DNSRecord]: """Build a set of address records and NSEC records for non-present record types.""" cacheable = override_ttl is None if self._get_address_and_nsec_records_cache is not None and cacheable: From 5a72fd4ca0c10c9759341517c3bfb0fd0bf062c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 May 2025 10:09:58 -0500 Subject: [PATCH 1267/1433] chore: migrate TTL to only accept int (#1577) --- src/zeroconf/_cache.pxd | 2 +- src/zeroconf/_cache.py | 2 +- src/zeroconf/_dns.pxd | 18 +++++++++--------- src/zeroconf/_dns.py | 23 +++++++++++------------ src/zeroconf/_handlers/record_manager.pxd | 2 +- src/zeroconf/_services/browser.py | 3 +-- src/zeroconf/const.py | 2 +- tests/test_protocol.py | 2 +- 8 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 273d46c37..05a40c0f3 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -83,5 +83,5 @@ cdef class DNSCache: self, DNSRecord record, double now, - cython.float ttl + unsigned int ttl ) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index c8e2686ee..c7ca8472b 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -317,7 +317,7 @@ def async_mark_unique_records_older_than_1s_to_expire( # Expire in 1s self._async_set_created_ttl(record, now, 1) - def _async_set_created_ttl(self, record: DNSRecord, now: _float, ttl: _float) -> None: + def _async_set_created_ttl(self, record: DNSRecord, now: _float, ttl: _int) -> None: """Set the created time and ttl of a record.""" # It would be better if we made a copy instead of mutating the record # in place, but records currently don't have a copy method. diff --git a/src/zeroconf/_dns.pxd b/src/zeroconf/_dns.pxd index 5ff98a8d4..7ef1dbec9 100644 --- a/src/zeroconf/_dns.pxd +++ b/src/zeroconf/_dns.pxd @@ -44,10 +44,10 @@ cdef class DNSQuestion(DNSEntry): cdef class DNSRecord(DNSEntry): - cdef public cython.float ttl + cdef public unsigned int ttl cdef public double created - cdef _fast_init_record(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, double created) + cdef _fast_init_record(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, double created) cdef bint _suppressed_by_answer(self, DNSRecord answer) @@ -66,7 +66,7 @@ cdef class DNSRecord(DNSEntry): cpdef bint is_recent(self, double now) - cdef _set_created_ttl(self, double now, cython.float ttl) + cdef _set_created_ttl(self, double now, unsigned int ttl) cdef class DNSAddress(DNSRecord): @@ -74,7 +74,7 @@ cdef class DNSAddress(DNSRecord): cdef public bytes address cdef public object scope_id - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, bytes address, object scope_id, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, bytes address, object scope_id, double created) cdef bint _eq(self, DNSAddress other) @@ -87,7 +87,7 @@ cdef class DNSHinfo(DNSRecord): cdef public str cpu cdef public str os - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str cpu, str os, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, str cpu, str os, double created) cdef bint _eq(self, DNSHinfo other) @@ -99,7 +99,7 @@ cdef class DNSPointer(DNSRecord): cdef public str alias cdef public str alias_key - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str alias, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, str alias, double created) cdef bint _eq(self, DNSPointer other) @@ -110,7 +110,7 @@ cdef class DNSText(DNSRecord): cdef public cython.int _hash cdef public bytes text - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, bytes text, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, bytes text, double created) cdef bint _eq(self, DNSText other) @@ -125,7 +125,7 @@ cdef class DNSService(DNSRecord): cdef public str server cdef public str server_key - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, cython.uint priority, cython.uint weight, cython.uint port, str server, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, cython.uint priority, cython.uint weight, cython.uint port, str server, double created) cdef bint _eq(self, DNSService other) @@ -137,7 +137,7 @@ cdef class DNSNsec(DNSRecord): cdef public str next_name cdef public cython.list rdtypes - cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, cython.float ttl, str next_name, cython.list rdtypes, double created) + cdef _fast_init(self, str name, cython.uint type_, cython.uint class_, unsigned int ttl, str next_name, cython.list rdtypes, double created) cdef bint _eq(self, DNSNsec other) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 591eb0183..60df14b11 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -166,18 +166,17 @@ class DNSRecord(DNSEntry): __slots__ = ("created", "ttl") - # TODO: Switch to just int ttl def __init__( self, name: str, type_: int, class_: int, - ttl: float | int, + ttl: _int, created: float | None = None, ) -> None: self._fast_init_record(name, type_, class_, ttl, created or current_time_millis()) - def _fast_init_record(self, name: str, type_: _int, class_: _int, ttl: _float, created: _float) -> None: + def _fast_init_record(self, name: str, type_: _int, class_: _int, ttl: _int, created: _float) -> None: """Fast init for reuse.""" self._fast_init_entry(name, type_, class_) self.ttl = ttl @@ -227,7 +226,7 @@ def is_recent(self, now: _float) -> bool: """Returns true if the record more than one quarter of its TTL remaining.""" return self.created + (_RECENT_TIME_MS * self.ttl) > now - def _set_created_ttl(self, created: _float, ttl: float | int) -> None: + def _set_created_ttl(self, created: _float, ttl: _int) -> None: """Set the created and ttl of a record.""" # It would be better if we made a copy instead of mutating the record # in place, but records currently don't have a copy method. @@ -266,7 +265,7 @@ def _fast_init( name: str, type_: _int, class_: _int, - ttl: _float, + ttl: _int, address: bytes, scope_id: _int | None, created: _float, @@ -327,7 +326,7 @@ def __init__( self._fast_init(name, type_, class_, ttl, cpu, os, created or current_time_millis()) def _fast_init( - self, name: str, type_: _int, class_: _int, ttl: _float, cpu: str, os: str, created: _float + self, name: str, type_: _int, class_: _int, ttl: _int, cpu: str, os: str, created: _float ) -> None: """Fast init for reuse.""" self._fast_init_record(name, type_, class_, ttl, created) @@ -374,7 +373,7 @@ def __init__( self._fast_init(name, type_, class_, ttl, alias, created or current_time_millis()) def _fast_init( - self, name: str, type_: _int, class_: _int, ttl: _float, alias: str, created: _float + self, name: str, type_: _int, class_: _int, ttl: _int, alias: str, created: _float ) -> None: self._fast_init_record(name, type_, class_, ttl, created) self.alias = alias @@ -429,7 +428,7 @@ def __init__( self._fast_init(name, type_, class_, ttl, text, created or current_time_millis()) def _fast_init( - self, name: str, type_: _int, class_: _int, ttl: _float, text: bytes, created: _float + self, name: str, type_: _int, class_: _int, ttl: _int, text: bytes, created: _float ) -> None: self._fast_init_record(name, type_, class_, ttl, created) self.text = text @@ -468,7 +467,7 @@ def __init__( name: str, type_: int, class_: int, - ttl: float | int, + ttl: int, priority: int, weight: int, port: int, @@ -484,7 +483,7 @@ def _fast_init( name: str, type_: _int, class_: _int, - ttl: _float, + ttl: _int, priority: _int, weight: _int, port: _int, @@ -539,7 +538,7 @@ def __init__( name: str, type_: int, class_: int, - ttl: int | float, + ttl: _int, next_name: str, rdtypes: list[int], created: float | None = None, @@ -551,7 +550,7 @@ def _fast_init( name: str, type_: _int, class_: _int, - ttl: _float, + ttl: _int, next_name: str, rdtypes: list[_int], created: _float, diff --git a/src/zeroconf/_handlers/record_manager.pxd b/src/zeroconf/_handlers/record_manager.pxd index 37232b131..b9bde975d 100644 --- a/src/zeroconf/_handlers/record_manager.pxd +++ b/src/zeroconf/_handlers/record_manager.pxd @@ -8,7 +8,7 @@ from .._updates cimport RecordUpdateListener from .._utils.time cimport current_time_millis from .._record_update cimport RecordUpdate -cdef cython.float _DNS_PTR_MIN_TTL +cdef unsigned int _DNS_PTR_MIN_TTL cdef cython.uint _TYPE_PTR cdef object _ADDRESS_RECORD_TYPES cdef bint TYPE_CHECKING diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index ab8c050d9..1f60e8f9c 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -394,9 +394,8 @@ def _schedule_ptr_refresh( refresh_time_millis: float_, ) -> None: """Schedule a query for a pointer.""" - ttl = int(pointer.ttl) if isinstance(pointer.ttl, float) else pointer.ttl scheduled_ptr_query = _ScheduledPTRQuery( - pointer.alias, pointer.name, ttl, expire_time_millis, refresh_time_millis + pointer.alias, pointer.name, pointer.ttl, expire_time_millis, refresh_time_millis ) self._schedule_ptr_query(scheduled_ptr_query) diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 3b4b3abcc..c3a62875a 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -57,7 +57,7 @@ # ServiceBrowsers generating excessive queries refresh queries. # Apple uses a 15s minimum TTL, however we do not have the same # level of rate limit and safe guards so we use 1/4 of the recommended value -_DNS_PTR_MIN_TTL = _DNS_OTHER_TTL / 4 +_DNS_PTR_MIN_TTL = 1125 _DNS_PACKET_HEADER_LEN = 12 diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 08d7e600a..edd87c2e7 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -196,7 +196,7 @@ def test_suppress_answer(self): "testname2.local.", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE, - const._DNS_HOST_TTL / 2, + int(const._DNS_HOST_TTL / 2), 0, 0, 80, From daaf8d6981c778fe4ba0a63371d9368cf217891a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 May 2025 10:19:16 -0500 Subject: [PATCH 1268/1433] feat: Cython 3.1 support (#1578) From 1569383c6cf8ce8977427cfdaf5c7104ce52ab08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 May 2025 11:04:00 -0500 Subject: [PATCH 1269/1433] feat: cython 3.11 support (#1579) From 1d9c94a82d8da16b8f5355131e6167b69293da6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 May 2025 11:12:27 -0500 Subject: [PATCH 1270/1433] feat: add cython 3.1 support (#1580) From 4cf513f69169b5992a73fe0bc431ec17f8f5040d Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 3 May 2025 16:22:31 +0000 Subject: [PATCH 1271/1433] 0.147.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d107aa50..d8a3d4cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # CHANGELOG +## v0.147.0 (2025-05-03) + +### Features + +- Add cython 3.1 support ([#1580](https://github.com/python-zeroconf/python-zeroconf/pull/1580), + [`1d9c94a`](https://github.com/python-zeroconf/python-zeroconf/commit/1d9c94a82d8da16b8f5355131e6167b69293da6c)) + +- Cython 3.1 support ([#1578](https://github.com/python-zeroconf/python-zeroconf/pull/1578), + [`daaf8d6`](https://github.com/python-zeroconf/python-zeroconf/commit/daaf8d6981c778fe4ba0a63371d9368cf217891a)) + +- Cython 3.11 support ([#1579](https://github.com/python-zeroconf/python-zeroconf/pull/1579), + [`1569383`](https://github.com/python-zeroconf/python-zeroconf/commit/1569383c6cf8ce8977427cfdaf5c7104ce52ab08)) + + ## v0.146.5 (2025-04-14) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index a13905020..d47a19667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.146.5" +version = "0.147.0" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2449e835d..439ffceb6 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.146.5" +__version__ = "0.147.0" __license__ = "LGPL" From f278ed994e73f4316cc410bcdc5023294329117f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 23:58:05 -0500 Subject: [PATCH 1272/1433] chore(deps-dev): bump setuptools from 80.0.0 to 80.3.1 (#1581) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 13e12b6ef..950e3cbd5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -826,14 +826,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.0.0" +version = "80.3.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.0.0-py3-none-any.whl", hash = "sha256:a38f898dcd6e5380f4da4381a87ec90bd0a7eec23d204a5552e80ee3cab6bd27"}, - {file = "setuptools-80.0.0.tar.gz", hash = "sha256:c40a5b3729d58dd749c0f08f1a07d134fb8a0a3d7f87dc33e7c5e1f762138650"}, + {file = "setuptools-80.3.1-py3-none-any.whl", hash = "sha256:ea8e00d7992054c4c592aeb892f6ad51fe1b4d90cc6947cc45c45717c40ec537"}, + {file = "setuptools-80.3.1.tar.gz", hash = "sha256:31e2c58dbb67c99c289f51c16d899afedae292b978f8051efaf6262d8212f927"}, ] [package.extras] From 53592faf8de7efbc8a8f12d333b4daffad035701 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 11:54:45 -0500 Subject: [PATCH 1273/1433] chore(pre-commit.ci): pre-commit autoupdate (#1582) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ad48a33e..429bea6ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.6.0 + rev: v4.6.1 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.7 + rev: v0.11.8 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 61a7b8bbf5b0f97aaa275ee9ee54f24c5fec772b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 May 2025 12:37:22 -0500 Subject: [PATCH 1274/1433] chore: fix mocking with PyPy and new Cython 3.1 (#1586) --- tests/test_handlers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ffa4ff88c..31354980a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1863,6 +1863,7 @@ async def test_response_aggregation_random_delay(): addresses=[socket.inet_aton("10.0.1.2")], ) mocked_zc = unittest.mock.MagicMock() + mocked_zc.loop = asyncio.get_running_loop() outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 500) now = current_time_millis() @@ -1930,6 +1931,7 @@ async def test_future_answers_are_removed_on_send(): addresses=[socket.inet_aton("10.0.1.3")], ) mocked_zc = unittest.mock.MagicMock() + mocked_zc.loop = asyncio.get_running_loop() outgoing_queue = MulticastOutgoingQueue(mocked_zc, 0, 0) now = current_time_millis() From e827cb1e2cb1ab2d4663e60068c1c7de6634c6ba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 13:02:38 -0500 Subject: [PATCH 1275/1433] chore(pre-commit.ci): pre-commit autoupdate (#1585) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 429bea6ea..50a1dd376 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.6.1 + rev: v4.7.0 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.8 + rev: v0.11.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 97c0ce6863a5d8476dd63a35670c39e38fdc1c63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 13:29:12 -0500 Subject: [PATCH 1276/1433] chore(deps-dev): bump pytest-timeout from 2.3.1 to 2.4.0 (#1583) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 950e3cbd5..0b5bd93e5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -769,14 +769,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" -version = "2.3.1" +version = "2.4.0" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, - {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, ] [package.dependencies] From 9acbd4cc58917a77d5b18bfeb87ce90c2ce1a1dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 13:29:27 -0500 Subject: [PATCH 1277/1433] chore(deps-dev): bump setuptools from 80.3.1 to 80.4.0 (#1584) --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0b5bd93e5..501a47e14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -826,14 +826,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.3.1" +version = "80.4.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.3.1-py3-none-any.whl", hash = "sha256:ea8e00d7992054c4c592aeb892f6ad51fe1b4d90cc6947cc45c45717c40ec537"}, - {file = "setuptools-80.3.1.tar.gz", hash = "sha256:31e2c58dbb67c99c289f51c16d899afedae292b978f8051efaf6262d8212f927"}, + {file = "setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2"}, + {file = "setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006"}, ] [package.extras] From 6360ec09368d91ad15ff773baf193606142486d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 01:53:40 -0400 Subject: [PATCH 1278/1433] chore(deps-dev): bump setuptools from 80.4.0 to 80.7.1 (#1587) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 501a47e14..c9e4642fa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -826,14 +826,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.4.0" +version = "80.7.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2"}, - {file = "setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006"}, + {file = "setuptools-80.7.1-py3-none-any.whl", hash = "sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009"}, + {file = "setuptools-80.7.1.tar.gz", hash = "sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552"}, ] [package.extras] From 9b1e55ecbf6d787f5ead341ca3b51b817350abda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 25 May 2025 23:48:24 -0500 Subject: [PATCH 1279/1433] chore(deps-dev): bump cython from 3.0.12 to 3.1.1 (#1590) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 130 ++++++++++++++++++++++++++-------------------------- 1 file changed, 64 insertions(+), 66 deletions(-) diff --git a/poetry.lock b/poetry.lock index c9e4642fa..c5862217a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -315,76 +315,74 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.0.12" +version = "3.1.1" description = "The Cython compiler for writing C extensions in the Python language." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "Cython-3.0.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba67eee9413b66dd9fbacd33f0bc2e028a2a120991d77b5fd4b19d0b1e4039b9"}, - {file = "Cython-3.0.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee2717e5b5f7d966d0c6e27d2efe3698c357aa4d61bb3201997c7a4f9fe485a"}, - {file = "Cython-3.0.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cffc3464f641c8d0dda942c7c53015291beea11ec4d32421bed2f13b386b819"}, - {file = "Cython-3.0.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d3a8f81980ffbd74e52f9186d8f1654e347d0c44bfea6b5997028977f481a179"}, - {file = "Cython-3.0.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8d32856716c369d01f2385ad9177cdd1a11079ac89ea0932dc4882de1aa19174"}, - {file = "Cython-3.0.12-cp310-cp310-win32.whl", hash = "sha256:712c3f31adec140dc60d064a7f84741f50e2c25a8edd7ae746d5eb4d3ef7072a"}, - {file = "Cython-3.0.12-cp310-cp310-win_amd64.whl", hash = "sha256:d6945694c5b9170cfbd5f2c0d00ef7487a2de7aba83713a64ee4ebce7fad9e05"}, - {file = "Cython-3.0.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feb86122a823937cc06e4c029d80ff69f082ebb0b959ab52a5af6cdd271c5dc3"}, - {file = "Cython-3.0.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfdbea486e702c328338314adb8e80f5f9741f06a0ae83aaec7463bc166d12e8"}, - {file = "Cython-3.0.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563de1728c8e48869d2380a1b76bbc1b1b1d01aba948480d68c1d05e52d20c92"}, - {file = "Cython-3.0.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d4576c1e1f6316282aa0b4a55139254fbed965cba7813e6d9900d3092b128"}, - {file = "Cython-3.0.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e5eadef80143026944ea8f9904715a008f5108d1d644a89f63094cc37351e73"}, - {file = "Cython-3.0.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a93cbda00a5451175b97dea5a9440a3fcee9e54b4cba7a7dbcba9a764b22aec"}, - {file = "Cython-3.0.12-cp311-cp311-win32.whl", hash = "sha256:3109e1d44425a2639e9a677b66cd7711721a5b606b65867cb2d8ef7a97e2237b"}, - {file = "Cython-3.0.12-cp311-cp311-win_amd64.whl", hash = "sha256:d4b70fc339adba1e2111b074ee6119fe9fd6072c957d8597bce9a0dd1c3c6784"}, - {file = "Cython-3.0.12-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fe030d4a00afb2844f5f70896b7f2a1a0d7da09bf3aa3d884cbe5f73fff5d310"}, - {file = "Cython-3.0.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7fec4f052b8fe173fe70eae75091389955b9a23d5cec3d576d21c5913b49d47"}, - {file = "Cython-3.0.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0faa5e39e5c8cdf6f9c3b1c3f24972826e45911e7f5b99cf99453fca5432f45e"}, - {file = "Cython-3.0.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d53de996ed340e9ab0fc85a88aaa8932f2591a2746e1ab1c06e262bd4ec4be7"}, - {file = "Cython-3.0.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea3a0e19ab77266c738aa110684a753a04da4e709472cadeff487133354d6ab8"}, - {file = "Cython-3.0.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c151082884be468f2f405645858a857298ac7f7592729e5b54788b5c572717ba"}, - {file = "Cython-3.0.12-cp312-cp312-win32.whl", hash = "sha256:3083465749911ac3b2ce001b6bf17f404ac9dd35d8b08469d19dc7e717f5877a"}, - {file = "Cython-3.0.12-cp312-cp312-win_amd64.whl", hash = "sha256:c0b91c7ebace030dd558ea28730de8c580680b50768e5af66db2904a3716c3e3"}, - {file = "Cython-3.0.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4ee6f1ea1bead8e6cbc4e64571505b5d8dbdb3b58e679d31f3a84160cebf1a1a"}, - {file = "Cython-3.0.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57aefa6d3341109e46ec1a13e3a763aaa2cbeb14e82af2485b318194be1d9170"}, - {file = "Cython-3.0.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:879ae9023958d63c0675015369384642d0afb9c9d1f3473df9186c42f7a9d265"}, - {file = "Cython-3.0.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36fcd584dae547de6f095500a380f4a0cce72b7a7e409e9ff03cb9beed6ac7a1"}, - {file = "Cython-3.0.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62b79dcc0de49efe9e84b9d0e2ae0a6fc9b14691a65565da727aa2e2e63c6a28"}, - {file = "Cython-3.0.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4aa255781b093a8401109d8f2104bbb2e52de7639d5896aefafddc85c30e0894"}, - {file = "Cython-3.0.12-cp313-cp313-win32.whl", hash = "sha256:77d48f2d4bab9fe1236eb753d18f03e8b2619af5b6f05d51df0532a92dfb38ab"}, - {file = "Cython-3.0.12-cp313-cp313-win_amd64.whl", hash = "sha256:86c304b20bd57c727c7357e90d5ba1a2b6f1c45492de2373814d7745ef2e63b4"}, - {file = "Cython-3.0.12-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ff5c0b6a65b08117d0534941d404833d516dac422eee88c6b4fd55feb409a5ed"}, - {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680f1d6ed4436ae94805db264d6155ed076d2835d84f20dcb31a7a3ad7f8668c"}, - {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc24609613fa06d0d896309f7164ba168f7e8d71c1e490ed2a08d23351c3f41"}, - {file = "Cython-3.0.12-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1879c073e2b34924ce9b7ca64c212705dcc416af4337c45f371242b2e5f6d32"}, - {file = "Cython-3.0.12-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:bfb75123dd4ff767baa37d7036da0de2dfb6781ff256eef69b11b88b9a0691d1"}, - {file = "Cython-3.0.12-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:f39640f8df0400cde6882e23c734f15bb8196de0a008ae5dc6c8d1ec5957d7c8"}, - {file = "Cython-3.0.12-cp36-cp36m-win32.whl", hash = "sha256:8c9efe9a0895abee3cadfdad4130b30f7b5e57f6e6a51ef2a44f9fc66a913880"}, - {file = "Cython-3.0.12-cp36-cp36m-win_amd64.whl", hash = "sha256:63d840f2975e44d74512f8f34f1f7cb8121c9428e26a3f6116ff273deb5e60a2"}, - {file = "Cython-3.0.12-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:75c5acd40b97cff16fadcf6901a91586cbca5dcdba81f738efaf1f4c6bc8dccb"}, - {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62564457851db1c40399bd95a5346b9bb99e17a819bf583b362f418d8f3457a"}, - {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ccd1228cc203b1f1b8a3d403f5a20ad1c40e5879b3fbf5851ce09d948982f2c"}, - {file = "Cython-3.0.12-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25529ee948f44d9a165ff960c49d4903267c20b5edf2df79b45924802e4cca6e"}, - {file = "Cython-3.0.12-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:90cf599372c5a22120609f7d3a963f17814799335d56dd0dcf8fe615980a8ae1"}, - {file = "Cython-3.0.12-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9f8c48748a9c94ea5d59c26ab49ad0fad514d36f894985879cf3c3ca0e600bf4"}, - {file = "Cython-3.0.12-cp37-cp37m-win32.whl", hash = "sha256:3e4fa855d98bc7bd6a2049e0c7dc0dcf595e2e7f571a26e808f3efd84d2db374"}, - {file = "Cython-3.0.12-cp37-cp37m-win_amd64.whl", hash = "sha256:120681093772bf3600caddb296a65b352a0d3556e962b9b147efcfb8e8c9801b"}, - {file = "Cython-3.0.12-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:731d719423e041242c9303c80cae4327467299b90ffe62d4cc407e11e9ea3160"}, - {file = "Cython-3.0.12-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3238a29f37999e27494d120983eca90d14896b2887a0bd858a381204549137a"}, - {file = "Cython-3.0.12-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b588c0a089a9f4dd316d2f9275230bad4a7271e5af04e1dc41d2707c816be44b"}, - {file = "Cython-3.0.12-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab9f5198af74eb16502cc143cdde9ca1cbbf66ea2912e67440dd18a36e3b5fa"}, - {file = "Cython-3.0.12-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8ee841c0e114efa1e849c281ac9b8df8aa189af10b4a103b1c5fd71cbb799679"}, - {file = "Cython-3.0.12-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:43c48b5789398b228ea97499f5b864843ba9b1ab837562a9227c6f58d16ede8b"}, - {file = "Cython-3.0.12-cp38-cp38-win32.whl", hash = "sha256:5e5f17c48a4f41557fbcc7ee660ccfebe4536a34c557f553b6893c1b3c83df2d"}, - {file = "Cython-3.0.12-cp38-cp38-win_amd64.whl", hash = "sha256:309c081057930bb79dc9ea3061a1af5086c679c968206e9c9c2ec90ab7cb471a"}, - {file = "Cython-3.0.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54115fcc126840926ff3b53cfd2152eae17b3522ae7f74888f8a41413bd32f25"}, - {file = "Cython-3.0.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:629db614b9c364596d7c975fa3fb3978e8c5349524353dbe11429896a783fc1e"}, - {file = "Cython-3.0.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af081838b0f9e12a83ec4c3809a00a64c817f489f7c512b0e3ecaf5f90a2a816"}, - {file = "Cython-3.0.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ce459808f7d8d5d4007bc5486fe50532529096b43957af6cbffcb4d9cc5c8d"}, - {file = "Cython-3.0.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d6c6cd6a75c8393e6805d17f7126b96a894f310a1a9ea91c47d141fb9341bfa8"}, - {file = "Cython-3.0.12-cp39-cp39-win32.whl", hash = "sha256:a4032e48d4734d2df68235d21920c715c451ac9de15fa14c71b378e8986b83be"}, - {file = "Cython-3.0.12-cp39-cp39-win_amd64.whl", hash = "sha256:dcdc3e5d4ce0e7a4af6903ed580833015641e968d18d528d8371e2435a34132c"}, - {file = "Cython-3.0.12-py2.py3-none-any.whl", hash = "sha256:0038c9bae46c459669390e53a1ec115f8096b2e4647ae007ff1bf4e6dee92806"}, - {file = "cython-3.0.12.tar.gz", hash = "sha256:b988bb297ce76c671e28c97d017b95411010f7c77fa6623dd0bb47eed1aee1bc"}, + {file = "cython-3.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7adff5b42d2556d073e9f321c2faa639a17fb195ec1de130327f60ec209d8"}, + {file = "cython-3.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b61b99205308c96b1162de59bd67ecadcad3d166a4a1f03a3d9e826c39cd375"}, + {file = "cython-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d14186bd96783d13b8fd0e5b289f2e137a8a25479638b73a1c7e4a99a8d70753"}, + {file = "cython-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3ccec55e2a534a712db14c6617b66f65ad149c014fad518fc3920f6edde770"}, + {file = "cython-3.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a585796939b09b3205b1980e4a55e745c0251e45a5c637afbcac3c6cc9ad6f90"}, + {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3fa4bd840de63509c74867b4b092541720a01db1e07351206011c34e0777dc96"}, + {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b68f1bc80387554eb43f2b62795c173bed9e37201f39dc5084ac437c90a79c9f"}, + {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e851ab66a31794e40df1bc6f649cdc56c998c637f5a1b9410c97a90f6b6cb855"}, + {file = "cython-3.1.1-cp310-cp310-win32.whl", hash = "sha256:64915259276482fa23417b284d1fdc7e3a618ee2f819bb6ea7f974c075633df6"}, + {file = "cython-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dee554f0a589377bdaea0eb70e212bf3f35dc6a51a2aa86c9351345e21fd2f07"}, + {file = "cython-3.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c360823e1063784efc2335617e0f28573d7a594c5a8a05d85e850a9621cccb1f"}, + {file = "cython-3.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:12e00b88147b03c148a95365f89dc1c45a0fc52f9c35aa75ff770ef65b615839"}, + {file = "cython-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab644415458d782c16ba7252de9cec1e3125371641cafea2e53a8c1cf85dd58d"}, + {file = "cython-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5cb6c054daadaf01a88c8f49f3edd9e829c9b76a82cbb4269e3f9878254540b"}, + {file = "cython-3.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af8f62cc9339b75fe8434325083e6a7cae88c9c21efd74bbb6ba4e3623219469"}, + {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:689c1aad373556bd2ab1aa1c2dad8939a2891465a1fbd2cbbdd42b488fb40ec8"}, + {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:953046c190fa9ab9a09a546a909b847cdbb4c1fe34e9bfa4a15b6ee1585a86aa"}, + {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:755a991601b27dd3555310d0f95b19a05e622a80d7b4e7a91fa6f5f3ef3f3b80"}, + {file = "cython-3.1.1-cp311-cp311-win32.whl", hash = "sha256:83b2af5c327f7da4f08afc34fddfaf6d24fa0c000b6b70a527c8125e493b6080"}, + {file = "cython-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:141ffd6279411c562f6b707adc56b63e965a4fd7f21db83f5d4fcbd8c50ac546"}, + {file = "cython-3.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d7dc0e4d0cd491fac679a61e9ede348c64ca449f99a284f9a01851aa1dbc7f6"}, + {file = "cython-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd689910002adfac8734f237cdea1573e38345f27ed7fd445482813b65a29457"}, + {file = "cython-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10f0434916994fe213ea7749268b88d77e3ebcbd1b99542cf64bb7d180f45470"}, + {file = "cython-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:873aac4ac0b0fb197557c0ac15458b780b9221daa4a716881cbd1a9016c8459f"}, + {file = "cython-3.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23b886a6c8a50b1101ccef2f2f3dc9c699b77633ef5bb5007090226c2ad3f9c2"}, + {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dff0e7dd53a0ca35b64cda843253d5cac944db26663dc097b3a1adf2c49514ad"}, + {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f7954b0b4b3302655d3caa6924261de5907a4e129bc22ace52fe9ae0cd5a758"}, + {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfa500fd7ae95ca152a5f8062b870532fa3e27efcef6d00612e1f28b9f72615f"}, + {file = "cython-3.1.1-cp312-cp312-win32.whl", hash = "sha256:cd748fab8e4426dbcb2e0fa2979558333934d24365e0de5672fbabfe337d880c"}, + {file = "cython-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:307f216ed319ea07644f2ef9974406c830f01bc8e677e2147e9bfcdf9e3ca8ad"}, + {file = "cython-3.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb5661941707bd41ec7a9c273d698113ac50392444f785088e9d9706c6a5937b"}, + {file = "cython-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28b174f41718a7041cfbe0f48913020875ff1aaa4793942b2451ac6d2baf3f07"}, + {file = "cython-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c740a10cd0f50321d048c8ca318eefb4c42b8bffef982dcd89c946d374192702"}, + {file = "cython-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7da069ca769903c5dee56c5f7ab47b2b7b91030eee48912630db5f4f3ec5954a"}, + {file = "cython-3.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c640c0746d984789fe2787a098f06cda456ef2dd78b90164d17884b350839a"}, + {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:426d78565eb91d3366569b20e92b8f14bffef5f57b2acd05b60bbb9ce5c056a1"}, + {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b181158b5761bdaf40f6854f016ab7ddff64d3db4fca55cb3ca0f73813dd76d6"}, + {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7489559e6c5ecbba49d535c2e03cf77c2594a3190b6aca7da5b508ba1664a89a"}, + {file = "cython-3.1.1-cp313-cp313-win32.whl", hash = "sha256:263cb0e497910fb5e0a361ad1393b6d728b092178afecc56e8a786f3739960c3"}, + {file = "cython-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:e000f0533eedf3d6dfbe30bb3c58a054c58f0a7778390342fa577a0dc47adab3"}, + {file = "cython-3.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdf53dc4b2a13bd072d6c2c18ac073dbf0f798555bc27ba4f7546a275eb16a0f"}, + {file = "cython-3.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ce82070ccf92c3599d331b9eaaefd9d4562976fb86a8d6bccf05c4a0b8389f2a"}, + {file = "cython-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:020089f9c9f10269181f17660a2cada7d4577bd8eea24b7d2b14e6b64b6996be"}, + {file = "cython-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:402f86c00b08f875cd0990f0c4dc52eb3e0bc5d630066cdf3c798631976f1937"}, + {file = "cython-3.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54a8934cb3bf13b1f8f6cbdae8e382e25a26e67de08ea6ebfd0a467131b67227"}, + {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6ea77ad1e649cec38f8622ba28dcdfbe7bf519bc132abbcf5df759b3975b5a73"}, + {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7e5cad896af896482240979b996bf4136b0d18dc40c56c72c5641bf0ea085dfb"}, + {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16d9870654946375b28280371d370d541641d1071da123d0d64d2c7ebba0cc56"}, + {file = "cython-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8aaa29e763adf3496ab9d371e3caed8da5d3ce5ff8fb57433e2a2f2b5036e5c8"}, + {file = "cython-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:011cdcbf7725f0cfc1abc55ec83d326e788050711272131daf3cc24a19c34bb2"}, + {file = "cython-3.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:40f50b07c479eaf33981d81cad274c68cf9fb81dbe79cbf991f59491c88a4705"}, + {file = "cython-3.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a92f6bd395eadea6eed722a8188d3bdd49db1c9fa3c38710456d6148ab71bad7"}, + {file = "cython-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:268420b92307ae6c5a16e3cf0e2ba1ae3c861650e992893922a0ce08db07cfdb"}, + {file = "cython-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a19188ecd385cdc649e3fec370f38d5fd7f1651aeed0b3fb403180f38fc88e8a"}, + {file = "cython-3.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fff6526bb6f4eea615663117b86de6ede0d17c477b600d3d8302be3502bd3c3"}, + {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3192a61c2a532d3faccdff508bc8427de9530b587888218bfc0226eb33a84e11"}, + {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:56c6768a6f601f93daab7c2487f9f110548a896a91e00a6e119445ada2575323"}, + {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:50ad80e2f438e9127a87c10927e6ac16a987df39c248b19ab2cd31330129be3c"}, + {file = "cython-3.1.1-cp39-cp39-win32.whl", hash = "sha256:b194a65a0fd91f305d2d1e7010f44111774a28533e1e44dd2a76e7de81a219b9"}, + {file = "cython-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:c8b8be01fd40b3e38a76c60a524f956548a3a7566e5530a833a48a695f3d6c12"}, + {file = "cython-3.1.1-py3-none-any.whl", hash = "sha256:07621e044f332d18139df2ccfcc930151fd323c2f61a58c82f304cffc9eb5280"}, + {file = "cython-3.1.1.tar.gz", hash = "sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397"}, ] [[package]] From c0687c72c2650297ca9f8f562ddc4b48b0a51e98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 25 May 2025 23:48:36 -0500 Subject: [PATCH 1280/1433] chore(deps-dev): bump setuptools from 80.7.1 to 80.8.0 (#1591) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index c5862217a..803774776 100644 --- a/poetry.lock +++ b/poetry.lock @@ -824,14 +824,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.7.1" +version = "80.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.7.1-py3-none-any.whl", hash = "sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009"}, - {file = "setuptools-80.7.1.tar.gz", hash = "sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552"}, + {file = "setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0"}, + {file = "setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257"}, ] [package.extras] From 2d14af68598b16cd36fd1aa8f0b215010274d710 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 11:48:12 -0500 Subject: [PATCH 1281/1433] chore(pre-commit.ci): pre-commit autoupdate (#1588) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50a1dd376..923b38e6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.7.0 + rev: v4.8.2 hooks: - id: commitizen stages: [commit-msg] @@ -35,12 +35,12 @@ repos: args: ["--tab-width", "2"] files: ".(css|html|js|json|md|toml|yaml)$" - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.20.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.9 + rev: v0.11.11 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 7bd977521f644e406b88ca70a09fa87fe1d5c669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:47:47 -0500 Subject: [PATCH 1282/1433] chore(deps-dev): bump setuptools from 80.8.0 to 80.9.0 (#1593) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 803774776..c0ad30ef6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -824,14 +824,14 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.8.0" +version = "80.9.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0"}, - {file = "setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257"}, + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] [package.extras] From 4454ec8a20312a96ca6ea83add488148f68f3bd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:48:00 -0500 Subject: [PATCH 1283/1433] chore(deps-dev): bump requests from 2.32.3 to 2.32.4 in the pip group (#1596) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index c0ad30ef6..665cd38c1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,7 +33,7 @@ version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, @@ -125,7 +125,7 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -419,7 +419,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -782,19 +782,19 @@ pytest = ">=7.0.0" [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["dev", "docs"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -1089,7 +1089,7 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, From b7345129c800ff32a3161a67e7a70c7626ecba23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:01:26 -0500 Subject: [PATCH 1284/1433] chore(deps-dev): bump pytest from 8.3.5 to 8.4.1 (#1599) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 665cd38c1..e0ab88b0f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -33,7 +33,7 @@ version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, @@ -125,7 +125,7 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -419,7 +419,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -672,26 +672,27 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.3.5" +version = "8.4.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -786,7 +787,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -1089,7 +1090,7 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, From d4d4adff2dec57fc36c1fe183c71025b8f8f6323 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:28:47 -1000 Subject: [PATCH 1285/1433] chore(deps-dev): bump pytest-asyncio from 0.26.0 to 1.1.0 (#1605) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 22 ++++++++++++++++++---- pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index e0ab88b0f..0d80c2186 100644 --- a/poetry.lock +++ b/poetry.lock @@ -27,6 +27,19 @@ files = [ [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -696,17 +709,18 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "0.26.0" +version = "1.1.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, - {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, ] [package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<9" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} @@ -1126,4 +1140,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "972988da838067a7f2d12b8212ce54ba946cb38a4f63576a520dd1ed40ac3e9b" +content-hash = "41eb7ce775d30ab9ea32c70f622d10c6dca9904c29635d00e7c9a9893b7cefd4" diff --git a/pyproject.toml b/pyproject.toml index d47a19667..9396b8c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = ">=4,<7" -pytest-asyncio = ">=0.20.3,<0.27.0" +pytest-asyncio = ">=0.20.3,<1.2.0" cython = "^3.0.5" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" From 9eb4a57d822e4dc325cf1e2242f37019a4ee8fe3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:29:14 -1000 Subject: [PATCH 1286/1433] chore(deps-dev): bump urllib3 from 2.3.0 to 2.5.0 in the pip group (#1601) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0d80c2186..65bf0d862 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1100,14 +1100,14 @@ files = [ [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] From 6846b6684c2021238994e6cf50b3dd79fc83ee92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:01:48 -1000 Subject: [PATCH 1287/1433] chore(deps-dev): bump pytest-codspeed from 3.2.0 to 4.0.0 (#1604) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 32 ++++++++++++++++---------------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index 65bf0d862..ea7178450 100644 --- a/poetry.lock +++ b/poetry.lock @@ -730,24 +730,24 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "3.2.0" +version = "4.0.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34"}, - {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c"}, - {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035"}, - {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58"}, - {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b"}, - {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2"}, - {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f"}, - {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b"}, - {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c"}, - {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da"}, - {file = "pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39"}, - {file = "pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155"}, + {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2517731b20a6aa9fe61d04822b802e1637ee67fd865189485b384a9d5897117f"}, + {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e5076bb5119d4f8248822b5cd6b768f70a18c7e1a7fbcd96a99cd4a6430096e"}, + {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06b324acdfe2076a0c97a9d31e8645f820822d6f0e766c73426767ff887a9381"}, + {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ebdac1a4d6138e1ca4f5391e7e3cafad6e3aa6d5660d1b243871b691bc1396c"}, + {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f3def79d4072867d038a33e7f35bc7fb1a2a75236a624b3a690c5540017cb38"}, + {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d29d4538c2d111c0034f71811bcce577304506d22af4dd65df87fadf3ab495"}, + {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90894c93c9e23f12487b7fdf16c28da8f6275d565056772072beb41a72a54cf9"}, + {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e9c40852fa7fc76776db4f1d290eceaeee2d6c5d2dc95a66c7cc690d83889e"}, + {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7330b6eadd6a729d4dba95d26496ee1c6f1649d552f515ef537b14a43908eb67"}, + {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1271cd28e895132b20d12875554a544ee041f7acfb8112af8a5c3cb201f2fc8"}, + {file = "pytest_codspeed-4.0.0-py3-none-any.whl", hash = "sha256:c5debd4b127dc1c507397a8304776f52cabbfa53aad6f51eae329a5489df1e06"}, + {file = "pytest_codspeed-4.0.0.tar.gz", hash = "sha256:0e9af08ca93ad897b376771db92693a81aa8990eecc2a778740412e00a6f6eaf"}, ] [package.dependencies] @@ -758,7 +758,7 @@ rich = ">=13.8.1" [package.extras] compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] -lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.6.5,<0.7.0)"] +lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.11.12,<0.12.0)"] test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] @@ -1104,7 +1104,7 @@ version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -1140,4 +1140,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "41eb7ce775d30ab9ea32c70f622d10c6dca9904c29635d00e7c9a9893b7cefd4" +content-hash = "a02185106a3a8390d2fa889ab86239f0990d8b42aad5e1ebed4e1dd78b5eaa47" diff --git a/pyproject.toml b/pyproject.toml index 9396b8c0b..f298a9148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ pytest-asyncio = ">=0.20.3,<1.2.0" cython = "^3.0.5" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" -pytest-codspeed = "^3.1.0" +pytest-codspeed = ">=3.1,<5.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" From cef7a17ec1a7ed5eb535b11d3ba019ca8981c6e3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:15:16 -1000 Subject: [PATCH 1288/1433] chore(pre-commit.ci): pre-commit autoupdate (#1594) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 10 +++++----- build_ext.py | 2 +- src/zeroconf/_dns.py | 4 ++-- src/zeroconf/_services/browser.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 923b38e6b..86a8ee7f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.8.2 + rev: v4.8.3 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.11 + rev: v0.12.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -50,16 +50,16 @@ repos: hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 - rev: 7.2.0 + rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.17.0 hooks: - id: mypy additional_dependencies: [ifaddr] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.16.6 + rev: v0.16.7 hooks: - id: cython-lint - id: double-quote-cython-strings diff --git a/build_ext.py b/build_ext.py index ff088f830..412bff3c5 100644 --- a/build_ext.py +++ b/build_ext.py @@ -56,7 +56,7 @@ def build(setup_kwargs: Any) -> None: if os.environ.get("SKIP_CYTHON"): return try: - from Cython.Build import cythonize + from Cython.Build import cythonize # noqa: PLC0415 setup_kwargs.update( { diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 60df14b11..93069eb3a 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -63,7 +63,7 @@ class DNSQuestionType(enum.Enum): QM = 2 -class DNSEntry: +class DNSEntry: # noqa: PLW1641 """A DNS entry""" __slots__ = ("class_", "key", "name", "type", "unique") @@ -161,7 +161,7 @@ def __repr__(self) -> str: ) -class DNSRecord(DNSEntry): +class DNSRecord(DNSEntry): # noqa: PLW1641 """A DNS record - like a DNS entry, but has a TTL""" __slots__ = ("created", "ttl") diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 1f60e8f9c..897b5dd64 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -99,7 +99,7 @@ heappush = heapq.heappush -class _ScheduledPTRQuery: +class _ScheduledPTRQuery: # noqa: PLW1641 __slots__ = ( "alias", "cancelled", From cb4ac97ef60bec816dd61e5edae2131f81234f2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:15:29 -1000 Subject: [PATCH 1289/1433] chore(deps-dev): bump cython from 3.1.1 to 3.1.2 (#1602) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 126 ++++++++++++++++++++++++++-------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/poetry.lock b/poetry.lock index ea7178450..297103a81 100644 --- a/poetry.lock +++ b/poetry.lock @@ -328,74 +328,74 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.1.1" +version = "3.1.2" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7adff5b42d2556d073e9f321c2faa639a17fb195ec1de130327f60ec209d8"}, - {file = "cython-3.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b61b99205308c96b1162de59bd67ecadcad3d166a4a1f03a3d9e826c39cd375"}, - {file = "cython-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d14186bd96783d13b8fd0e5b289f2e137a8a25479638b73a1c7e4a99a8d70753"}, - {file = "cython-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3ccec55e2a534a712db14c6617b66f65ad149c014fad518fc3920f6edde770"}, - {file = "cython-3.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a585796939b09b3205b1980e4a55e745c0251e45a5c637afbcac3c6cc9ad6f90"}, - {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3fa4bd840de63509c74867b4b092541720a01db1e07351206011c34e0777dc96"}, - {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b68f1bc80387554eb43f2b62795c173bed9e37201f39dc5084ac437c90a79c9f"}, - {file = "cython-3.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e851ab66a31794e40df1bc6f649cdc56c998c637f5a1b9410c97a90f6b6cb855"}, - {file = "cython-3.1.1-cp310-cp310-win32.whl", hash = "sha256:64915259276482fa23417b284d1fdc7e3a618ee2f819bb6ea7f974c075633df6"}, - {file = "cython-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dee554f0a589377bdaea0eb70e212bf3f35dc6a51a2aa86c9351345e21fd2f07"}, - {file = "cython-3.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c360823e1063784efc2335617e0f28573d7a594c5a8a05d85e850a9621cccb1f"}, - {file = "cython-3.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:12e00b88147b03c148a95365f89dc1c45a0fc52f9c35aa75ff770ef65b615839"}, - {file = "cython-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab644415458d782c16ba7252de9cec1e3125371641cafea2e53a8c1cf85dd58d"}, - {file = "cython-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5cb6c054daadaf01a88c8f49f3edd9e829c9b76a82cbb4269e3f9878254540b"}, - {file = "cython-3.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af8f62cc9339b75fe8434325083e6a7cae88c9c21efd74bbb6ba4e3623219469"}, - {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:689c1aad373556bd2ab1aa1c2dad8939a2891465a1fbd2cbbdd42b488fb40ec8"}, - {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:953046c190fa9ab9a09a546a909b847cdbb4c1fe34e9bfa4a15b6ee1585a86aa"}, - {file = "cython-3.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:755a991601b27dd3555310d0f95b19a05e622a80d7b4e7a91fa6f5f3ef3f3b80"}, - {file = "cython-3.1.1-cp311-cp311-win32.whl", hash = "sha256:83b2af5c327f7da4f08afc34fddfaf6d24fa0c000b6b70a527c8125e493b6080"}, - {file = "cython-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:141ffd6279411c562f6b707adc56b63e965a4fd7f21db83f5d4fcbd8c50ac546"}, - {file = "cython-3.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d7dc0e4d0cd491fac679a61e9ede348c64ca449f99a284f9a01851aa1dbc7f6"}, - {file = "cython-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd689910002adfac8734f237cdea1573e38345f27ed7fd445482813b65a29457"}, - {file = "cython-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10f0434916994fe213ea7749268b88d77e3ebcbd1b99542cf64bb7d180f45470"}, - {file = "cython-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:873aac4ac0b0fb197557c0ac15458b780b9221daa4a716881cbd1a9016c8459f"}, - {file = "cython-3.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23b886a6c8a50b1101ccef2f2f3dc9c699b77633ef5bb5007090226c2ad3f9c2"}, - {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dff0e7dd53a0ca35b64cda843253d5cac944db26663dc097b3a1adf2c49514ad"}, - {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f7954b0b4b3302655d3caa6924261de5907a4e129bc22ace52fe9ae0cd5a758"}, - {file = "cython-3.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfa500fd7ae95ca152a5f8062b870532fa3e27efcef6d00612e1f28b9f72615f"}, - {file = "cython-3.1.1-cp312-cp312-win32.whl", hash = "sha256:cd748fab8e4426dbcb2e0fa2979558333934d24365e0de5672fbabfe337d880c"}, - {file = "cython-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:307f216ed319ea07644f2ef9974406c830f01bc8e677e2147e9bfcdf9e3ca8ad"}, - {file = "cython-3.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb5661941707bd41ec7a9c273d698113ac50392444f785088e9d9706c6a5937b"}, - {file = "cython-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28b174f41718a7041cfbe0f48913020875ff1aaa4793942b2451ac6d2baf3f07"}, - {file = "cython-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c740a10cd0f50321d048c8ca318eefb4c42b8bffef982dcd89c946d374192702"}, - {file = "cython-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7da069ca769903c5dee56c5f7ab47b2b7b91030eee48912630db5f4f3ec5954a"}, - {file = "cython-3.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c640c0746d984789fe2787a098f06cda456ef2dd78b90164d17884b350839a"}, - {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:426d78565eb91d3366569b20e92b8f14bffef5f57b2acd05b60bbb9ce5c056a1"}, - {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b181158b5761bdaf40f6854f016ab7ddff64d3db4fca55cb3ca0f73813dd76d6"}, - {file = "cython-3.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7489559e6c5ecbba49d535c2e03cf77c2594a3190b6aca7da5b508ba1664a89a"}, - {file = "cython-3.1.1-cp313-cp313-win32.whl", hash = "sha256:263cb0e497910fb5e0a361ad1393b6d728b092178afecc56e8a786f3739960c3"}, - {file = "cython-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:e000f0533eedf3d6dfbe30bb3c58a054c58f0a7778390342fa577a0dc47adab3"}, - {file = "cython-3.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdf53dc4b2a13bd072d6c2c18ac073dbf0f798555bc27ba4f7546a275eb16a0f"}, - {file = "cython-3.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ce82070ccf92c3599d331b9eaaefd9d4562976fb86a8d6bccf05c4a0b8389f2a"}, - {file = "cython-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:020089f9c9f10269181f17660a2cada7d4577bd8eea24b7d2b14e6b64b6996be"}, - {file = "cython-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:402f86c00b08f875cd0990f0c4dc52eb3e0bc5d630066cdf3c798631976f1937"}, - {file = "cython-3.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54a8934cb3bf13b1f8f6cbdae8e382e25a26e67de08ea6ebfd0a467131b67227"}, - {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6ea77ad1e649cec38f8622ba28dcdfbe7bf519bc132abbcf5df759b3975b5a73"}, - {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:7e5cad896af896482240979b996bf4136b0d18dc40c56c72c5641bf0ea085dfb"}, - {file = "cython-3.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16d9870654946375b28280371d370d541641d1071da123d0d64d2c7ebba0cc56"}, - {file = "cython-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8aaa29e763adf3496ab9d371e3caed8da5d3ce5ff8fb57433e2a2f2b5036e5c8"}, - {file = "cython-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:011cdcbf7725f0cfc1abc55ec83d326e788050711272131daf3cc24a19c34bb2"}, - {file = "cython-3.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:40f50b07c479eaf33981d81cad274c68cf9fb81dbe79cbf991f59491c88a4705"}, - {file = "cython-3.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a92f6bd395eadea6eed722a8188d3bdd49db1c9fa3c38710456d6148ab71bad7"}, - {file = "cython-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:268420b92307ae6c5a16e3cf0e2ba1ae3c861650e992893922a0ce08db07cfdb"}, - {file = "cython-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a19188ecd385cdc649e3fec370f38d5fd7f1651aeed0b3fb403180f38fc88e8a"}, - {file = "cython-3.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fff6526bb6f4eea615663117b86de6ede0d17c477b600d3d8302be3502bd3c3"}, - {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3192a61c2a532d3faccdff508bc8427de9530b587888218bfc0226eb33a84e11"}, - {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:56c6768a6f601f93daab7c2487f9f110548a896a91e00a6e119445ada2575323"}, - {file = "cython-3.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:50ad80e2f438e9127a87c10927e6ac16a987df39c248b19ab2cd31330129be3c"}, - {file = "cython-3.1.1-cp39-cp39-win32.whl", hash = "sha256:b194a65a0fd91f305d2d1e7010f44111774a28533e1e44dd2a76e7de81a219b9"}, - {file = "cython-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:c8b8be01fd40b3e38a76c60a524f956548a3a7566e5530a833a48a695f3d6c12"}, - {file = "cython-3.1.1-py3-none-any.whl", hash = "sha256:07621e044f332d18139df2ccfcc930151fd323c2f61a58c82f304cffc9eb5280"}, - {file = "cython-3.1.1.tar.gz", hash = "sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397"}, + {file = "cython-3.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f2add8b23cb19da3f546a688cd8f9e0bfc2776715ebf5e283bc3113b03ff008"}, + {file = "cython-3.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0d6248a2ae155ca4c42d7fa6a9a05154d62e695d7736bc17e1b85da6dcc361df"}, + {file = "cython-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:262bf49d9da64e2a34c86cbf8de4aa37daffb0f602396f116cca1ed47dc4b9f2"}, + {file = "cython-3.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae53ae93c699d5f113953a9869df2fc269d8e173f9aa0616c6d8d6e12b4e9827"}, + {file = "cython-3.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b417c5d046ce676ee595ec7955ed47a68ad6f419cbf8c2a8708e55a3b38dfa35"}, + {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:af127da4b956e0e906e552fad838dc3fb6b6384164070ceebb0d90982a8ae25a"}, + {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9be3d4954b46fd0f2dceac011d470f658eaf819132db52fbd1cf226ee60348db"}, + {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63da49672c4bb022b4de9d37bab6c29953dbf5a31a2f40dffd0cf0915dcd7a17"}, + {file = "cython-3.1.2-cp310-cp310-win32.whl", hash = "sha256:2d8291dbbc1cb86b8d60c86fe9cbf99ec72de28cb157cbe869c95df4d32efa96"}, + {file = "cython-3.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:e1f30a1339e03c80968a371ef76bf27a6648c5646cccd14a97e731b6957db97a"}, + {file = "cython-3.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5548573e0912d7dc80579827493315384c462e2f15797b91a8ed177686d31eb9"}, + {file = "cython-3.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bf3ea5bc50d80762c490f42846820a868a6406fdb5878ae9e4cc2f11b50228a"}, + {file = "cython-3.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ce53951d06ab2bca39f153d9c5add1d631c2a44d58bf67288c9d631be9724e"}, + {file = "cython-3.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e05a36224e3002d48c7c1c695b3771343bd16bc57eab60d6c5d5e08f3cbbafd8"}, + {file = "cython-3.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc0fc0777c7ab82297c01c61a1161093a22a41714f62e8c35188a309bd5db8e"}, + {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:18161ef3dd0e90a944daa2be468dd27696712a5f792d6289e97d2a31298ad688"}, + {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ca45020950cd52d82189d6dfb6225737586be6fe7b0b9d3fadd7daca62eff531"}, + {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaae97d6d07610224be2b73a93e9e3dd85c09aedfd8e47054e3ef5a863387dae"}, + {file = "cython-3.1.2-cp311-cp311-win32.whl", hash = "sha256:3d439d9b19e7e70f6ff745602906d282a853dd5219d8e7abbf355de680c9d120"}, + {file = "cython-3.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:8efa44ee2f1876e40eb5e45f6513a19758077c56bf140623ccab43d31f873b61"}, + {file = "cython-3.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c2c4b6f9a941c857b40168b3f3c81d514e509d985c2dcd12e1a4fea9734192e"}, + {file = "cython-3.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bdbc115bbe1b8c1dcbcd1b03748ea87fa967eb8dfc3a1a9bb243d4a382efcff4"}, + {file = "cython-3.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05111f89db1ca98edc0675cfaa62be47b3ff519a29876eb095532a9f9e052b8"}, + {file = "cython-3.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e7188df8709be32cfdfadc7c3782e361c929df9132f95e1bbc90a340dca3c7"}, + {file = "cython-3.1.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0ecc71e60a051732c2607b8eb8f2a03a5dac09b28e52b8af323c329db9987b"}, + {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f27143cf88835c8bcc9bf3304953f23f377d1d991e8942982fe7be344c7cfce3"}, + {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8c43566701133f53bf13485839d8f3f309095fe0d3b9d0cd5873073394d2edc"}, + {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a3bb893e85f027a929c1764bb14db4c31cbdf8a96f59a78f608f2ba7cfbbce95"}, + {file = "cython-3.1.2-cp312-cp312-win32.whl", hash = "sha256:12c5902f105e43ca9af7874cdf87a23627f98c15d5a4f6d38bc9d334845145c0"}, + {file = "cython-3.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:06789eb7bd2e55b38b9dd349e9309f794aee0fed99c26ea5c9562d463877763f"}, + {file = "cython-3.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc22e5f18af436c894b90c257130346930fdc860d7f42b924548c591672beeef"}, + {file = "cython-3.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42c7bffb0fe9898996c7eef9eb74ce3654553c7a3a3f3da66e5a49f801904ce0"}, + {file = "cython-3.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88dc7fd54bfae78c366c6106a759f389000ea4dfe8ed9568af9d2f612825a164"}, + {file = "cython-3.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80d0ce057672ca50728153757d022842d5dcec536b50c79615a22dda2a874ea0"}, + {file = "cython-3.1.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eda6a43f1b78eae0d841698916eef661d15f8bc8439c266a964ea4c504f05612"}, + {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4c516d103e87c2e9c1ab85227e4d91c7484c1ba29e25f8afbf67bae93fee164"}, + {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7542f1d18ab2cd22debc72974ec9e53437a20623d47d6001466e430538d7df54"}, + {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63335513c06dcec4ecdaa8598f36c969032149ffd92a461f641ee363dc83c7ad"}, + {file = "cython-3.1.2-cp313-cp313-win32.whl", hash = "sha256:b377d542299332bfeb61ec09c57821b10f1597304394ba76544f4d07780a16df"}, + {file = "cython-3.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:8ab1319c77f15b0ae04b3fb03588df3afdec4cf79e90eeea5c961e0ebd8fdf72"}, + {file = "cython-3.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dbc1f225cb9f9be7a025589463507e10bb2d76a3258f8d308e0e2d0b966c556e"}, + {file = "cython-3.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c1661c1701c96e1866f839e238570c96a97535a81da76a26f45f99ede18b3897"}, + {file = "cython-3.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:955bc6032d89ce380458266e65dcf5ae0ed1e7c03a7a4457e3e4773e90ba7373"}, + {file = "cython-3.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b58e859889dd0fc6c3a990445b930f692948b28328bb4f3ed84b51028b7e183"}, + {file = "cython-3.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:992a6504aa3eed50dd1fc3d1fa998928b08c1188130bd526e177b6d7f3383ec4"}, + {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f3d03077938b02ec47a56aa156da7bfc2379193738397d4e88086db5b0a374e0"}, + {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b7e1d3c383a5f4ca5319248b9cb1b16a04fb36e153d651e558897171b7dbabb9"}, + {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:58d4d45e40cadf4f602d96b7016cf24ccfe4d954c61fa30b79813db8ccb7818f"}, + {file = "cython-3.1.2-cp38-cp38-win32.whl", hash = "sha256:919ff38a93f7c21829a519693b336979feb41a0f7ca35969402d7e211706100e"}, + {file = "cython-3.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:aca994519645ba8fb5e99c0f9d4be28d61435775552aaf893a158c583cd218a5"}, + {file = "cython-3.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe7f1ee4c13f8a773bd6c66b3d25879f40596faeab49f97d28c39b16ace5fff9"}, + {file = "cython-3.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9ec7d2baea122d94790624f743ff5b78f4e777bf969384be65b69d92fa4bc3f"}, + {file = "cython-3.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df57827185874f29240b02402e615547ab995d90182a852c6ec4f91bbae355a4"}, + {file = "cython-3.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1a69b9b4fe0a48a8271027c0703c71ab1993c4caca01791c0fd2e2bd9031aa"}, + {file = "cython-3.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:970cc1558519f0f108c3e2f4b3480de4945228d9292612d5b2bb687e36c646b8"}, + {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:604c39cd6d152498a940aeae28b6fd44481a255a3fdf1b0051c30f3873c88b7f"}, + {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:855f2ae06438c7405997cf0df42d5b508ec3248272bb39df4a7a4a82a5f7c8cb"}, + {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9e3016ca7a86728bfcbdd52449521e859a977451f296a7ae4967cefa2ec498f7"}, + {file = "cython-3.1.2-cp39-cp39-win32.whl", hash = "sha256:4896fc2b0f90820ea6fcf79a07e30822f84630a404d4e075784124262f6d0adf"}, + {file = "cython-3.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:a965b81eb4f5a5f3f6760b162cb4de3907c71a9ba25d74de1ad7a0e4856f0412"}, + {file = "cython-3.1.2-py3-none-any.whl", hash = "sha256:d23fd7ffd7457205f08571a42b108a3cf993e83a59fe4d72b42e6fc592cf2639"}, + {file = "cython-3.1.2.tar.gz", hash = "sha256:6bbf7a953fa6762dfecdec015e3b054ba51c0121a45ad851fa130f63f5331381"}, ] [[package]] From 99ddc08ebcb4e984d6db90181a37fc303231856c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 09:09:55 -0500 Subject: [PATCH 1290/1433] chore(deps-dev): bump pytest-cov from 6.1.1 to 6.2.1 (#1598) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 297103a81..223b11769 100644 --- a/poetry.lock +++ b/poetry.lock @@ -763,19 +763,20 @@ test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" -version = "6.1.1" +version = "6.2.1" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, - {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, ] [package.dependencies] coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" +pluggy = ">=1.2" +pytest = ">=6.2.5" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] From 374cdeb418d7be55aa1e3bcba0ef2a2535b56054 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Sep 2025 14:11:37 -0500 Subject: [PATCH 1291/1433] chore: disable flakey ipv6 test on github actions with macos (#1612) --- tests/services/test_types.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 632922465..10056c1e5 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -91,6 +91,10 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi @unittest.skipIf(not has_working_ipv6() or sys.platform == "win32", "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") +@unittest.skipIf( + sys.platform == "darwin" and os.environ.get("GITHUB_ACTIONS") == "true", + "IPv6 multicast not working on macOS GitHub Actions", +) def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" From 1322c0cbc6320783739b73e8a80ed5a2823fa17c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:16:57 -0500 Subject: [PATCH 1292/1433] chore(pre-commit.ci): pre-commit autoupdate (#1607) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86a8ee7f7..bf622e2b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-builtin-literals - id: check-case-conflict @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.5 + rev: v0.12.11 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.0 + rev: v1.17.1 hooks: - id: mypy additional_dependencies: [ifaddr] From 02cc054f2899c13d11638a53f110e1aac17a70b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:24:19 -0500 Subject: [PATCH 1293/1433] chore(deps-dev): bump cython from 3.1.2 to 3.1.3 (#1609) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- poetry.lock | 134 ++++++++++++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/poetry.lock b/poetry.lock index 223b11769..3d8961a6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -328,74 +328,82 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.1.2" +version = "3.1.3" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0f2add8b23cb19da3f546a688cd8f9e0bfc2776715ebf5e283bc3113b03ff008"}, - {file = "cython-3.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0d6248a2ae155ca4c42d7fa6a9a05154d62e695d7736bc17e1b85da6dcc361df"}, - {file = "cython-3.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:262bf49d9da64e2a34c86cbf8de4aa37daffb0f602396f116cca1ed47dc4b9f2"}, - {file = "cython-3.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae53ae93c699d5f113953a9869df2fc269d8e173f9aa0616c6d8d6e12b4e9827"}, - {file = "cython-3.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b417c5d046ce676ee595ec7955ed47a68ad6f419cbf8c2a8708e55a3b38dfa35"}, - {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:af127da4b956e0e906e552fad838dc3fb6b6384164070ceebb0d90982a8ae25a"}, - {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9be3d4954b46fd0f2dceac011d470f658eaf819132db52fbd1cf226ee60348db"}, - {file = "cython-3.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63da49672c4bb022b4de9d37bab6c29953dbf5a31a2f40dffd0cf0915dcd7a17"}, - {file = "cython-3.1.2-cp310-cp310-win32.whl", hash = "sha256:2d8291dbbc1cb86b8d60c86fe9cbf99ec72de28cb157cbe869c95df4d32efa96"}, - {file = "cython-3.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:e1f30a1339e03c80968a371ef76bf27a6648c5646cccd14a97e731b6957db97a"}, - {file = "cython-3.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5548573e0912d7dc80579827493315384c462e2f15797b91a8ed177686d31eb9"}, - {file = "cython-3.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bf3ea5bc50d80762c490f42846820a868a6406fdb5878ae9e4cc2f11b50228a"}, - {file = "cython-3.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ce53951d06ab2bca39f153d9c5add1d631c2a44d58bf67288c9d631be9724e"}, - {file = "cython-3.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e05a36224e3002d48c7c1c695b3771343bd16bc57eab60d6c5d5e08f3cbbafd8"}, - {file = "cython-3.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc0fc0777c7ab82297c01c61a1161093a22a41714f62e8c35188a309bd5db8e"}, - {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:18161ef3dd0e90a944daa2be468dd27696712a5f792d6289e97d2a31298ad688"}, - {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ca45020950cd52d82189d6dfb6225737586be6fe7b0b9d3fadd7daca62eff531"}, - {file = "cython-3.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaae97d6d07610224be2b73a93e9e3dd85c09aedfd8e47054e3ef5a863387dae"}, - {file = "cython-3.1.2-cp311-cp311-win32.whl", hash = "sha256:3d439d9b19e7e70f6ff745602906d282a853dd5219d8e7abbf355de680c9d120"}, - {file = "cython-3.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:8efa44ee2f1876e40eb5e45f6513a19758077c56bf140623ccab43d31f873b61"}, - {file = "cython-3.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c2c4b6f9a941c857b40168b3f3c81d514e509d985c2dcd12e1a4fea9734192e"}, - {file = "cython-3.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bdbc115bbe1b8c1dcbcd1b03748ea87fa967eb8dfc3a1a9bb243d4a382efcff4"}, - {file = "cython-3.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05111f89db1ca98edc0675cfaa62be47b3ff519a29876eb095532a9f9e052b8"}, - {file = "cython-3.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e7188df8709be32cfdfadc7c3782e361c929df9132f95e1bbc90a340dca3c7"}, - {file = "cython-3.1.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0ecc71e60a051732c2607b8eb8f2a03a5dac09b28e52b8af323c329db9987b"}, - {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f27143cf88835c8bcc9bf3304953f23f377d1d991e8942982fe7be344c7cfce3"}, - {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8c43566701133f53bf13485839d8f3f309095fe0d3b9d0cd5873073394d2edc"}, - {file = "cython-3.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a3bb893e85f027a929c1764bb14db4c31cbdf8a96f59a78f608f2ba7cfbbce95"}, - {file = "cython-3.1.2-cp312-cp312-win32.whl", hash = "sha256:12c5902f105e43ca9af7874cdf87a23627f98c15d5a4f6d38bc9d334845145c0"}, - {file = "cython-3.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:06789eb7bd2e55b38b9dd349e9309f794aee0fed99c26ea5c9562d463877763f"}, - {file = "cython-3.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc22e5f18af436c894b90c257130346930fdc860d7f42b924548c591672beeef"}, - {file = "cython-3.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42c7bffb0fe9898996c7eef9eb74ce3654553c7a3a3f3da66e5a49f801904ce0"}, - {file = "cython-3.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88dc7fd54bfae78c366c6106a759f389000ea4dfe8ed9568af9d2f612825a164"}, - {file = "cython-3.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80d0ce057672ca50728153757d022842d5dcec536b50c79615a22dda2a874ea0"}, - {file = "cython-3.1.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eda6a43f1b78eae0d841698916eef661d15f8bc8439c266a964ea4c504f05612"}, - {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4c516d103e87c2e9c1ab85227e4d91c7484c1ba29e25f8afbf67bae93fee164"}, - {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7542f1d18ab2cd22debc72974ec9e53437a20623d47d6001466e430538d7df54"}, - {file = "cython-3.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63335513c06dcec4ecdaa8598f36c969032149ffd92a461f641ee363dc83c7ad"}, - {file = "cython-3.1.2-cp313-cp313-win32.whl", hash = "sha256:b377d542299332bfeb61ec09c57821b10f1597304394ba76544f4d07780a16df"}, - {file = "cython-3.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:8ab1319c77f15b0ae04b3fb03588df3afdec4cf79e90eeea5c961e0ebd8fdf72"}, - {file = "cython-3.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dbc1f225cb9f9be7a025589463507e10bb2d76a3258f8d308e0e2d0b966c556e"}, - {file = "cython-3.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c1661c1701c96e1866f839e238570c96a97535a81da76a26f45f99ede18b3897"}, - {file = "cython-3.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:955bc6032d89ce380458266e65dcf5ae0ed1e7c03a7a4457e3e4773e90ba7373"}, - {file = "cython-3.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b58e859889dd0fc6c3a990445b930f692948b28328bb4f3ed84b51028b7e183"}, - {file = "cython-3.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:992a6504aa3eed50dd1fc3d1fa998928b08c1188130bd526e177b6d7f3383ec4"}, - {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f3d03077938b02ec47a56aa156da7bfc2379193738397d4e88086db5b0a374e0"}, - {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b7e1d3c383a5f4ca5319248b9cb1b16a04fb36e153d651e558897171b7dbabb9"}, - {file = "cython-3.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:58d4d45e40cadf4f602d96b7016cf24ccfe4d954c61fa30b79813db8ccb7818f"}, - {file = "cython-3.1.2-cp38-cp38-win32.whl", hash = "sha256:919ff38a93f7c21829a519693b336979feb41a0f7ca35969402d7e211706100e"}, - {file = "cython-3.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:aca994519645ba8fb5e99c0f9d4be28d61435775552aaf893a158c583cd218a5"}, - {file = "cython-3.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe7f1ee4c13f8a773bd6c66b3d25879f40596faeab49f97d28c39b16ace5fff9"}, - {file = "cython-3.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9ec7d2baea122d94790624f743ff5b78f4e777bf969384be65b69d92fa4bc3f"}, - {file = "cython-3.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df57827185874f29240b02402e615547ab995d90182a852c6ec4f91bbae355a4"}, - {file = "cython-3.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1a69b9b4fe0a48a8271027c0703c71ab1993c4caca01791c0fd2e2bd9031aa"}, - {file = "cython-3.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:970cc1558519f0f108c3e2f4b3480de4945228d9292612d5b2bb687e36c646b8"}, - {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:604c39cd6d152498a940aeae28b6fd44481a255a3fdf1b0051c30f3873c88b7f"}, - {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:855f2ae06438c7405997cf0df42d5b508ec3248272bb39df4a7a4a82a5f7c8cb"}, - {file = "cython-3.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9e3016ca7a86728bfcbdd52449521e859a977451f296a7ae4967cefa2ec498f7"}, - {file = "cython-3.1.2-cp39-cp39-win32.whl", hash = "sha256:4896fc2b0f90820ea6fcf79a07e30822f84630a404d4e075784124262f6d0adf"}, - {file = "cython-3.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:a965b81eb4f5a5f3f6760b162cb4de3907c71a9ba25d74de1ad7a0e4856f0412"}, - {file = "cython-3.1.2-py3-none-any.whl", hash = "sha256:d23fd7ffd7457205f08571a42b108a3cf993e83a59fe4d72b42e6fc592cf2639"}, - {file = "cython-3.1.2.tar.gz", hash = "sha256:6bbf7a953fa6762dfecdec015e3b054ba51c0121a45ad851fa130f63f5331381"}, + {file = "cython-3.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae7683987eea77890eb3251738b52a775565652730d6a1a6e39c5e1351d7d89b"}, + {file = "cython-3.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:38e268eba028760167188ae81091f5380ca40a72d2a9eaff4fc6d41fd73a9254"}, + {file = "cython-3.1.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:16dc5d40712b73f663c3ae19a283b9f2c6228d47eb8369bc87d1d8fc35c2bce8"}, + {file = "cython-3.1.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea4116d35642d2b56d105b82c53480677d6e97bff354977948587512328b14d9"}, + {file = "cython-3.1.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c366a179d0c1f285c661efddcfe969f40208f76f008d4a5a657a0822ae3dbf96"}, + {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3af75ad4f490708f4254aa143991dd5cb59c1bda328a099ecf056add2aa81209"}, + {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d03179a36256031be112e4ae0b41cc18fe26eef54ef615b7ee859be9bb30260a"}, + {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4680169f2839294d33e8c290e780cee87417b4544ff0bd3f1ca5edbeda608292"}, + {file = "cython-3.1.3-cp310-cp310-win32.whl", hash = "sha256:f3897d5763df5ffc78327605c5a62748a04f043458928b33b2032ebefa35ee6d"}, + {file = "cython-3.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:840755545f61e79b3f75c92af7c0c19ab89f57c5521bf0fb59660412dd50c5c4"}, + {file = "cython-3.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b3b2f6587b42efdece2d174a2aa4234da4524cc6673f3955c2e62b60c6d11fd"}, + {file = "cython-3.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:963cf640d049fcca1cefd62d1653f859892d6dc8e4d958eb49a5babc491de6a1"}, + {file = "cython-3.1.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2ab05d1bf2d5522ecff35d94ca233b77db2300413597c3ca0b6448377fa4bd7c"}, + {file = "cython-3.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd517e3be052fb49443585b01f02f46080b3408e32c1108a0fdc4cc25b3c9d30"}, + {file = "cython-3.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a48e2180d74e3c528561d85b48f9a939a429537f9ea8aac7fb16180e7bff47e2"}, + {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7c9daa90b15f59aa2a0d638ac1b36777a7e80122099952a0295c71190ce14bc"}, + {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:08ac646ff42781827f23b7a9b61669cdb92055f52724cd8cbe0e1defc56fce2e"}, + {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bb0e0e7fceaffa22e4dc9600f7f75998eef5cc6ac5a8c0733b482851ba765ef2"}, + {file = "cython-3.1.3-cp311-cp311-win32.whl", hash = "sha256:42b1c3ebe36a52e2a8e939c0651e9ca5d30b81d03f800bbf0499deb0503ab565"}, + {file = "cython-3.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:34a973844998281951bf54cdd0b6a9946ba03ba94580820738583a00da167d8f"}, + {file = "cython-3.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:849ef3d15d4354e5f74cdb6d3c80d80b03209b3bf1f4ff93315890b19da18944"}, + {file = "cython-3.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93dd0f62a3f8e93166d8584f8b243180d681ba8fed1f530b55d5f70c348c5797"}, + {file = "cython-3.1.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff4a2cb84798faffb3988bd94636c3ad31a95ff44ef017f09121abffc56f84cf"}, + {file = "cython-3.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b05319e36f34d5388deea5cc2bcfd65f9ebf76f4ea050829421a69625dbba4a"}, + {file = "cython-3.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac902a17934a6da46f80755f49413bc4c03a569ae3c834f5d66da7288ba7e6c"}, + {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7d7a555a864b1b08576f9e8a67f3789796a065837544f9f683ebf3188012fdbd"}, + {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b827ce7d97ef8624adcf2bdda594b3dcb6c9b4f124d8f72001d8aea27d69dc1c"}, + {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7851107204085f4f02d0eb6b660ddcad2ce4846e8b7a1eaba724a0bd3cd68a6b"}, + {file = "cython-3.1.3-cp312-cp312-win32.whl", hash = "sha256:ed20f1b45b2da5a4f8e71a80025bca1cdc96ba35820b0b17658a4a025be920b0"}, + {file = "cython-3.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:dc4ca0f4dec55124cd79ddcfc555be1cbe0092cc99bcf1403621d17b9c6218bb"}, + {file = "cython-3.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9458d540ef0853ea4fc65b8a946587bd483ef7244b470b3d93424eb7b04edeb1"}, + {file = "cython-3.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32d1b22c3b231326e9f16480a7f508c6841bbf7d0615c2d6f489ebc72dd05205"}, + {file = "cython-3.1.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d4c7e0b8584b02a349952de7d7d47f89c97cbf3fee74962e89e3caa78139ec84"}, + {file = "cython-3.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9178f0c06f4bc92372dc44e3867e9285bebd556953e47857c26b389aabe2828"}, + {file = "cython-3.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4da2e624d381e9790152672bfc599a5fb4b823b99d82700a10f5db3311851f9"}, + {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:680c9168882c5e8031dd31df199b9a5ee897e95136d15f8c6454b62162ede25e"}, + {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:833cd0fdba9210d2f1f29e097579565a296d7ff567fd63e8cf5fde4c14339f4f"}, + {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c04367fa0e6c35b199eb51d64b5e185584b810f6c2b96726ce450300faf99686"}, + {file = "cython-3.1.3-cp313-cp313-win32.whl", hash = "sha256:f02ef2bf72a576bf541534c704971b8901616db431bc46d368eed1d6b20aaa1e"}, + {file = "cython-3.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:00264cafcc451dcefc01eaf29ed5ec150fb73af21d4d21105d97e9d829a53e99"}, + {file = "cython-3.1.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62b0a9514b68391aae9784405b65738bbe19cdead3dd7b90dd9e963281db1ee3"}, + {file = "cython-3.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:976db373c315f342dcb24cd65b5e4c08d2c7b42f9f6ac1b3f677eb2abc9bfb0f"}, + {file = "cython-3.1.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e765c12a02dea0bd968cf1e85af77be1dc6d21909c3fbf5bd81815a7cdd4a65e"}, + {file = "cython-3.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097374fa1370e9967e48442a41a0acbebb94fe9d63976cad31eacd38424847bf"}, + {file = "cython-3.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d8fda4d62b693e62992c665a688e3a220be70958c48eb4c2634093c9998156"}, + {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:da23fa5082940ae1eed487ee9b7c1da7015b53f9feffeee661f4ee57f696dcd5"}, + {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8880daa7a0ddf971593f24da161c976bc1bea895393fdfebb8e54269321d9d2b"}, + {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d6b5a9fc210d3bc2880413011f606e1208e12ee6efc74717445a63f9795af1"}, + {file = "cython-3.1.3-cp314-cp314-win32.whl", hash = "sha256:3b2243fed3eeb129dedf2cebbe3be0d9b02fbf3bc75b387aafd54aac3950baa6"}, + {file = "cython-3.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:d32792c80b1fa8be9de207ec8844d49c4d1d0d60e5136d20f344729270db6490"}, + {file = "cython-3.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ec6007da0395d6537f3946f5e0ae25a583e4cf652422b14ff65fe8b11467d8d"}, + {file = "cython-3.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:640f38f1e90bfd8ac1912a1af17574936ba30dc90797da0b11fee913a22f49a2"}, + {file = "cython-3.1.3-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46957ccbbc1647ba7efd72dabf031ec0eff9be1673de4a8aa99c8c1162d2f50f"}, + {file = "cython-3.1.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee16fb1c999b049b4e6b3438a56acb65e827f834105def8a9eee25ab9b2b18f8"}, + {file = "cython-3.1.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7fc012991421720620aeb846f01f8acefdac4ff57819388036f6ab1d613b51"}, + {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d5b7648ff5cd160fd6f94955944e2f6826b2ebff1b1d4bcd9dc98625ef1548b7"}, + {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d1fff5b2a9dec9e300dc9430d163783968723faa7266646084ab5bb9fa79f4ce"}, + {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:661f0121bdd9fe1ec6583c41f110c0e24f9cf551b1bfa76fd436de628654f8b2"}, + {file = "cython-3.1.3-cp38-cp38-win32.whl", hash = "sha256:9ec901176efca3628e017603978d931f69ddb078b01e7221dd3b874615fc5b13"}, + {file = "cython-3.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:3003814be46f67ef997430c9ca5a1f6aa169a79df3faddb277c20fb9f52d1e4b"}, + {file = "cython-3.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db6b15c00fcd102bed7af25aa68f52789a81d02766f3ba9b6be57e2c6e7ecb4d"}, + {file = "cython-3.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:22473fe11a8dc156b66f5bd9e60b4dd59b9350a1ee70317c1dda9bb71e34b409"}, + {file = "cython-3.1.3-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f63b4e351ffa434b3b07629ab9068c25bfe322edc0c8f6b0310d950598ca5214"}, + {file = "cython-3.1.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd2de686b64bef9a9efe059f826705b2a885cbcb9eff5eb8d42c290bdc72debd"}, + {file = "cython-3.1.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40f519fb0757394c892f970ad4c299b27f348c96abe0a7f4882f57d288101fde"}, + {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c62c6eb8b3d500a236e30e9e192f7765506e729e745c25366429ba38652b9116"}, + {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8ea4e363a523eabd987852715372de6ed34c1af65d04357058b18e509e83bfb7"}, + {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6aadf7de28bc75ddfe2ce2c53fc8acf428f0499b3ac62ab3cc3b71a248edf59b"}, + {file = "cython-3.1.3-py3-none-any.whl", hash = "sha256:d13025b34f72f77bf7f65c1cd628914763e6c285f4deb934314c922b91e6be5a"}, + {file = "cython-3.1.3.tar.gz", hash = "sha256:10ee785e42328924b78f75a74f66a813cb956b4a9bc91c44816d089d5934c089"}, ] [[package]] From 8c382eedc6da80031d9a7a42f299f95f115b7e47 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 5 Sep 2025 21:43:57 +0200 Subject: [PATCH 1294/1433] fix: increase check time and add random wait to avoid service collisions (#1611) Co-authored-by: J. Nick Koston --- src/zeroconf/_core.py | 6 ++++++ src/zeroconf/const.py | 2 +- tests/test_asyncio.py | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 5e3a7f465..71e2c2f4d 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -24,6 +24,7 @@ import asyncio import logging +import random import sys import threading from collections.abc import Awaitable @@ -544,6 +545,11 @@ async def async_check_service( instance_name = instance_name_from_service_info(info, strict=strict) if cooperating_responders: return + + # Wait a random amount of time up avoid collisions and avoid + # a thundering herd when multiple services are started on the network + await self.async_wait(random.randint(150, 250)) # noqa: S311 + next_instance_number = 2 next_time = now = current_time_millis() i = 0 diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index c3a62875a..1db39a465 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -28,7 +28,7 @@ # Some timing constants _UNREGISTER_TIME = 125 # ms -_CHECK_TIME = 175 # ms +_CHECK_TIME = 500 # ms _REGISTER_TIME = 225 # ms _LISTENER_TIME = 200 # ms _BROWSER_TIME = 10000 # ms diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index b6e124aad..fe24b1486 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1081,7 +1081,8 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # The rest of the startup questions should have # known answers for answer_list in answers[1:-2]: - assert len(answer_list) == 1 + # Allow 0 or 1 answers due to random delays and timing + assert len(answer_list) <= 1 # Once the TTL is reached, the last question should have no known answers assert len(answers[-1]) == 0 From 0f63f05f676695c6d76930a8d66ea2cae3873fe8 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 5 Sep 2025 19:52:54 +0000 Subject: [PATCH 1295/1433] 0.147.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a3d4cfb..f71e8adce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # CHANGELOG +## v0.147.1 (2025-09-05) + +### Bug Fixes + +- Increase check time and add random wait to avoid service collisions + ([#1611](https://github.com/python-zeroconf/python-zeroconf/pull/1611), + [`8c382ee`](https://github.com/python-zeroconf/python-zeroconf/commit/8c382eedc6da80031d9a7a42f299f95f115b7e47)) + +Co-authored-by: J. Nick Koston + + ## v0.147.0 (2025-05-03) ### Features diff --git a/pyproject.toml b/pyproject.toml index f298a9148..909b0e1c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.147.0" +version = "0.147.1" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 439ffceb6..81c6e7731 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.147.0" +__version__ = "0.147.1" __license__ = "LGPL" From f8e2381a500c78dcefeba3772822d5d3ec5f6060 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Sep 2025 15:15:11 -0500 Subject: [PATCH 1296/1433] fix: missing wheel builds for Windows (#1613) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8d1ef03..e8e30030f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,7 +170,7 @@ jobs: [ ubuntu-24.04-arm, ubuntu-latest, - windows-2019, + windows-latest, macos-13, macos-latest, ] From 4998907262adbbb64525b850d3a89526257c2201 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 5 Sep 2025 20:31:32 +0000 Subject: [PATCH 1297/1433] 0.147.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f71e8adce..27886405c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # CHANGELOG +## v0.147.2 (2025-09-05) + +### Bug Fixes + +- Missing wheel builds for Windows + ([#1613](https://github.com/python-zeroconf/python-zeroconf/pull/1613), + [`f8e2381`](https://github.com/python-zeroconf/python-zeroconf/commit/f8e2381a500c78dcefeba3772822d5d3ec5f6060)) + + ## v0.147.1 (2025-09-05) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 909b0e1c0..a8fb5e2a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zeroconf" -version = "0.147.1" +version = "0.147.2" description = "A pure python implementation of multicast DNS service discovery" authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 81c6e7731..efa1d8d23 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.147.1" +__version__ = "0.147.2" __license__ = "LGPL" From 6ba8576a16352cc554ca8f47167ca7d86733cfb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:44:00 -0600 Subject: [PATCH 1298/1433] chore(deps-dev): bump pytest-cov from 6.2.1 to 7.0.0 (#1618) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 167 ++++++++++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 97 insertions(+), 72 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3d8961a6e..1d07c7222 100644 --- a/poetry.lock +++ b/poetry.lock @@ -249,75 +249,100 @@ files = [ [[package]] name = "coverage" -version = "7.6.12" +version = "7.10.6" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, - {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, - {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, - {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, - {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, - {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, - {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, - {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, - {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, - {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, - {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, - {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, - {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, - {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, - {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, - {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, - {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, - {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, - {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, - {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, - {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, - {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, - {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, - {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, - {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, - {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, - {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, - {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, - {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, - {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, - {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, - {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, - {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, - {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, - {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, - {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, - {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, - {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, - {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, + {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, + {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, + {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, + {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, + {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, + {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, + {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, + {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, + {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, + {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, + {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, + {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, + {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, + {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, + {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, + {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, + {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, + {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, + {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, + {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, + {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, + {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, + {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, + {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, + {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, + {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, + {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, + {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, + {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, + {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, + {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, + {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, + {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, + {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, + {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, + {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, + {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, + {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, + {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, + {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, + {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, + {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, + {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, + {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, + {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, + {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, + {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, + {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, + {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, + {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, + {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, + {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, + {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, + {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, + {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, + {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, ] [package.dependencies] @@ -771,23 +796,23 @@ test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" -version = "6.2.1" +version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, - {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} +coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" -pytest = ">=6.2.5" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" @@ -1149,4 +1174,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "a02185106a3a8390d2fa889ab86239f0990d8b42aad5e1ebed4e1dd78b5eaa47" +content-hash = "8fe5bf5d6c9d82ae18735beca8c89be8847cdfd62ad044d5e7dbb7f65f1c3cf2" diff --git a/pyproject.toml b/pyproject.toml index a8fb5e2a4..cd44e324b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" -pytest-cov = ">=4,<7" +pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.2.0" cython = "^3.0.5" setuptools = ">=65.6.3,<81.0.0" From b82389831ce47a1bd5d4404fe14b27e5e01b8abe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:44:12 -0600 Subject: [PATCH 1299/1433] chore(deps-dev): bump pytest from 8.4.1 to 8.4.2 (#1614) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1d07c7222..19ba7c224 100644 --- a/poetry.lock +++ b/poetry.lock @@ -718,14 +718,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.4.1" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] From c76a4e408617c30f207e55dc1db2a8a44cd9a37a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:50:09 -0600 Subject: [PATCH 1300/1433] chore(deps-dev): bump cython from 3.1.3 to 3.1.4 (#1619) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 144 ++++++++++++++++++++++++++-------------------------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/poetry.lock b/poetry.lock index 19ba7c224..79f56a32a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -353,82 +353,84 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.1.3" +version = "3.1.4" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae7683987eea77890eb3251738b52a775565652730d6a1a6e39c5e1351d7d89b"}, - {file = "cython-3.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:38e268eba028760167188ae81091f5380ca40a72d2a9eaff4fc6d41fd73a9254"}, - {file = "cython-3.1.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:16dc5d40712b73f663c3ae19a283b9f2c6228d47eb8369bc87d1d8fc35c2bce8"}, - {file = "cython-3.1.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea4116d35642d2b56d105b82c53480677d6e97bff354977948587512328b14d9"}, - {file = "cython-3.1.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c366a179d0c1f285c661efddcfe969f40208f76f008d4a5a657a0822ae3dbf96"}, - {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3af75ad4f490708f4254aa143991dd5cb59c1bda328a099ecf056add2aa81209"}, - {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d03179a36256031be112e4ae0b41cc18fe26eef54ef615b7ee859be9bb30260a"}, - {file = "cython-3.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4680169f2839294d33e8c290e780cee87417b4544ff0bd3f1ca5edbeda608292"}, - {file = "cython-3.1.3-cp310-cp310-win32.whl", hash = "sha256:f3897d5763df5ffc78327605c5a62748a04f043458928b33b2032ebefa35ee6d"}, - {file = "cython-3.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:840755545f61e79b3f75c92af7c0c19ab89f57c5521bf0fb59660412dd50c5c4"}, - {file = "cython-3.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b3b2f6587b42efdece2d174a2aa4234da4524cc6673f3955c2e62b60c6d11fd"}, - {file = "cython-3.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:963cf640d049fcca1cefd62d1653f859892d6dc8e4d958eb49a5babc491de6a1"}, - {file = "cython-3.1.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2ab05d1bf2d5522ecff35d94ca233b77db2300413597c3ca0b6448377fa4bd7c"}, - {file = "cython-3.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd517e3be052fb49443585b01f02f46080b3408e32c1108a0fdc4cc25b3c9d30"}, - {file = "cython-3.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a48e2180d74e3c528561d85b48f9a939a429537f9ea8aac7fb16180e7bff47e2"}, - {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7c9daa90b15f59aa2a0d638ac1b36777a7e80122099952a0295c71190ce14bc"}, - {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:08ac646ff42781827f23b7a9b61669cdb92055f52724cd8cbe0e1defc56fce2e"}, - {file = "cython-3.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bb0e0e7fceaffa22e4dc9600f7f75998eef5cc6ac5a8c0733b482851ba765ef2"}, - {file = "cython-3.1.3-cp311-cp311-win32.whl", hash = "sha256:42b1c3ebe36a52e2a8e939c0651e9ca5d30b81d03f800bbf0499deb0503ab565"}, - {file = "cython-3.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:34a973844998281951bf54cdd0b6a9946ba03ba94580820738583a00da167d8f"}, - {file = "cython-3.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:849ef3d15d4354e5f74cdb6d3c80d80b03209b3bf1f4ff93315890b19da18944"}, - {file = "cython-3.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93dd0f62a3f8e93166d8584f8b243180d681ba8fed1f530b55d5f70c348c5797"}, - {file = "cython-3.1.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff4a2cb84798faffb3988bd94636c3ad31a95ff44ef017f09121abffc56f84cf"}, - {file = "cython-3.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b05319e36f34d5388deea5cc2bcfd65f9ebf76f4ea050829421a69625dbba4a"}, - {file = "cython-3.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac902a17934a6da46f80755f49413bc4c03a569ae3c834f5d66da7288ba7e6c"}, - {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7d7a555a864b1b08576f9e8a67f3789796a065837544f9f683ebf3188012fdbd"}, - {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b827ce7d97ef8624adcf2bdda594b3dcb6c9b4f124d8f72001d8aea27d69dc1c"}, - {file = "cython-3.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7851107204085f4f02d0eb6b660ddcad2ce4846e8b7a1eaba724a0bd3cd68a6b"}, - {file = "cython-3.1.3-cp312-cp312-win32.whl", hash = "sha256:ed20f1b45b2da5a4f8e71a80025bca1cdc96ba35820b0b17658a4a025be920b0"}, - {file = "cython-3.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:dc4ca0f4dec55124cd79ddcfc555be1cbe0092cc99bcf1403621d17b9c6218bb"}, - {file = "cython-3.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9458d540ef0853ea4fc65b8a946587bd483ef7244b470b3d93424eb7b04edeb1"}, - {file = "cython-3.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32d1b22c3b231326e9f16480a7f508c6841bbf7d0615c2d6f489ebc72dd05205"}, - {file = "cython-3.1.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d4c7e0b8584b02a349952de7d7d47f89c97cbf3fee74962e89e3caa78139ec84"}, - {file = "cython-3.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9178f0c06f4bc92372dc44e3867e9285bebd556953e47857c26b389aabe2828"}, - {file = "cython-3.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4da2e624d381e9790152672bfc599a5fb4b823b99d82700a10f5db3311851f9"}, - {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:680c9168882c5e8031dd31df199b9a5ee897e95136d15f8c6454b62162ede25e"}, - {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:833cd0fdba9210d2f1f29e097579565a296d7ff567fd63e8cf5fde4c14339f4f"}, - {file = "cython-3.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c04367fa0e6c35b199eb51d64b5e185584b810f6c2b96726ce450300faf99686"}, - {file = "cython-3.1.3-cp313-cp313-win32.whl", hash = "sha256:f02ef2bf72a576bf541534c704971b8901616db431bc46d368eed1d6b20aaa1e"}, - {file = "cython-3.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:00264cafcc451dcefc01eaf29ed5ec150fb73af21d4d21105d97e9d829a53e99"}, - {file = "cython-3.1.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62b0a9514b68391aae9784405b65738bbe19cdead3dd7b90dd9e963281db1ee3"}, - {file = "cython-3.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:976db373c315f342dcb24cd65b5e4c08d2c7b42f9f6ac1b3f677eb2abc9bfb0f"}, - {file = "cython-3.1.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e765c12a02dea0bd968cf1e85af77be1dc6d21909c3fbf5bd81815a7cdd4a65e"}, - {file = "cython-3.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097374fa1370e9967e48442a41a0acbebb94fe9d63976cad31eacd38424847bf"}, - {file = "cython-3.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d8fda4d62b693e62992c665a688e3a220be70958c48eb4c2634093c9998156"}, - {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:da23fa5082940ae1eed487ee9b7c1da7015b53f9feffeee661f4ee57f696dcd5"}, - {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8880daa7a0ddf971593f24da161c976bc1bea895393fdfebb8e54269321d9d2b"}, - {file = "cython-3.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d6b5a9fc210d3bc2880413011f606e1208e12ee6efc74717445a63f9795af1"}, - {file = "cython-3.1.3-cp314-cp314-win32.whl", hash = "sha256:3b2243fed3eeb129dedf2cebbe3be0d9b02fbf3bc75b387aafd54aac3950baa6"}, - {file = "cython-3.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:d32792c80b1fa8be9de207ec8844d49c4d1d0d60e5136d20f344729270db6490"}, - {file = "cython-3.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ec6007da0395d6537f3946f5e0ae25a583e4cf652422b14ff65fe8b11467d8d"}, - {file = "cython-3.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:640f38f1e90bfd8ac1912a1af17574936ba30dc90797da0b11fee913a22f49a2"}, - {file = "cython-3.1.3-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46957ccbbc1647ba7efd72dabf031ec0eff9be1673de4a8aa99c8c1162d2f50f"}, - {file = "cython-3.1.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee16fb1c999b049b4e6b3438a56acb65e827f834105def8a9eee25ab9b2b18f8"}, - {file = "cython-3.1.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7fc012991421720620aeb846f01f8acefdac4ff57819388036f6ab1d613b51"}, - {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d5b7648ff5cd160fd6f94955944e2f6826b2ebff1b1d4bcd9dc98625ef1548b7"}, - {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d1fff5b2a9dec9e300dc9430d163783968723faa7266646084ab5bb9fa79f4ce"}, - {file = "cython-3.1.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:661f0121bdd9fe1ec6583c41f110c0e24f9cf551b1bfa76fd436de628654f8b2"}, - {file = "cython-3.1.3-cp38-cp38-win32.whl", hash = "sha256:9ec901176efca3628e017603978d931f69ddb078b01e7221dd3b874615fc5b13"}, - {file = "cython-3.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:3003814be46f67ef997430c9ca5a1f6aa169a79df3faddb277c20fb9f52d1e4b"}, - {file = "cython-3.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db6b15c00fcd102bed7af25aa68f52789a81d02766f3ba9b6be57e2c6e7ecb4d"}, - {file = "cython-3.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:22473fe11a8dc156b66f5bd9e60b4dd59b9350a1ee70317c1dda9bb71e34b409"}, - {file = "cython-3.1.3-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f63b4e351ffa434b3b07629ab9068c25bfe322edc0c8f6b0310d950598ca5214"}, - {file = "cython-3.1.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd2de686b64bef9a9efe059f826705b2a885cbcb9eff5eb8d42c290bdc72debd"}, - {file = "cython-3.1.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40f519fb0757394c892f970ad4c299b27f348c96abe0a7f4882f57d288101fde"}, - {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c62c6eb8b3d500a236e30e9e192f7765506e729e745c25366429ba38652b9116"}, - {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8ea4e363a523eabd987852715372de6ed34c1af65d04357058b18e509e83bfb7"}, - {file = "cython-3.1.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6aadf7de28bc75ddfe2ce2c53fc8acf428f0499b3ac62ab3cc3b71a248edf59b"}, - {file = "cython-3.1.3-py3-none-any.whl", hash = "sha256:d13025b34f72f77bf7f65c1cd628914763e6c285f4deb934314c922b91e6be5a"}, - {file = "cython-3.1.3.tar.gz", hash = "sha256:10ee785e42328924b78f75a74f66a813cb956b4a9bc91c44816d089d5934c089"}, + {file = "cython-3.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:523110241408ef6511d897e9cebbdffb99120ac82ef3aea89baacce290958f93"}, + {file = "cython-3.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd34f960c3809fa2a7c3487ce9b3cb2c5bbc5ae2107f073a1a51086885958881"}, + {file = "cython-3.1.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90842e7fb8cddfd173478670297f6a6b3df090e029a31ea6ce93669030e67b81"}, + {file = "cython-3.1.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c88234303e2c15a5a88ae21c99698c7195433280b049aa2ad0ace906e6294dab"}, + {file = "cython-3.1.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f06b037f7c244dda9fc38091e87a68498c85c7c27ddc19aa84b08cf42a8a84a"}, + {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1aba748e9dcb9c0179d286cdb20215246c46b69cf227715e46287dcea8de7372"}, + {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:297b6042d764f68dc6213312578ef4b69310d04c963f94a489914efbf44ab133"}, + {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2ecf927e73bde50043d3a9fe3159f834b0e642b97e60a21018439fd25d344888"}, + {file = "cython-3.1.4-cp310-cp310-win32.whl", hash = "sha256:3d940d603f85732627795518f9dba8fa63080d8221bb5f477c7a156ee08714ad"}, + {file = "cython-3.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:1e0671be9859bb313d8df5ca9b9c137e384f1e025831c58cee9a839ace432d3c"}, + {file = "cython-3.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d1d7013dba5fb0506794d4ef8947ff5ed021370614950a8d8d04e57c8c84499e"}, + {file = "cython-3.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eed989f5c139d6550ef2665b783d86fab99372590c97f10a3c26c4523c5fce9e"}, + {file = "cython-3.1.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df3beb8b024dfd73cfddb7f2f7456751cebf6e31655eed3189c209b634bc2f2"}, + {file = "cython-3.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8354703f1168e1aaa01348940f719734c1f11298be333bdb5b94101d49677c0"}, + {file = "cython-3.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a928bd7d446247855f54f359057ab4a32c465219c8c1e299906a483393a59a9e"}, + {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c233bfff4cc7b9d629eecb7345f9b733437f76dc4441951ec393b0a6e29919fc"}, + {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e9691a2cbc2faf0cd819108bceccf9bfc56c15a06d172eafe74157388c44a601"}, + {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ada319207432ea7c6691c70b5c112d261637d79d21ba086ae3726fedde79bfbf"}, + {file = "cython-3.1.4-cp311-cp311-win32.whl", hash = "sha256:dae81313c28222bf7be695f85ae1d16625aac35a0973a3af1e001f63379440c5"}, + {file = "cython-3.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:60d2f192059ac34c5c26527f2beac823d34aaa766ef06792a3b7f290c18ac5e2"}, + {file = "cython-3.1.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d26af46505d0e54fe0f05e7ad089fd0eed8fa04f385f3ab88796f554467bcb9"}, + {file = "cython-3.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ac8bb5068156c92359e3f0eefa138c177d59d1a2e8a89467881fa7d06aba3b"}, + {file = "cython-3.1.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2e42714faec723d2305607a04bafb49a48a8d8f25dd39368d884c058dbcfbc"}, + {file = "cython-3.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0fd655b27997a209a574873304ded9629de588f021154009e8f923475e2c677"}, + {file = "cython-3.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9def7c41f4dc339003b1e6875f84edf059989b9c7f5e9a245d3ce12c190742d9"}, + {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:196555584a8716bf7e017e23ca53e9f632ed493f9faa327d0718e7551588f55d"}, + {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7fff0e739e07a20726484b8898b8628a7b87acb960d0fc5486013c6b77b7bb97"}, + {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2754034fa10f95052949cd6b07eb2f61d654c1b9cfa0b17ea53a269389422e8"}, + {file = "cython-3.1.4-cp312-cp312-win32.whl", hash = "sha256:2e0808ff3614a1dbfd1adfcbff9b2b8119292f1824b3535b4a173205109509f8"}, + {file = "cython-3.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:f262b32327b6bce340cce5d45bbfe3972cb62543a4930460d8564a489f3aea12"}, + {file = "cython-3.1.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab549d0fc187804e0f14fc4759e4b5ad6485ffc01554b2f8b720cc44aeb929cd"}, + {file = "cython-3.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52eae5d9bcc515441a436dcae2cbadfd00c5063d4d7809bd0178931690c06a76"}, + {file = "cython-3.1.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f06345cfa583dd17fff1beedb237853689b85aa400ea9e0db7e5265f3322d15"}, + {file = "cython-3.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d915556c757212cb8ddd4e48c16f2ab481dbb9a76f5153ab26f418c3537eb5"}, + {file = "cython-3.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f3bb603f28b3c1df66baaa5cdbf6029578552b458f1d321bae23b87f6c3199"}, + {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7aff230893ee1044e7bc98d313c034ead70a3dd54d4d22e89ca1734540d94084"}, + {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e83f114c04f72f85591ddb0b28f08ab2e40d250c26686d6509c0f70a9e2ca34"}, + {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8096394960d38b793545753b73781bc0ec695f0b8c22454431704b297e296045"}, + {file = "cython-3.1.4-cp313-cp313-win32.whl", hash = "sha256:4e7c726ac753ca1a5aa30286cbadcd10ed4b4312ea710a8a16bb908d41e9c059"}, + {file = "cython-3.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:f2ee2bb77943044f301cec04d0b51d8e3810507c9c250d6cd079a3e2d6ba88f2"}, + {file = "cython-3.1.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7258739d5560918741cb040bd85ba7cc2f09d868de9116a637e06714fec1f69"}, + {file = "cython-3.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b2d522ee8d3528035e247ee721fb40abe92e9ea852dc9e48802cec080d5de859"}, + {file = "cython-3.1.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4e0560baeb56c29d7d8d693a050dd4d2ed922d8d7c66f5c5715c6f2be84e903"}, + {file = "cython-3.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4223cacc81cba0df0f06f79657c5d6286e153b9a9b989dad1cdf4666f618c073"}, + {file = "cython-3.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4d1f159edee6af38572318681388fbd6448b0d08b9a47494aaf0b698e93394"}, + {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2537c53071a9a124e0bc502a716e1930d9bb101e94c26673016cf1820e4fdbd1"}, + {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:85416717c529fb5ccf908464657a5187753e76d7b6ffec9b1c2d91544f6c3628"}, + {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:18882e2f5c0e0c25f9c44f16f2fb9c48f33988885c5f9eae2856f10c6f089ffa"}, + {file = "cython-3.1.4-cp314-cp314-win32.whl", hash = "sha256:8ef8deadc888eaf95e5328fc176fb6c37bccee1213f07517c6ea55b5f817c457"}, + {file = "cython-3.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:acb99ddec62ba1ea5de0e0087760fa834ec42c94f0488065a4f1995584e8e94e"}, + {file = "cython-3.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:949074a2445c8fe2b84e81b6cf9c23b30b92e853ad05689457d6735acb2ca738"}, + {file = "cython-3.1.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:533970de5d96ca9ba207aa096caa883e2365ce7d41f0531f070292842b4ba97a"}, + {file = "cython-3.1.4-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078e4e48d3867abf1ba11ca6bd2d62974a326ef2c0d139daa6691f5e01a691c5"}, + {file = "cython-3.1.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c162d63c2a7636864bcee0f333d974ece325d4fbc33e75d38886926e1cc997a1"}, + {file = "cython-3.1.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e10357ea1acc27b42d83aaeb14cd94587b515806a41227a0100028d45140d753"}, + {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:71435feecca14f4de4b3a6b3828d8b1ae4a3b535e53597c32d5f22bc87f3d2bb"}, + {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d24c8a4fb10c089ec45fcc1c44f73df5b4228c95f20539cc4aade7a8933be31c"}, + {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2ccbd6db72f864c6717dcc62e44673e81cfe38984277748818cbe427f461573c"}, + {file = "cython-3.1.4-cp38-cp38-win32.whl", hash = "sha256:94fd9a7807fdd7cffe82c0b7167a9f5fcf0e7c6ef518d89bed66430bd9107854"}, + {file = "cython-3.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:b47a82cfbc8e6a87143f02e1b33b7a0332bd1b83b668cb41ebcb5e4f9a39bc09"}, + {file = "cython-3.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95d2303ee54cf469f7c61aa94ef46c195d42e75a76881b9e33770bcf6c0a5c6"}, + {file = "cython-3.1.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fc723ffca257beebe42c8e2dcb2b9771957b752d6f42684f17ca1d2a4346b21"}, + {file = "cython-3.1.4-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fb5060dd0c89f5c64002a0c44c2fcb5d066d119e2ae4d1bfa2c6482333dd42a"}, + {file = "cython-3.1.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:077cc5881da9b48cc7b7f7952faedcea0ad9c3fcb9ba9f5fb89fdb5ded07dd70"}, + {file = "cython-3.1.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33da3c03797f7b7fde371a05f9e0d63eca679875c1c75e01066893eff2ec2f12"}, + {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daf1bde7af8202f52cfd51625a5c48de55de27786392e38a429bfe8b9a16161f"}, + {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3371af4af1aae0d46c10f8e3c1edff0361f03a5687081c8b36f61678545caff3"}, + {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d4e1115c6b4b848ade9d76a4e3f72c7bb2b7e7a935fcbda56f6032f50e079999"}, + {file = "cython-3.1.4-cp39-cp39-win32.whl", hash = "sha256:1b59709bcec2f38e447e53c51a20caee8c30911d4025dd3d102835f3f10b7bef"}, + {file = "cython-3.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:006e2a175ba9898a7f1551e6c7c4cafd8eb9b42e53d1dfe4308caba9e8453cc3"}, + {file = "cython-3.1.4-py3-none-any.whl", hash = "sha256:d194d95e4fa029a3f6c7d46bdd16d973808c7ea4797586911fdb67cb98b1a2c6"}, + {file = "cython-3.1.4.tar.gz", hash = "sha256:9aefefe831331e2d66ab31799814eae4d0f8a2d246cbaaaa14d1be29ef777683"}, ] [[package]] From 02ecb9534e9805bddabf1bb4148e7b796acfa6ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:02:38 -0500 Subject: [PATCH 1301/1433] chore(pre-commit.ci): pre-commit autoupdate (#1616) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 6 +++--- tests/utils/test_net.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf622e2b4..388de0737 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.8.3 + rev: v4.9.1 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.11 + rev: v0.13.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 + rev: v1.18.2 hooks: - id: mypy additional_dependencies: [ifaddr] diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 7de106618..e55a8cb47 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -357,7 +357,7 @@ def test_create_sockets_interfaces_all_unicast(): mock_socket = Mock(spec=socket.socket) mock_new_socket.return_value = mock_socket - listen_socket, respond_sockets = r.create_sockets( + listen_socket, _respond_sockets = r.create_sockets( interfaces=r.InterfaceChoice.All, unicast=True, ip_version=r.IPVersion.All ) From 6d68a6f6dff0d08d94479912963c04de6fd614de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:05:33 -0500 Subject: [PATCH 1302/1433] chore(ci): bump the github-actions group across 1 directory with 8 updates (#1621) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8e30030f..da102aa26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: "3.12" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -26,7 +26,7 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 @@ -65,11 +65,11 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" @@ -87,16 +87,16 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - name: Setup Python 3.13 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: 3.13 - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 @@ -105,10 +105,11 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3 + uses: CodSpeedHQ/action@653fdc30e6c40ffd9739e40c8a0576f4f4523ca1 # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks + mode: instrumentation release: needs: @@ -128,28 +129,28 @@ jobs: newest_release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 + uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 if: github.ref_name != 'master' with: - root_options: --noop + no_operation_mode: true # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 + uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 id: release if: github.ref_name == 'master' with: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases @@ -204,6 +205,14 @@ jobs: qemu: armv7l musl: "musllinux" pyver: cp313 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp314 + - os: ubuntu-latest + qemu: armv7l + musl: "musllinux" + pyver: cp314t # qemu is slow, make a single # runner per Python version - os: ubuntu-latest @@ -226,15 +235,23 @@ jobs: qemu: armv7l musl: "" pyver: cp313 + - os: ubuntu-latest + qemu: armv7l + musl: "" + pyver: cp314 + - os: ubuntu-latest + qemu: armv7l + musl: "" + pyver: cp314t steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: fetch-depth: 0 ref: "master" # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: "3.12" - name: Set up QEMU @@ -262,13 +279,13 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 + uses: pypa/cibuildwheel@7c619efba910c04005a835b110b057fc28fd6e93 # v3.2.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} @@ -288,7 +305,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir @@ -297,4 +314,4 @@ jobs: merge-multiple: true - uses: - pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 From 2c3c29655bd365213a7e0a4360b8dd860d833470 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:06:17 +0200 Subject: [PATCH 1303/1433] fix: update poetry to v2 (#1623) --- poetry.lock | 8 ++++---- pyproject.toml | 42 ++++++++++++++++++++++-------------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/poetry.lock b/poetry.lock index 79f56a32a..2ca9e7e74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -507,7 +507,7 @@ description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, @@ -1159,7 +1159,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" groups = ["dev", "docs"] -markers = "python_version < \"3.10\"" +markers = "python_version == \"3.9\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, @@ -1176,4 +1176,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "8fe5bf5d6c9d82ae18735beca8c89be8847cdfd62ad044d5e7dbb7f65f1c3cf2" +content-hash = "9a9d1f3bd06a8b5750b5843da18367fe2f3534cf741f7148aae071d2c44014bd" diff --git a/pyproject.toml b/pyproject.toml index cd44e324b..af9eb0d40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,28 @@ -[tool.poetry] +[build-system] +requires = ['setuptools>=77.0', 'Cython>=3.0.8', "poetry-core>=2.1.0"] +build-backend = "poetry.core.masonry.api" + +[project] name = "zeroconf" version = "0.147.2" -description = "A pure python implementation of multicast DNS service discovery" -authors = ["Paul Scott-Murphy", "William McBrine", "Jakub Stasiak", "J. Nick Koston"] license = "LGPL-2.1-or-later" +description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" -repository = "https://github.com/python-zeroconf/python-zeroconf" -documentation = "https://python-zeroconf.readthedocs.io" +authors = [ + { name = "Paul Scott-Murphy" }, + { name = "William McBrine" }, + { name = "Jakub Stasiak" }, + { name = "J. Nick Koston" }, +] +requires-python = ">=3.9" + +[project.urls] +"Repository" = "https://github.com/python-zeroconf/python-zeroconf" +"Documentation" = "https://python-zeroconf.readthedocs.io" +"Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" +"Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md" + +[tool.poetry] classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', @@ -15,11 +31,6 @@ classifiers=[ 'Operating System :: POSIX :: Linux', 'Operating System :: MacOS :: MacOS X', 'Topic :: Software Development :: Libraries', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] @@ -35,17 +46,13 @@ include = [ # Make sure we don't package temporary C files generated by the build process exclude = [ "**/*.c" ] -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/python-zeroconf/python-zeroconf/issues" -"Changelog" = "https://github.com/python-zeroconf/python-zeroconf/blob/master/CHANGELOG.md" - [tool.poetry.build] generate-setup-file = true script = "build_ext.py" [tool.semantic_release] branch = "master" -version_toml = ["pyproject.toml:tool.poetry.version"] +version_toml = ["pyproject.toml:project.version"] version_variables = [ "src/zeroconf/__init__.py:__version__" ] @@ -266,11 +273,6 @@ allow_untyped_defs = true module = "bench.*" ignore_errors = true -[build-system] -# 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505 -requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.8', "poetry-core>=1.5.2"] -build-backend = "poetry.core.masonry.api" - [tool.codespell] ignore-words-list = ["additionals", "HASS"] From 581b139047855152326c068a5e13a2a7ee92daeb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:34:54 +0200 Subject: [PATCH 1304/1433] chore: add Python 3.14 to the CI (#1622) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da102aa26..038f7da8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.14" + - "3.14t" - "pypy-3.9" - "pypy-3.10" os: From f8caab03827230c8d56dab60943cb4a7dc9c92d8 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 4 Oct 2025 20:35:26 +0000 Subject: [PATCH 1305/1433] 0.147.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27886405c..d34c53e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # CHANGELOG +## v0.147.3 (2025-10-04) + +### Bug Fixes + +- Update poetry to v2 ([#1623](https://github.com/python-zeroconf/python-zeroconf/pull/1623), + [`2c3c296`](https://github.com/python-zeroconf/python-zeroconf/commit/2c3c29655bd365213a7e0a4360b8dd860d833470)) + + ## v0.147.2 (2025-09-05) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index af9eb0d40..2e2907b74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.147.2" +version = "0.147.3" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index efa1d8d23..8c2007359 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.147.2" +__version__ = "0.147.3" __license__ = "LGPL" From 0f5be1d943afd3c96eb74ec880b570b5ffc69bce Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 4 Oct 2025 20:47:16 +0000 Subject: [PATCH 1306/1433] 1.0.0 Automatically generated by python-semantic-release --- pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e2907b74..a12c95a99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.147.3" +version = "1.0.0" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 8c2007359..275913982 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.147.3" +__version__ = "1.0.0" __license__ = "LGPL" From 4b4f0b09c1c187b2e0821a795569d55b863fcac2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:21:25 -0500 Subject: [PATCH 1307/1433] chore(deps-dev): bump pytest-asyncio from 1.1.0 to 1.2.0 (#1617) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 14 +++++++------- pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2ca9e7e74..cb5c156c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "alabaster" @@ -744,20 +744,20 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.1.0" +version = "1.2.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, - {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, + {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, + {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, ] [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -1128,7 +1128,7 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version < \"3.13\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1176,4 +1176,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "9a9d1f3bd06a8b5750b5843da18367fe2f3534cf741f7148aae071d2c44014bd" +content-hash = "195fc2cf07bbdc615cb72e2b75b5654e99fe5aa7b41256f98c050dc74c81e056" diff --git a/pyproject.toml b/pyproject.toml index a12c95a99..57b28f447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" -pytest-asyncio = ">=0.20.3,<1.2.0" +pytest-asyncio = ">=0.20.3,<1.3.0" cython = "^3.0.5" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" From 795946340dd11a7cd928d005f3aaec1d7db43edd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 5 Oct 2025 01:19:59 +0200 Subject: [PATCH 1308/1433] chore: update ci push trigger (#1624) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 038f7da8b..805a56a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - release-0.x pull_request: concurrency: From 318871ba3d791ae9756e94c2c6f32f52757dcd4e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:22:34 -1000 Subject: [PATCH 1309/1433] chore(pre-commit.ci): pre-commit autoupdate (#1629) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 388de0737..9d3fb7062 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.2 + rev: v0.13.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -59,7 +59,7 @@ repos: - id: mypy additional_dependencies: [ifaddr] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.16.7 + rev: v0.17.0 hooks: - id: cython-lint - id: double-quote-cython-strings From 77baf333c16f838b78423b02de66ddcbbd0c6540 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:07:13 -1000 Subject: [PATCH 1310/1433] chore(deps-dev): bump pytest-codspeed from 4.0.0 to 4.1.1 (#1630) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/poetry.lock b/poetry.lock index cb5c156c2..aec8d7d15 100644 --- a/poetry.lock +++ b/poetry.lock @@ -765,24 +765,24 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.0.0" +version = "4.1.1" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2517731b20a6aa9fe61d04822b802e1637ee67fd865189485b384a9d5897117f"}, - {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e5076bb5119d4f8248822b5cd6b768f70a18c7e1a7fbcd96a99cd4a6430096e"}, - {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06b324acdfe2076a0c97a9d31e8645f820822d6f0e766c73426767ff887a9381"}, - {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ebdac1a4d6138e1ca4f5391e7e3cafad6e3aa6d5660d1b243871b691bc1396c"}, - {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f3def79d4072867d038a33e7f35bc7fb1a2a75236a624b3a690c5540017cb38"}, - {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d29d4538c2d111c0034f71811bcce577304506d22af4dd65df87fadf3ab495"}, - {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90894c93c9e23f12487b7fdf16c28da8f6275d565056772072beb41a72a54cf9"}, - {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e9c40852fa7fc76776db4f1d290eceaeee2d6c5d2dc95a66c7cc690d83889e"}, - {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7330b6eadd6a729d4dba95d26496ee1c6f1649d552f515ef537b14a43908eb67"}, - {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1271cd28e895132b20d12875554a544ee041f7acfb8112af8a5c3cb201f2fc8"}, - {file = "pytest_codspeed-4.0.0-py3-none-any.whl", hash = "sha256:c5debd4b127dc1c507397a8304776f52cabbfa53aad6f51eae329a5489df1e06"}, - {file = "pytest_codspeed-4.0.0.tar.gz", hash = "sha256:0e9af08ca93ad897b376771db92693a81aa8990eecc2a778740412e00a6f6eaf"}, + {file = "pytest_codspeed-4.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa83a1a0aaeb6bdb9918a18294708eebe765a3b5a855adccf9213629d2a0d302"}, + {file = "pytest_codspeed-4.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6fe213b2589ffe6f2189b3b21ca14717c9346b226e6028d2e2b4d4d7dac750f"}, + {file = "pytest_codspeed-4.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b3bd5a71bfab4478e9a9b5058237cf2b34938570b43495093c2ea213175bd5"}, + {file = "pytest_codspeed-4.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfc1efdbcc92fb4b4cbc8eaa8d7387664b063c17e025985ece4816100f1fff29"}, + {file = "pytest_codspeed-4.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:506d446d2911e5188aca7be702c2850a9b8680a72ed241a633d7edaeef00ac13"}, + {file = "pytest_codspeed-4.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1773c74394c98317c6846e9eb60c352222c031bdf1ded109f5c35772a3ce6dc2"}, + {file = "pytest_codspeed-4.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8af528f7f950cb745971fc1e9f59ebc52cc4c51a7eac7a931577fd55d21b94"}, + {file = "pytest_codspeed-4.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db8b2b71cabde1a7ae77a29a3ce67bcb852c28d5599b4eb7428fdb26cd067815"}, + {file = "pytest_codspeed-4.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db34904eab84b64f5618dc6aa6ed88b7991d30e27a632ae97c7503af550d721"}, + {file = "pytest_codspeed-4.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a5d0167669ffbd96f38089674e050a5391d05af45f25bfa290ebf316657425e"}, + {file = "pytest_codspeed-4.1.1-py3-none-any.whl", hash = "sha256:a0a7aa318b09d87541f4f65db9cd473b53d4f1589598d883b238fe208ae2ac8b"}, + {file = "pytest_codspeed-4.1.1.tar.gz", hash = "sha256:9acc3394cc8aafd4543193254831d87de6be79accfdbd43475919fdaa2fc8d81"}, ] [package.dependencies] @@ -793,8 +793,6 @@ rich = ">=13.8.1" [package.extras] compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] -lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.11.12,<0.12.0)"] -test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" From 64291bd6dc089882addfadc4a152d47768fda20f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:08:10 -0500 Subject: [PATCH 1311/1433] chore(ci): bump the github-actions group with 4 updates (#1634) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805a56a87..e4496b8c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@653fdc30e6c40ffd9739e40c8a0576f4f4523ca1 # v3 + uses: CodSpeedHQ/action@4348f634fa7309fe23aac9502e88b999ec90a164 # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -288,14 +288,14 @@ jobs: fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@7c619efba910c04005a835b110b057fc28fd6e93 # v3.2.0 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} @@ -308,7 +308,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v4 + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir From bb85116b1923b2f64bc31e32ed13e8b765dae682 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:08:23 -0500 Subject: [PATCH 1312/1433] chore(deps-dev): bump pytest-codspeed from 4.1.1 to 4.2.0 (#1633) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index aec8d7d15..6c3ae71e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -765,24 +765,28 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.1.1" +version = "4.2.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa83a1a0aaeb6bdb9918a18294708eebe765a3b5a855adccf9213629d2a0d302"}, - {file = "pytest_codspeed-4.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6fe213b2589ffe6f2189b3b21ca14717c9346b226e6028d2e2b4d4d7dac750f"}, - {file = "pytest_codspeed-4.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b3bd5a71bfab4478e9a9b5058237cf2b34938570b43495093c2ea213175bd5"}, - {file = "pytest_codspeed-4.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfc1efdbcc92fb4b4cbc8eaa8d7387664b063c17e025985ece4816100f1fff29"}, - {file = "pytest_codspeed-4.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:506d446d2911e5188aca7be702c2850a9b8680a72ed241a633d7edaeef00ac13"}, - {file = "pytest_codspeed-4.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1773c74394c98317c6846e9eb60c352222c031bdf1ded109f5c35772a3ce6dc2"}, - {file = "pytest_codspeed-4.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8af528f7f950cb745971fc1e9f59ebc52cc4c51a7eac7a931577fd55d21b94"}, - {file = "pytest_codspeed-4.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db8b2b71cabde1a7ae77a29a3ce67bcb852c28d5599b4eb7428fdb26cd067815"}, - {file = "pytest_codspeed-4.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db34904eab84b64f5618dc6aa6ed88b7991d30e27a632ae97c7503af550d721"}, - {file = "pytest_codspeed-4.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a5d0167669ffbd96f38089674e050a5391d05af45f25bfa290ebf316657425e"}, - {file = "pytest_codspeed-4.1.1-py3-none-any.whl", hash = "sha256:a0a7aa318b09d87541f4f65db9cd473b53d4f1589598d883b238fe208ae2ac8b"}, - {file = "pytest_codspeed-4.1.1.tar.gz", hash = "sha256:9acc3394cc8aafd4543193254831d87de6be79accfdbd43475919fdaa2fc8d81"}, + {file = "pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609828b03972966b75b9b7416fa2570c4a0f6124f67e02d35cd3658e64312a7b"}, + {file = "pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23a0c0fbf8bb4de93a3454fd9e5efcdca164c778aaef0a9da4f233d85cb7f5b8"}, + {file = "pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de87bde9fbc6fd53f0fd21dcf2599c89e0b8948d49f9bad224edce51c47e26b"}, + {file = "pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95aeb2479ca383f6b18e2cc9ebcd3b03ab184980a59a232aea6f370bbf59a1e3"}, + {file = "pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d4fefbd4ae401e2c60f6be920a0be50eef0c3e4a1f0a1c83962efd45be38b39"}, + {file = "pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309b4227f57fcbb9df21e889ea1ae191d0d1cd8b903b698fdb9ea0461dbf1dfe"}, + {file = "pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72aab8278452a6d020798b9e4f82780966adb00f80d27a25d1274272c54630d5"}, + {file = "pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:684fcd9491d810ded653a8d38de4835daa2d001645f4a23942862950664273f8"}, + {file = "pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50794dabea6ec90d4288904452051e2febace93e7edf4ca9f2bce8019dd8cd37"}, + {file = "pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0ebd87f2a99467a1cfd8e83492c4712976e43d353ee0b5f71cbb057f1393aca"}, + {file = "pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbbb2d61b85bef8fc7e2193f723f9ac2db388a48259d981bbce96319043e9830"}, + {file = "pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748411c832147bfc85f805af78a1ab1684f52d08e14aabe22932bbe46c079a5f"}, + {file = "pytest_codspeed-4.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:238e17abe8f08d8747fa6c7acff34fefd3c40f17a56a7847ca13dc8d6e8c6009"}, + {file = "pytest_codspeed-4.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0881a736285f33b9a8894da8fe8e1775aa1a4310226abe5d1f0329228efb680c"}, + {file = "pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0"}, + {file = "pytest_codspeed-4.2.0.tar.gz", hash = "sha256:04b5d0bc5a1851ba1504d46bf9d7dbb355222a69f2cd440d54295db721b331f7"}, ] [package.dependencies] From 64ee43198ca2c518dba1d040973e84ad34000e3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:08:32 -0500 Subject: [PATCH 1313/1433] chore(pre-commit.ci): pre-commit autoupdate (#1631) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d3fb7062..299ae13e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,12 +35,12 @@ repos: args: ["--tab-width", "2"] files: ".(css|html|js|json|md|toml|yaml)$" - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.3 + rev: v0.14.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -59,7 +59,7 @@ repos: - id: mypy additional_dependencies: [ifaddr] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.17.0 + rev: v0.18.1 hooks: - id: cython-lint - id: double-quote-cython-strings From e5fd618fa9987d17ace1a1178d48bf5405ad10ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:08:41 -0500 Subject: [PATCH 1314/1433] chore(deps-dev): bump cython from 3.1.4 to 3.1.6 (#1632) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 148 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6c3ae71e0..f7cf40550 100644 --- a/poetry.lock +++ b/poetry.lock @@ -353,84 +353,84 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.1.4" +version = "3.1.6" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:523110241408ef6511d897e9cebbdffb99120ac82ef3aea89baacce290958f93"}, - {file = "cython-3.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd34f960c3809fa2a7c3487ce9b3cb2c5bbc5ae2107f073a1a51086885958881"}, - {file = "cython-3.1.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90842e7fb8cddfd173478670297f6a6b3df090e029a31ea6ce93669030e67b81"}, - {file = "cython-3.1.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c88234303e2c15a5a88ae21c99698c7195433280b049aa2ad0ace906e6294dab"}, - {file = "cython-3.1.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f06b037f7c244dda9fc38091e87a68498c85c7c27ddc19aa84b08cf42a8a84a"}, - {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1aba748e9dcb9c0179d286cdb20215246c46b69cf227715e46287dcea8de7372"}, - {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:297b6042d764f68dc6213312578ef4b69310d04c963f94a489914efbf44ab133"}, - {file = "cython-3.1.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2ecf927e73bde50043d3a9fe3159f834b0e642b97e60a21018439fd25d344888"}, - {file = "cython-3.1.4-cp310-cp310-win32.whl", hash = "sha256:3d940d603f85732627795518f9dba8fa63080d8221bb5f477c7a156ee08714ad"}, - {file = "cython-3.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:1e0671be9859bb313d8df5ca9b9c137e384f1e025831c58cee9a839ace432d3c"}, - {file = "cython-3.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d1d7013dba5fb0506794d4ef8947ff5ed021370614950a8d8d04e57c8c84499e"}, - {file = "cython-3.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eed989f5c139d6550ef2665b783d86fab99372590c97f10a3c26c4523c5fce9e"}, - {file = "cython-3.1.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df3beb8b024dfd73cfddb7f2f7456751cebf6e31655eed3189c209b634bc2f2"}, - {file = "cython-3.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8354703f1168e1aaa01348940f719734c1f11298be333bdb5b94101d49677c0"}, - {file = "cython-3.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a928bd7d446247855f54f359057ab4a32c465219c8c1e299906a483393a59a9e"}, - {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c233bfff4cc7b9d629eecb7345f9b733437f76dc4441951ec393b0a6e29919fc"}, - {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e9691a2cbc2faf0cd819108bceccf9bfc56c15a06d172eafe74157388c44a601"}, - {file = "cython-3.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ada319207432ea7c6691c70b5c112d261637d79d21ba086ae3726fedde79bfbf"}, - {file = "cython-3.1.4-cp311-cp311-win32.whl", hash = "sha256:dae81313c28222bf7be695f85ae1d16625aac35a0973a3af1e001f63379440c5"}, - {file = "cython-3.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:60d2f192059ac34c5c26527f2beac823d34aaa766ef06792a3b7f290c18ac5e2"}, - {file = "cython-3.1.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d26af46505d0e54fe0f05e7ad089fd0eed8fa04f385f3ab88796f554467bcb9"}, - {file = "cython-3.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ac8bb5068156c92359e3f0eefa138c177d59d1a2e8a89467881fa7d06aba3b"}, - {file = "cython-3.1.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2e42714faec723d2305607a04bafb49a48a8d8f25dd39368d884c058dbcfbc"}, - {file = "cython-3.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0fd655b27997a209a574873304ded9629de588f021154009e8f923475e2c677"}, - {file = "cython-3.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9def7c41f4dc339003b1e6875f84edf059989b9c7f5e9a245d3ce12c190742d9"}, - {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:196555584a8716bf7e017e23ca53e9f632ed493f9faa327d0718e7551588f55d"}, - {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7fff0e739e07a20726484b8898b8628a7b87acb960d0fc5486013c6b77b7bb97"}, - {file = "cython-3.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2754034fa10f95052949cd6b07eb2f61d654c1b9cfa0b17ea53a269389422e8"}, - {file = "cython-3.1.4-cp312-cp312-win32.whl", hash = "sha256:2e0808ff3614a1dbfd1adfcbff9b2b8119292f1824b3535b4a173205109509f8"}, - {file = "cython-3.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:f262b32327b6bce340cce5d45bbfe3972cb62543a4930460d8564a489f3aea12"}, - {file = "cython-3.1.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab549d0fc187804e0f14fc4759e4b5ad6485ffc01554b2f8b720cc44aeb929cd"}, - {file = "cython-3.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52eae5d9bcc515441a436dcae2cbadfd00c5063d4d7809bd0178931690c06a76"}, - {file = "cython-3.1.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f06345cfa583dd17fff1beedb237853689b85aa400ea9e0db7e5265f3322d15"}, - {file = "cython-3.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d915556c757212cb8ddd4e48c16f2ab481dbb9a76f5153ab26f418c3537eb5"}, - {file = "cython-3.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f3bb603f28b3c1df66baaa5cdbf6029578552b458f1d321bae23b87f6c3199"}, - {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7aff230893ee1044e7bc98d313c034ead70a3dd54d4d22e89ca1734540d94084"}, - {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e83f114c04f72f85591ddb0b28f08ab2e40d250c26686d6509c0f70a9e2ca34"}, - {file = "cython-3.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8096394960d38b793545753b73781bc0ec695f0b8c22454431704b297e296045"}, - {file = "cython-3.1.4-cp313-cp313-win32.whl", hash = "sha256:4e7c726ac753ca1a5aa30286cbadcd10ed4b4312ea710a8a16bb908d41e9c059"}, - {file = "cython-3.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:f2ee2bb77943044f301cec04d0b51d8e3810507c9c250d6cd079a3e2d6ba88f2"}, - {file = "cython-3.1.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7258739d5560918741cb040bd85ba7cc2f09d868de9116a637e06714fec1f69"}, - {file = "cython-3.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b2d522ee8d3528035e247ee721fb40abe92e9ea852dc9e48802cec080d5de859"}, - {file = "cython-3.1.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4e0560baeb56c29d7d8d693a050dd4d2ed922d8d7c66f5c5715c6f2be84e903"}, - {file = "cython-3.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4223cacc81cba0df0f06f79657c5d6286e153b9a9b989dad1cdf4666f618c073"}, - {file = "cython-3.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4d1f159edee6af38572318681388fbd6448b0d08b9a47494aaf0b698e93394"}, - {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2537c53071a9a124e0bc502a716e1930d9bb101e94c26673016cf1820e4fdbd1"}, - {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:85416717c529fb5ccf908464657a5187753e76d7b6ffec9b1c2d91544f6c3628"}, - {file = "cython-3.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:18882e2f5c0e0c25f9c44f16f2fb9c48f33988885c5f9eae2856f10c6f089ffa"}, - {file = "cython-3.1.4-cp314-cp314-win32.whl", hash = "sha256:8ef8deadc888eaf95e5328fc176fb6c37bccee1213f07517c6ea55b5f817c457"}, - {file = "cython-3.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:acb99ddec62ba1ea5de0e0087760fa834ec42c94f0488065a4f1995584e8e94e"}, - {file = "cython-3.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:949074a2445c8fe2b84e81b6cf9c23b30b92e853ad05689457d6735acb2ca738"}, - {file = "cython-3.1.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:533970de5d96ca9ba207aa096caa883e2365ce7d41f0531f070292842b4ba97a"}, - {file = "cython-3.1.4-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078e4e48d3867abf1ba11ca6bd2d62974a326ef2c0d139daa6691f5e01a691c5"}, - {file = "cython-3.1.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c162d63c2a7636864bcee0f333d974ece325d4fbc33e75d38886926e1cc997a1"}, - {file = "cython-3.1.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e10357ea1acc27b42d83aaeb14cd94587b515806a41227a0100028d45140d753"}, - {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:71435feecca14f4de4b3a6b3828d8b1ae4a3b535e53597c32d5f22bc87f3d2bb"}, - {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d24c8a4fb10c089ec45fcc1c44f73df5b4228c95f20539cc4aade7a8933be31c"}, - {file = "cython-3.1.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2ccbd6db72f864c6717dcc62e44673e81cfe38984277748818cbe427f461573c"}, - {file = "cython-3.1.4-cp38-cp38-win32.whl", hash = "sha256:94fd9a7807fdd7cffe82c0b7167a9f5fcf0e7c6ef518d89bed66430bd9107854"}, - {file = "cython-3.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:b47a82cfbc8e6a87143f02e1b33b7a0332bd1b83b668cb41ebcb5e4f9a39bc09"}, - {file = "cython-3.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95d2303ee54cf469f7c61aa94ef46c195d42e75a76881b9e33770bcf6c0a5c6"}, - {file = "cython-3.1.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1fc723ffca257beebe42c8e2dcb2b9771957b752d6f42684f17ca1d2a4346b21"}, - {file = "cython-3.1.4-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fb5060dd0c89f5c64002a0c44c2fcb5d066d119e2ae4d1bfa2c6482333dd42a"}, - {file = "cython-3.1.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:077cc5881da9b48cc7b7f7952faedcea0ad9c3fcb9ba9f5fb89fdb5ded07dd70"}, - {file = "cython-3.1.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33da3c03797f7b7fde371a05f9e0d63eca679875c1c75e01066893eff2ec2f12"}, - {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daf1bde7af8202f52cfd51625a5c48de55de27786392e38a429bfe8b9a16161f"}, - {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3371af4af1aae0d46c10f8e3c1edff0361f03a5687081c8b36f61678545caff3"}, - {file = "cython-3.1.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d4e1115c6b4b848ade9d76a4e3f72c7bb2b7e7a935fcbda56f6032f50e079999"}, - {file = "cython-3.1.4-cp39-cp39-win32.whl", hash = "sha256:1b59709bcec2f38e447e53c51a20caee8c30911d4025dd3d102835f3f10b7bef"}, - {file = "cython-3.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:006e2a175ba9898a7f1551e6c7c4cafd8eb9b42e53d1dfe4308caba9e8453cc3"}, - {file = "cython-3.1.4-py3-none-any.whl", hash = "sha256:d194d95e4fa029a3f6c7d46bdd16d973808c7ea4797586911fdb67cb98b1a2c6"}, - {file = "cython-3.1.4.tar.gz", hash = "sha256:9aefefe831331e2d66ab31799814eae4d0f8a2d246cbaaaa14d1be29ef777683"}, + {file = "cython-3.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c4027b4d1bf7781fdfb2dbe1c1d81ccac9b910831511747e2c9fc8452fb3ea6b"}, + {file = "cython-3.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:141dea9df09f9c711af3b95510bd417c58b2abd33676eef1cb61f25581f7090a"}, + {file = "cython-3.1.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:486376a988268408b7e8ea7b4cccffb914aa497c498b41589fb4a862ba47e050"}, + {file = "cython-3.1.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc6e63a04ead11812752a5198b85b7fc079688c76712348d072403f18fdeb49"}, + {file = "cython-3.1.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e79f0bfbf403a5d6008bc9e7214e81e647794ca95cae6716399ba21abcc706"}, + {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2379f729f1d5a445adb4621f279f7c23aeb6245f036f96cce14b5b2fd1f5ff0a"}, + {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1089e18d938b6e742f077e398d52e1701080213c4f203755afde6f1b33d9e051"}, + {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73576246abbc62397db85cbdde74d2e5d73dabfdb7e593fdbb3671275ffb50ce"}, + {file = "cython-3.1.6-cp310-cp310-win32.whl", hash = "sha256:f48eae3275b3352ba7eb550fc5321b0fb1ba8d916fa9985fb2f02ce42ae69ddd"}, + {file = "cython-3.1.6-cp310-cp310-win_amd64.whl", hash = "sha256:4066908ee24a18572880966de1d0865d178f5ab9828a9249faa97e1ffdfbed9f"}, + {file = "cython-3.1.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a1aedd8990f470d108b76ca768d9f1766d6610cf2546b73075dbe1e523daebe"}, + {file = "cython-3.1.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f75c33e83e224737b1a68b2868bc08bddaabc6f04aef74864ff6069fe2e68341"}, + {file = "cython-3.1.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91b8fb3e961b3344bf257b851f2ce679727f44857fec94d643bcc458601dab54"}, + {file = "cython-3.1.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cfeb04d43464f5ff8398b499ba46c6eef22093da0e74b25f972576e768880e7"}, + {file = "cython-3.1.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f32366c198ac663a540ff4fa6ed55801d113183616c51100f4cc533568d2c4cf"}, + {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9856e8cd7f7a95a3f10a8f15fef4d17e5a4a57fb5185fe3482cec4adb0536635"}, + {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6966f4d4ee13eceade2d952dc63bdf313f413c0c3f165aef0d6f62e6f27dab02"}, + {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dffb14bc986626be50003f4edc614a2c0a56cbaaf87259f6c763a6d21da14921"}, + {file = "cython-3.1.6-cp311-cp311-win32.whl", hash = "sha256:cde4748d37483b6c91df9f4327768e2828b1e374cb61bcee06d618958de59b7b"}, + {file = "cython-3.1.6-cp311-cp311-win_amd64.whl", hash = "sha256:29d6141b0c9697dfcaf5940eceb06353bec76f51f0579658964c0d29418000df"}, + {file = "cython-3.1.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d2c32e8f6c65854e8203b381ff7ab540820763756b7c326e2c8dc18c9bbb44e"}, + {file = "cython-3.1.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be24fcde7300a81712af279467ebc79baafc8483eb4dfa4daebf8ee90a826d39"}, + {file = "cython-3.1.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5012025af433bd7188fe1f7705df1c4a67e7add80c71658f6c6bc35ea876cc68"}, + {file = "cython-3.1.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b3520e2d4484f927c3ec00d32ffda75ec72cfd6a2ee07adac721cce339fa26f"}, + {file = "cython-3.1.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a01d241d775319bcd7adb4144b070e1c4b01cdf841a62032492f07fad9efdc"}, + {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fd88799fa7bb177182423e0745c9197c50938c6839ebfbe6fd01539582ed488e"}, + {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f809bae2e00b79c01ff5daf9a260df7c1bc9fda087b9d625592fa28c1a2248a9"}, + {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f657e7a4b2242d159de603f280928d8e458dfba48144714774ad76c08f5a530"}, + {file = "cython-3.1.6-cp312-cp312-win32.whl", hash = "sha256:6502f3e58db0ab3e2c983bec2c8c9e45d602e2c7ff921a5a8515b0008d918102"}, + {file = "cython-3.1.6-cp312-cp312-win_amd64.whl", hash = "sha256:71d099d8d6094c5de63a32e67b29964565aed889a218e8d16a94083f4239b904"}, + {file = "cython-3.1.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f0d6b9f803eacf48e9e80ea12a03f54e5f5ac48914341b0a6b81554b3b3154"}, + {file = "cython-3.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec79615d0e29fa29fd4283bc7a2ed9c3d00532086a0031532d64b724db8c3e8e"}, + {file = "cython-3.1.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:037d457738cf4fc12260946c6524b745f488cf413428099f2a064af7612d181f"}, + {file = "cython-3.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b036cb4ed7abcbc89cc04311832b22ad386c532fdd1fe690e1364aa992a54c7"}, + {file = "cython-3.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0fb2694327834c5bda7c5a07605f76437354d0ff76bb8739e77b479d176cf52"}, + {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92eb7a39e60426165a5b2a219af181e5695c4dedd598e317a7a4d9086bd66b91"}, + {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c475018b28f4e7111148bd02b600595090e0aac6cc49615c4586bb4e7f164a22"}, + {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1b4bb661103cb95c6ca70daf5d39992b2d89fd260b02a54d92e365095ed37eb"}, + {file = "cython-3.1.6-cp313-cp313-win32.whl", hash = "sha256:69b1bea23b51628b8c9f14c3e0bb4c7dd5be63781bfbaa581b1c683b473c728a"}, + {file = "cython-3.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:c844004712a9fe2a6f2ed4d6fe02aabb2e0e34f88c150724aad1afec7caff37a"}, + {file = "cython-3.1.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8893619efa77fc83934c1255c619d522711a5cf5933cef0d5c2b9755e8e5fabc"}, + {file = "cython-3.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bb49c74220af0b098f406701f0b87876b1c7614716d39786306986b9feea774b"}, + {file = "cython-3.1.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:defbf9571fca78e8a6e21b93d35c0a491d6af77a8e6180a0146da1b3c8eb8ce6"}, + {file = "cython-3.1.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cd7ea8c6ce0adf52d142bf37c4d54b8d0356818144a4584a24f2a0b9cdae6b8"}, + {file = "cython-3.1.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c47fcc47553214e0a139fd33199d825c5d13970cd6c1039d2594af855ffb338"}, + {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92489385bca6d1935913540e35701a979618fdfeed4dbec6cad1be924fb487bf"}, + {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:926a3efd9b7012cdb3df0d1886e6f0e32e0b72a5d311ac2d3f48c0716fd91c6d"}, + {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e35118eedfa0138154a43fb6b14e83703dae93193ba9940c747c170ed845cca7"}, + {file = "cython-3.1.6-cp314-cp314-win32.whl", hash = "sha256:27f2b26442737d6e080900284883e078aae0276dfd7715a49b338f1a9481f7b9"}, + {file = "cython-3.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:7f75ead2a7cad5ee719427b915711c70e40a114f045b2a9b5bd983484a0b83a7"}, + {file = "cython-3.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fdc17c4b0b1b5fba32880b718b89062e001b65695939a3db27586f0b6d0199e"}, + {file = "cython-3.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a1b288238f4e33dbb51cf7263582821cc57f98a78d1ede6e64fab424969503c"}, + {file = "cython-3.1.6-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a0e3a2b307c6e48e1a9ad12ca487906f8783b5473157bd4563b680e85949b3bf"}, + {file = "cython-3.1.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81e6fe7eb6bb902fc86b97672f284a5c04c9c5f14e2466fb65e6ee68a27dbff7"}, + {file = "cython-3.1.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1059d0f5b5cf654c280721e6d07b8b2dfd104b3b8336449dc41e1abf6f09392"}, + {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:48e7dba361cc6ffaf90623e20a7a663bc19e9a24bcfdabbe87deef4d816abedb"}, + {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:162d4207e79894b2094c7152192a40aa0782c9d61b4c5e6fe087a1c4c1d5eb41"}, + {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5b7e594ae7a43fe17812f8ab1a3b76b69e487c338b71f0def6edaa4c851d"}, + {file = "cython-3.1.6-cp38-cp38-win32.whl", hash = "sha256:206f44565f9fbb3806c0b7cd7184944ab1e9f53a367a657dd459140fa37ef858"}, + {file = "cython-3.1.6-cp38-cp38-win_amd64.whl", hash = "sha256:ca1ae069ed8ba89343e55513f541ac06674d5888e218ec3bb08810c2dfe53024"}, + {file = "cython-3.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f91791319fff49e41acee86f7933e3f2c186610898b899d3dbf209dfcffa1ccd"}, + {file = "cython-3.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2375531527162296fef6c2b4db6801a704a164235d6981098381a76defe1ed46"}, + {file = "cython-3.1.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca38c150c486025d937cdf5c82830eea1c69d9f1ab9b34e877365f96c73a63d"}, + {file = "cython-3.1.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8dd368ec73dbed6f3022d03a37d1ed23285b1e41f736da6f34cb8fc14892bfb"}, + {file = "cython-3.1.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7735d1b301a99a85a20758cddd9d66a33b36c225efe9de0e17c0abd998f2f32"}, + {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6c19cbfb3adcbec754742e9fd096cdb8537e9f7dabcf376c78e02d145ec30c99"}, + {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:958cb56acced5faf22c9ec8eace122b9ee7e591b089ee1dcf0ed1b4511122653"}, + {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:07f8231e45cfedbfbd384c9bcab5c09ebead662ecf4fdf7983e3dc1625a09b13"}, + {file = "cython-3.1.6-cp39-cp39-win32.whl", hash = "sha256:0d28b3f7c580f0a59b4e46524094f7d0a231eb19f679a27bcfc3b13c0599fc88"}, + {file = "cython-3.1.6-cp39-cp39-win_amd64.whl", hash = "sha256:ea8e5e190b0aa628e0cfabd3200453fdbe8efc7ee5a7eef409c4e5a0975d9b57"}, + {file = "cython-3.1.6-py3-none-any.whl", hash = "sha256:91dcf7eb9b6a089ce4e9e1140e571d84c3bca834afb77ec269be7aa9d31a8157"}, + {file = "cython-3.1.6.tar.gz", hash = "sha256:ff4ccffcf98f30ab5723fc45a39c0548a3f6ab14f01d73930c5bfaea455ff01c"}, ] [[package]] @@ -1178,4 +1178,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "195fc2cf07bbdc615cb72e2b75b5654e99fe5aa7b41256f98c050dc74c81e056" +content-hash = "ec982330977690c9a6ef0c5432eaf085107d4b0c5d0335f1adff03f1c0027b9d" diff --git a/pyproject.toml b/pyproject.toml index 57b28f447..77452bc43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ifaddr = ">=0.1.7" pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.3.0" -cython = "^3.0.5" +cython = "^3.1.6" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=3.1,<5.0" From 052c982608fd4ae5b9bddbbedeee0d6a00c61c80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 22:50:57 -0600 Subject: [PATCH 1315/1433] chore(deps-dev): bump cython from 3.1.6 to 3.2.1 (#1637) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 115 ++++++++++++++++++------------------------------- pyproject.toml | 2 +- 2 files changed, 42 insertions(+), 75 deletions(-) diff --git a/poetry.lock b/poetry.lock index f7cf40550..4af28411b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -353,84 +353,51 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.1.6" +version = "3.2.1" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c4027b4d1bf7781fdfb2dbe1c1d81ccac9b910831511747e2c9fc8452fb3ea6b"}, - {file = "cython-3.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:141dea9df09f9c711af3b95510bd417c58b2abd33676eef1cb61f25581f7090a"}, - {file = "cython-3.1.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:486376a988268408b7e8ea7b4cccffb914aa497c498b41589fb4a862ba47e050"}, - {file = "cython-3.1.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc6e63a04ead11812752a5198b85b7fc079688c76712348d072403f18fdeb49"}, - {file = "cython-3.1.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e79f0bfbf403a5d6008bc9e7214e81e647794ca95cae6716399ba21abcc706"}, - {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2379f729f1d5a445adb4621f279f7c23aeb6245f036f96cce14b5b2fd1f5ff0a"}, - {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1089e18d938b6e742f077e398d52e1701080213c4f203755afde6f1b33d9e051"}, - {file = "cython-3.1.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73576246abbc62397db85cbdde74d2e5d73dabfdb7e593fdbb3671275ffb50ce"}, - {file = "cython-3.1.6-cp310-cp310-win32.whl", hash = "sha256:f48eae3275b3352ba7eb550fc5321b0fb1ba8d916fa9985fb2f02ce42ae69ddd"}, - {file = "cython-3.1.6-cp310-cp310-win_amd64.whl", hash = "sha256:4066908ee24a18572880966de1d0865d178f5ab9828a9249faa97e1ffdfbed9f"}, - {file = "cython-3.1.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a1aedd8990f470d108b76ca768d9f1766d6610cf2546b73075dbe1e523daebe"}, - {file = "cython-3.1.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f75c33e83e224737b1a68b2868bc08bddaabc6f04aef74864ff6069fe2e68341"}, - {file = "cython-3.1.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91b8fb3e961b3344bf257b851f2ce679727f44857fec94d643bcc458601dab54"}, - {file = "cython-3.1.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cfeb04d43464f5ff8398b499ba46c6eef22093da0e74b25f972576e768880e7"}, - {file = "cython-3.1.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f32366c198ac663a540ff4fa6ed55801d113183616c51100f4cc533568d2c4cf"}, - {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9856e8cd7f7a95a3f10a8f15fef4d17e5a4a57fb5185fe3482cec4adb0536635"}, - {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6966f4d4ee13eceade2d952dc63bdf313f413c0c3f165aef0d6f62e6f27dab02"}, - {file = "cython-3.1.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dffb14bc986626be50003f4edc614a2c0a56cbaaf87259f6c763a6d21da14921"}, - {file = "cython-3.1.6-cp311-cp311-win32.whl", hash = "sha256:cde4748d37483b6c91df9f4327768e2828b1e374cb61bcee06d618958de59b7b"}, - {file = "cython-3.1.6-cp311-cp311-win_amd64.whl", hash = "sha256:29d6141b0c9697dfcaf5940eceb06353bec76f51f0579658964c0d29418000df"}, - {file = "cython-3.1.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d2c32e8f6c65854e8203b381ff7ab540820763756b7c326e2c8dc18c9bbb44e"}, - {file = "cython-3.1.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be24fcde7300a81712af279467ebc79baafc8483eb4dfa4daebf8ee90a826d39"}, - {file = "cython-3.1.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5012025af433bd7188fe1f7705df1c4a67e7add80c71658f6c6bc35ea876cc68"}, - {file = "cython-3.1.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b3520e2d4484f927c3ec00d32ffda75ec72cfd6a2ee07adac721cce339fa26f"}, - {file = "cython-3.1.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8a01d241d775319bcd7adb4144b070e1c4b01cdf841a62032492f07fad9efdc"}, - {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fd88799fa7bb177182423e0745c9197c50938c6839ebfbe6fd01539582ed488e"}, - {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f809bae2e00b79c01ff5daf9a260df7c1bc9fda087b9d625592fa28c1a2248a9"}, - {file = "cython-3.1.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f657e7a4b2242d159de603f280928d8e458dfba48144714774ad76c08f5a530"}, - {file = "cython-3.1.6-cp312-cp312-win32.whl", hash = "sha256:6502f3e58db0ab3e2c983bec2c8c9e45d602e2c7ff921a5a8515b0008d918102"}, - {file = "cython-3.1.6-cp312-cp312-win_amd64.whl", hash = "sha256:71d099d8d6094c5de63a32e67b29964565aed889a218e8d16a94083f4239b904"}, - {file = "cython-3.1.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f0d6b9f803eacf48e9e80ea12a03f54e5f5ac48914341b0a6b81554b3b3154"}, - {file = "cython-3.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec79615d0e29fa29fd4283bc7a2ed9c3d00532086a0031532d64b724db8c3e8e"}, - {file = "cython-3.1.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:037d457738cf4fc12260946c6524b745f488cf413428099f2a064af7612d181f"}, - {file = "cython-3.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b036cb4ed7abcbc89cc04311832b22ad386c532fdd1fe690e1364aa992a54c7"}, - {file = "cython-3.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0fb2694327834c5bda7c5a07605f76437354d0ff76bb8739e77b479d176cf52"}, - {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92eb7a39e60426165a5b2a219af181e5695c4dedd598e317a7a4d9086bd66b91"}, - {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c475018b28f4e7111148bd02b600595090e0aac6cc49615c4586bb4e7f164a22"}, - {file = "cython-3.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1b4bb661103cb95c6ca70daf5d39992b2d89fd260b02a54d92e365095ed37eb"}, - {file = "cython-3.1.6-cp313-cp313-win32.whl", hash = "sha256:69b1bea23b51628b8c9f14c3e0bb4c7dd5be63781bfbaa581b1c683b473c728a"}, - {file = "cython-3.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:c844004712a9fe2a6f2ed4d6fe02aabb2e0e34f88c150724aad1afec7caff37a"}, - {file = "cython-3.1.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8893619efa77fc83934c1255c619d522711a5cf5933cef0d5c2b9755e8e5fabc"}, - {file = "cython-3.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bb49c74220af0b098f406701f0b87876b1c7614716d39786306986b9feea774b"}, - {file = "cython-3.1.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:defbf9571fca78e8a6e21b93d35c0a491d6af77a8e6180a0146da1b3c8eb8ce6"}, - {file = "cython-3.1.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cd7ea8c6ce0adf52d142bf37c4d54b8d0356818144a4584a24f2a0b9cdae6b8"}, - {file = "cython-3.1.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c47fcc47553214e0a139fd33199d825c5d13970cd6c1039d2594af855ffb338"}, - {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92489385bca6d1935913540e35701a979618fdfeed4dbec6cad1be924fb487bf"}, - {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:926a3efd9b7012cdb3df0d1886e6f0e32e0b72a5d311ac2d3f48c0716fd91c6d"}, - {file = "cython-3.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e35118eedfa0138154a43fb6b14e83703dae93193ba9940c747c170ed845cca7"}, - {file = "cython-3.1.6-cp314-cp314-win32.whl", hash = "sha256:27f2b26442737d6e080900284883e078aae0276dfd7715a49b338f1a9481f7b9"}, - {file = "cython-3.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:7f75ead2a7cad5ee719427b915711c70e40a114f045b2a9b5bd983484a0b83a7"}, - {file = "cython-3.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fdc17c4b0b1b5fba32880b718b89062e001b65695939a3db27586f0b6d0199e"}, - {file = "cython-3.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a1b288238f4e33dbb51cf7263582821cc57f98a78d1ede6e64fab424969503c"}, - {file = "cython-3.1.6-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a0e3a2b307c6e48e1a9ad12ca487906f8783b5473157bd4563b680e85949b3bf"}, - {file = "cython-3.1.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81e6fe7eb6bb902fc86b97672f284a5c04c9c5f14e2466fb65e6ee68a27dbff7"}, - {file = "cython-3.1.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1059d0f5b5cf654c280721e6d07b8b2dfd104b3b8336449dc41e1abf6f09392"}, - {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:48e7dba361cc6ffaf90623e20a7a663bc19e9a24bcfdabbe87deef4d816abedb"}, - {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:162d4207e79894b2094c7152192a40aa0782c9d61b4c5e6fe087a1c4c1d5eb41"}, - {file = "cython-3.1.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5b7e594ae7a43fe17812f8ab1a3b76b69e487c338b71f0def6edaa4c851d"}, - {file = "cython-3.1.6-cp38-cp38-win32.whl", hash = "sha256:206f44565f9fbb3806c0b7cd7184944ab1e9f53a367a657dd459140fa37ef858"}, - {file = "cython-3.1.6-cp38-cp38-win_amd64.whl", hash = "sha256:ca1ae069ed8ba89343e55513f541ac06674d5888e218ec3bb08810c2dfe53024"}, - {file = "cython-3.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f91791319fff49e41acee86f7933e3f2c186610898b899d3dbf209dfcffa1ccd"}, - {file = "cython-3.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2375531527162296fef6c2b4db6801a704a164235d6981098381a76defe1ed46"}, - {file = "cython-3.1.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca38c150c486025d937cdf5c82830eea1c69d9f1ab9b34e877365f96c73a63d"}, - {file = "cython-3.1.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8dd368ec73dbed6f3022d03a37d1ed23285b1e41f736da6f34cb8fc14892bfb"}, - {file = "cython-3.1.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7735d1b301a99a85a20758cddd9d66a33b36c225efe9de0e17c0abd998f2f32"}, - {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6c19cbfb3adcbec754742e9fd096cdb8537e9f7dabcf376c78e02d145ec30c99"}, - {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:958cb56acced5faf22c9ec8eace122b9ee7e591b089ee1dcf0ed1b4511122653"}, - {file = "cython-3.1.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:07f8231e45cfedbfbd384c9bcab5c09ebead662ecf4fdf7983e3dc1625a09b13"}, - {file = "cython-3.1.6-cp39-cp39-win32.whl", hash = "sha256:0d28b3f7c580f0a59b4e46524094f7d0a231eb19f679a27bcfc3b13c0599fc88"}, - {file = "cython-3.1.6-cp39-cp39-win_amd64.whl", hash = "sha256:ea8e5e190b0aa628e0cfabd3200453fdbe8efc7ee5a7eef409c4e5a0975d9b57"}, - {file = "cython-3.1.6-py3-none-any.whl", hash = "sha256:91dcf7eb9b6a089ce4e9e1140e571d84c3bca834afb77ec269be7aa9d31a8157"}, - {file = "cython-3.1.6.tar.gz", hash = "sha256:ff4ccffcf98f30ab5723fc45a39c0548a3f6ab14f01d73930c5bfaea455ff01c"}, + {file = "cython-3.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d10b3731171a33563ba81fdcba39c229e45087269dfbe07a1c00e7dcb2537f"}, + {file = "cython-3.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92b814b6066d178a5057b557d372e2a03854e947e41cb9dec21db732fbd14c3c"}, + {file = "cython-3.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9fc6abd0532007827d8c6143b2bfedf80c7cb89a3c1c12f058336663489ed2e"}, + {file = "cython-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:14f1ed135347587cfddcd3c3219667cac4f0ea0b66aa1c4c0187d50a1b92c222"}, + {file = "cython-3.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cb32c650e7f4476941d1f735cae75a2067d5e3279576273bb8802e8ea907222"}, + {file = "cython-3.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2b306813d7f28aa0a2c3e4e63ada1427a8109917532df942cd5429db228252"}, + {file = "cython-3.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0959d9a36d4f004ce63acc1474b3c606745af98b65e8ae709efd0c10988e9d6b"}, + {file = "cython-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:60c62e734421365135cc2842013d883136054a26c617c001be494235edfc447a"}, + {file = "cython-3.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ea5097d97afd2ab14e98637b7033eba5146de29a5dedf89f5e946076396ab891"}, + {file = "cython-3.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf12de0475bb6a21e2336a4a04dc4a2b4dd0507a2a3c703e045f3484266605"}, + {file = "cython-3.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18c64a0f69a1b8164de70ec7efc72250c589fec21519170de21582300f6aaed9"}, + {file = "cython-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ba14907d5826d8010e82306ce279a0d3650f5b50a4813c80836a17b2213c520"}, + {file = "cython-3.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b4e850fc7a2f72d19679dd083fe4d20bf66860fceabb4f3207112f240249d708"}, + {file = "cython-3.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d20ca4afe993f7dccad3aeddbf4c3536cb0fd3ad6dc7a225935a666a5655af2"}, + {file = "cython-3.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5a54a757d01ca6a260b02ce5baf17d9db1c2253566ab5844ee4966ff2a69c19"}, + {file = "cython-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b81e56584727a328e00d91c164f8f0f2c59b02bf6857c3f000cd830fa571453"}, + {file = "cython-3.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7af6ad01c0fe1965d1d3badaeb6df53c1f37383ebae1ccb405b73f628f87713"}, + {file = "cython-3.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ea7cd085b62acb67c0fbde5cd17a7d9e47992c965e81ec977cf9ea7c59cd65"}, + {file = "cython-3.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:986aea38fdf231e78d73745f83271c5654852c822dc5141a1d3fba64429a6aa6"}, + {file = "cython-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:4960e26cd34c1385f21646339f2e0361fcdd2ed3c01cdb50fe734add577ec56a"}, + {file = "cython-3.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3fa3f625f775f68998c387dd2a9f39314b14dfb728ade11af2b61dfe3121eafc"}, + {file = "cython-3.2.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48dd7f76809bde103ae9029d52b036af1f81a41b6997cec028864df9eae5ab3"}, + {file = "cython-3.2.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c58a496e8365a6094f2c4981938d08a8937f7a18e3a38b75f0a2652058b3669"}, + {file = "cython-3.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:19124aeb9f86a4d293a6e79e7a971c4fb5b7bd16757432729e580c2f505e2efd"}, + {file = "cython-3.2.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4e9167316bf6ecfea33dcca62f074605648fb93cc053ef46b5deb3e5d12fc0d3"}, + {file = "cython-3.2.1-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3095df6cd470064742f428c937bed7200c5123b9e19ee04aa09ec61281e565a3"}, + {file = "cython-3.2.1-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db3f53b2d9afb206075a2605f1150aa019f0733c7795a38eccc6119c2e9c3f7b"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0fc5e7687ac8f8e2b2fb95648f43e9e074ebaa72fd5cb3d8e20e5f1e8b8e02d9"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bbb3bc152bc0de82b031c8d355418fa4890a92424209d59366c2c0bc9e6cf53c"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:a2022bc48ad0c2c0e0485bf0b54902913a3d81086b7d435f4437620c667799f6"}, + {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99fdd4ffc2dcb513f4be9ce71c6fedd895b96b1f814655b6bbab196df497b090"}, + {file = "cython-3.2.1-cp39-abi3-win32.whl", hash = "sha256:06071f85bd5ce040464d43b2f9f287742a79f905e81b709fe904567230f1ed51"}, + {file = "cython-3.2.1-cp39-abi3-win_arm64.whl", hash = "sha256:e87c131d59480aee1ebac622b64f287c0e1d665ad1a1b7d498ac48accdb36c6b"}, + {file = "cython-3.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb3c59a137aead5aecdda87922aa96ff5541147b5dc84850955717b539753014"}, + {file = "cython-3.2.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2d11f770259884b337481df78547727e92d042afbbe29e0f21f7246f1f9252"}, + {file = "cython-3.2.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7ccb0e16071fac3f115c083b2c5c25fa6c519c20c662b6c20f2c26fe6d98877"}, + {file = "cython-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c0ced1c077412b38594c1e3b97ef8db5432e51bf35046678cfe5828e00252c96"}, + {file = "cython-3.2.1-py3-none-any.whl", hash = "sha256:cd72c46e7bffe8250c52d400e72c8d5d3086437b6aeec5b0eca99ccd337f5834"}, + {file = "cython-3.2.1.tar.gz", hash = "sha256:2be1e4d0cbdf7f4cd4d9b8284a034e1989b59fd060f6bd4d24bf3729394d2ed8"}, ] [[package]] @@ -1178,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "ec982330977690c9a6ef0c5432eaf085107d4b0c5d0335f1adff03f1c0027b9d" +content-hash = "846333f25301c6f537f6b8099b0c916e10bea84f7eb4693c07c765c365910ba8" diff --git a/pyproject.toml b/pyproject.toml index 77452bc43..91e847d08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ifaddr = ">=0.1.7" pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.3.0" -cython = "^3.1.6" +cython = "^3.2.1" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=3.1,<5.0" From 6e8d0497783a7ae6bf9f2d6582dd96245206a874 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 22:51:11 -0600 Subject: [PATCH 1316/1433] chore(pre-commit.ci): pre-commit autoupdate (#1635) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 299ae13e5..ef52918ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.9.1 + rev: v4.10.0 hooks: - id: commitizen stages: [commit-msg] @@ -35,12 +35,12 @@ repos: args: ["--tab-width", "2"] files: ".(css|html|js|json|md|toml|yaml)$" - repo: https://github.com/asottile/pyupgrade - rev: v3.21.0 + rev: v3.21.1 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.2 + rev: v0.14.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 5c5e643a174776c6ff2ddf3a8bca446cf169891e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:06:07 -0600 Subject: [PATCH 1317/1433] chore(pre-commit.ci): pre-commit autoupdate (#1638) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef52918ad..03ea80f2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,12 +35,12 @@ repos: args: ["--tab-width", "2"] files: ".(css|html|js|json|md|toml|yaml)$" - repo: https://github.com/asottile/pyupgrade - rev: v3.21.1 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.5 + rev: v0.14.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 1620a87791cc195fc81fb64cc7d7677c3bc684d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:57:40 -0600 Subject: [PATCH 1318/1433] chore(deps-dev): bump cython from 3.2.1 to 3.2.2 (#1640) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 82 +++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4af28411b..9d94f4ed7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -353,51 +353,51 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.2.1" +version = "3.2.2" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f1d10b3731171a33563ba81fdcba39c229e45087269dfbe07a1c00e7dcb2537f"}, - {file = "cython-3.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92b814b6066d178a5057b557d372e2a03854e947e41cb9dec21db732fbd14c3c"}, - {file = "cython-3.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9fc6abd0532007827d8c6143b2bfedf80c7cb89a3c1c12f058336663489ed2e"}, - {file = "cython-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:14f1ed135347587cfddcd3c3219667cac4f0ea0b66aa1c4c0187d50a1b92c222"}, - {file = "cython-3.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cb32c650e7f4476941d1f735cae75a2067d5e3279576273bb8802e8ea907222"}, - {file = "cython-3.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2b306813d7f28aa0a2c3e4e63ada1427a8109917532df942cd5429db228252"}, - {file = "cython-3.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0959d9a36d4f004ce63acc1474b3c606745af98b65e8ae709efd0c10988e9d6b"}, - {file = "cython-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:60c62e734421365135cc2842013d883136054a26c617c001be494235edfc447a"}, - {file = "cython-3.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ea5097d97afd2ab14e98637b7033eba5146de29a5dedf89f5e946076396ab891"}, - {file = "cython-3.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf12de0475bb6a21e2336a4a04dc4a2b4dd0507a2a3c703e045f3484266605"}, - {file = "cython-3.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18c64a0f69a1b8164de70ec7efc72250c589fec21519170de21582300f6aaed9"}, - {file = "cython-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ba14907d5826d8010e82306ce279a0d3650f5b50a4813c80836a17b2213c520"}, - {file = "cython-3.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b4e850fc7a2f72d19679dd083fe4d20bf66860fceabb4f3207112f240249d708"}, - {file = "cython-3.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d20ca4afe993f7dccad3aeddbf4c3536cb0fd3ad6dc7a225935a666a5655af2"}, - {file = "cython-3.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5a54a757d01ca6a260b02ce5baf17d9db1c2253566ab5844ee4966ff2a69c19"}, - {file = "cython-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:1b81e56584727a328e00d91c164f8f0f2c59b02bf6857c3f000cd830fa571453"}, - {file = "cython-3.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7af6ad01c0fe1965d1d3badaeb6df53c1f37383ebae1ccb405b73f628f87713"}, - {file = "cython-3.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ea7cd085b62acb67c0fbde5cd17a7d9e47992c965e81ec977cf9ea7c59cd65"}, - {file = "cython-3.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:986aea38fdf231e78d73745f83271c5654852c822dc5141a1d3fba64429a6aa6"}, - {file = "cython-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:4960e26cd34c1385f21646339f2e0361fcdd2ed3c01cdb50fe734add577ec56a"}, - {file = "cython-3.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3fa3f625f775f68998c387dd2a9f39314b14dfb728ade11af2b61dfe3121eafc"}, - {file = "cython-3.2.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b48dd7f76809bde103ae9029d52b036af1f81a41b6997cec028864df9eae5ab3"}, - {file = "cython-3.2.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c58a496e8365a6094f2c4981938d08a8937f7a18e3a38b75f0a2652058b3669"}, - {file = "cython-3.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:19124aeb9f86a4d293a6e79e7a971c4fb5b7bd16757432729e580c2f505e2efd"}, - {file = "cython-3.2.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4e9167316bf6ecfea33dcca62f074605648fb93cc053ef46b5deb3e5d12fc0d3"}, - {file = "cython-3.2.1-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3095df6cd470064742f428c937bed7200c5123b9e19ee04aa09ec61281e565a3"}, - {file = "cython-3.2.1-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db3f53b2d9afb206075a2605f1150aa019f0733c7795a38eccc6119c2e9c3f7b"}, - {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0fc5e7687ac8f8e2b2fb95648f43e9e074ebaa72fd5cb3d8e20e5f1e8b8e02d9"}, - {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bbb3bc152bc0de82b031c8d355418fa4890a92424209d59366c2c0bc9e6cf53c"}, - {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:a2022bc48ad0c2c0e0485bf0b54902913a3d81086b7d435f4437620c667799f6"}, - {file = "cython-3.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99fdd4ffc2dcb513f4be9ce71c6fedd895b96b1f814655b6bbab196df497b090"}, - {file = "cython-3.2.1-cp39-abi3-win32.whl", hash = "sha256:06071f85bd5ce040464d43b2f9f287742a79f905e81b709fe904567230f1ed51"}, - {file = "cython-3.2.1-cp39-abi3-win_arm64.whl", hash = "sha256:e87c131d59480aee1ebac622b64f287c0e1d665ad1a1b7d498ac48accdb36c6b"}, - {file = "cython-3.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb3c59a137aead5aecdda87922aa96ff5541147b5dc84850955717b539753014"}, - {file = "cython-3.2.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2d11f770259884b337481df78547727e92d042afbbe29e0f21f7246f1f9252"}, - {file = "cython-3.2.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7ccb0e16071fac3f115c083b2c5c25fa6c519c20c662b6c20f2c26fe6d98877"}, - {file = "cython-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:c0ced1c077412b38594c1e3b97ef8db5432e51bf35046678cfe5828e00252c96"}, - {file = "cython-3.2.1-py3-none-any.whl", hash = "sha256:cd72c46e7bffe8250c52d400e72c8d5d3086437b6aeec5b0eca99ccd337f5834"}, - {file = "cython-3.2.1.tar.gz", hash = "sha256:2be1e4d0cbdf7f4cd4d9b8284a034e1989b59fd060f6bd4d24bf3729394d2ed8"}, + {file = "cython-3.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5afac4e77e71a9010dc7fd3191ced00f9b12b494dd7525c140781054ce63a73"}, + {file = "cython-3.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd2ede6af225499ad22888dbfb13b92d71fc1016f401ee637559a5831b177c2"}, + {file = "cython-3.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c9265b3e84ae2d999b7c3165c683e366bbbbbe4346468055ca2366fe013f2df"}, + {file = "cython-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d7b3447b2005dffc5f276d420a480d2b57d15091242652d410b6a46fb00ed251"}, + {file = "cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd"}, + {file = "cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622"}, + {file = "cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb"}, + {file = "cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847"}, + {file = "cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f"}, + {file = "cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d"}, + {file = "cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe"}, + {file = "cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9"}, + {file = "cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7"}, + {file = "cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a"}, + {file = "cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c"}, + {file = "cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604"}, + {file = "cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3"}, + {file = "cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744"}, + {file = "cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11"}, + {file = "cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11"}, + {file = "cython-3.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e7200309b81f4066cf36a96efeec646716ca74afd73d159045169263db891133"}, + {file = "cython-3.2.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e72ee88a9a5381d30a6da116a3c8352730b9b038a49ed9bc5c3d0ed6d69b06c"}, + {file = "cython-3.2.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e35ff0f1bb3a7a5c40afb8fb540e4178b6551909f10748bf39d323f8140ccf3"}, + {file = "cython-3.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b223c1f84c3420c24f6a4858e979524bd35a79437a5839e29d41201c87ed119d"}, + {file = "cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56"}, + {file = "cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf"}, + {file = "cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392"}, + {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0"}, + {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904"}, + {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e"}, + {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2"}, + {file = "cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252"}, + {file = "cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba"}, + {file = "cython-3.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff07e784ea748225bbdea07fec0ac451379e9e41a0a84cb57b36db19dd01ae71"}, + {file = "cython-3.2.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff11412ed5fc78bd8b148621f4d1034fcad6cfcba468c20cd9f327b4f61ec3e"}, + {file = "cython-3.2.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca18d9d53c0e2f0c9347478b37532b46e0dc34c704e052ab1b0d8b21a290fc0f"}, + {file = "cython-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:86b1d39a1ea974dd16fe3bcef0df7b64dadd0bd38d05a339f287b48d37cb109f"}, + {file = "cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb"}, + {file = "cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634"}, ] [[package]] @@ -1145,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "846333f25301c6f537f6b8099b0c916e10bea84f7eb4693c07c765c365910ba8" +content-hash = "cb024ff7ebcfc9a857e32c4dc7c38d0643c39953519dc1eb693cce78ba47066a" diff --git a/pyproject.toml b/pyproject.toml index 91e847d08..af1f0f809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ifaddr = ">=0.1.7" pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.3.0" -cython = "^3.2.1" +cython = "^3.2.2" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=3.1,<5.0" From 10d6b35a73df1cd60470a08becfeef57efd382ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:57:54 -0600 Subject: [PATCH 1319/1433] chore(ci): bump the github-actions group with 6 updates (#1639) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4496b8c6..29667066a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: python-version: "3.12" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -27,7 +27,7 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 @@ -68,11 +68,11 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" @@ -97,9 +97,9 @@ jobs: benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 - name: Setup Python 3.13 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: python-version: 3.13 - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 @@ -108,7 +108,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@4348f634fa7309fe23aac9502e88b999ec90a164 # v3 + uses: CodSpeedHQ/action@346a2d8a8d9d38909abd0bc3d23f773110f076ad # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -132,21 +132,21 @@ jobs: newest_release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 + uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 if: github.ref_name != 'master' with: no_operation_mode: true # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 + uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 id: release if: github.ref_name == 'master' with: @@ -248,18 +248,18 @@ jobs: pyver: cp314t steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 with: fetch-depth: 0 ref: "master" # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: python-version: "3.12" - name: Set up QEMU if: ${{ matrix.qemu }} - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 with: platforms: all # This should be temporary @@ -282,13 +282,13 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 + uses: pypa/cibuildwheel@63fd63b352a9a8bdcc24791c9dbee952ee9a8abc # v3.3.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} From d6a2240d1dc7c4068384885ba673535927ee42fc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 09:58:11 -0600 Subject: [PATCH 1320/1433] chore(pre-commit.ci): pre-commit autoupdate (#1641) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 03ea80f2d..4e1eb550e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.6 + rev: v0.14.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 + rev: v1.19.0 hooks: - id: mypy additional_dependencies: [ifaddr] From 091970346d4ac686cf1d030c777c8bb1614731f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 09:58:20 -0600 Subject: [PATCH 1321/1433] chore(deps-dev): bump urllib3 from 2.5.0 to 2.6.0 in the pip group across 1 directory (#1642) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9d94f4ed7..33f2d31a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1105,21 +1105,21 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, + {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "zipp" From 3735d3cb0235a4fa82cf7df0c0cef9febca3549e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:50:29 -1000 Subject: [PATCH 1322/1433] chore(pre-commit.ci): pre-commit autoupdate (#1643) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e1eb550e..82a48c8ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.10.0 + rev: v4.11.1 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.7 + rev: v0.14.10 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.0 + rev: v1.19.1 hooks: - id: mypy additional_dependencies: [ifaddr] From 4ed0ae4246c9e05ab59d9ac6b544ca089bc49eb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:50:39 -1000 Subject: [PATCH 1323/1433] chore(deps-dev): bump cython from 3.2.2 to 3.2.4 (#1646) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 84 +++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/poetry.lock b/poetry.lock index 33f2d31a9..643be5fda 100644 --- a/poetry.lock +++ b/poetry.lock @@ -353,51 +353,51 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cython" -version = "3.2.2" +version = "3.2.4" description = "The Cython compiler for writing C extensions in the Python language." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "cython-3.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5afac4e77e71a9010dc7fd3191ced00f9b12b494dd7525c140781054ce63a73"}, - {file = "cython-3.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd2ede6af225499ad22888dbfb13b92d71fc1016f401ee637559a5831b177c2"}, - {file = "cython-3.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c9265b3e84ae2d999b7c3165c683e366bbbbbe4346468055ca2366fe013f2df"}, - {file = "cython-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d7b3447b2005dffc5f276d420a480d2b57d15091242652d410b6a46fb00ed251"}, - {file = "cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd"}, - {file = "cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622"}, - {file = "cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb"}, - {file = "cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847"}, - {file = "cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f"}, - {file = "cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d"}, - {file = "cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe"}, - {file = "cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9"}, - {file = "cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7"}, - {file = "cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a"}, - {file = "cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c"}, - {file = "cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604"}, - {file = "cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3"}, - {file = "cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744"}, - {file = "cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11"}, - {file = "cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11"}, - {file = "cython-3.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e7200309b81f4066cf36a96efeec646716ca74afd73d159045169263db891133"}, - {file = "cython-3.2.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e72ee88a9a5381d30a6da116a3c8352730b9b038a49ed9bc5c3d0ed6d69b06c"}, - {file = "cython-3.2.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e35ff0f1bb3a7a5c40afb8fb540e4178b6551909f10748bf39d323f8140ccf3"}, - {file = "cython-3.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b223c1f84c3420c24f6a4858e979524bd35a79437a5839e29d41201c87ed119d"}, - {file = "cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56"}, - {file = "cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf"}, - {file = "cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392"}, - {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0"}, - {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904"}, - {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e"}, - {file = "cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2"}, - {file = "cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252"}, - {file = "cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba"}, - {file = "cython-3.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff07e784ea748225bbdea07fec0ac451379e9e41a0a84cb57b36db19dd01ae71"}, - {file = "cython-3.2.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff11412ed5fc78bd8b148621f4d1034fcad6cfcba468c20cd9f327b4f61ec3e"}, - {file = "cython-3.2.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca18d9d53c0e2f0c9347478b37532b46e0dc34c704e052ab1b0d8b21a290fc0f"}, - {file = "cython-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:86b1d39a1ea974dd16fe3bcef0df7b64dadd0bd38d05a339f287b48d37cb109f"}, - {file = "cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb"}, - {file = "cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634"}, + {file = "cython-3.2.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02cb0cc0f23b9874ad262d7d2b9560aed9c7e2df07b49b920bda6f2cc9cb505e"}, + {file = "cython-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f136f379a4a54246facd0eb6f1ee15c3837cb314ce87b677582ec014db4c6845"}, + {file = "cython-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35ab0632186057406ec729374c737c37051d2eacad9d515d94e5a3b3e58a9b02"}, + {file = "cython-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ca2399dc75796b785f74fb85c938254fa10c80272004d573c455f9123eceed86"}, + {file = "cython-3.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff9af2134c05e3734064808db95b4dd7341a39af06e8945d05ea358e1741aaed"}, + {file = "cython-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67922c9de058a0bfb72d2e75222c52d09395614108c68a76d9800f150296ddb3"}, + {file = "cython-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b362819d155fff1482575e804e43e3a8825332d32baa15245f4642022664a3f4"}, + {file = "cython-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:1a64a112a34ec719b47c01395647e54fb4cf088a511613f9a3a5196694e8e382"}, + {file = "cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9"}, + {file = "cython-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:869487ea41d004f8b92171f42271fbfadb1ec03bede3158705d16cd570d6b891"}, + {file = "cython-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55b6c44cd30821f0b25220ceba6fe636ede48981d2a41b9bbfe3c7902ce44ea7"}, + {file = "cython-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:767b143704bdd08a563153448955935844e53b852e54afdc552b43902ed1e235"}, + {file = "cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0"}, + {file = "cython-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03893c88299a2c868bb741ba6513357acd104e7c42265809fd58dce1456a36fc"}, + {file = "cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8"}, + {file = "cython-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:83266c356c13c68ffe658b4905279c993d8a5337bb0160fa90c8a3e297ea9a2e"}, + {file = "cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa"}, + {file = "cython-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3b5ac54e95f034bc7fb07313996d27cbf71abc17b229b186c1540942d2dc28e"}, + {file = "cython-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f43be4eaa6afd58ce20d970bb1657a3627c44e1760630b82aa256ba74b4acb"}, + {file = "cython-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:983f9d2bb8a896e16fa68f2b37866ded35fa980195eefe62f764ddc5f9f5ef8e"}, + {file = "cython-3.2.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:55eb425c0baf1c8a46aa4424bc35b709db22f3c8a1de33adb3ecb8a3d54ea42a"}, + {file = "cython-3.2.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f583cad7a7eed109f0babb5035e92d0c1260598f53add626a8568b57246b62c3"}, + {file = "cython-3.2.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:72e6c0bbd978e2678b45351395f6825b9b8466095402eae293f4f7a73e9a3e85"}, + {file = "cython-3.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:14dae483ca2838b287085ff98bc206abd7a597b7bb16939a092f8e84d9062842"}, + {file = "cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf"}, + {file = "cython-3.2.4-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6d5267f22b6451eb1e2e1b88f6f78a2c9c8733a6ddefd4520d3968d26b824581"}, + {file = "cython-3.2.4-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b6e58f73a69230218d5381817850ce6d0da5bb7e87eb7d528c7027cbba40b06"}, + {file = "cython-3.2.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e71efb20048358a6b8ec604a0532961c50c067b5e63e345e2e359fff72feaee8"}, + {file = "cython-3.2.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:28b1e363b024c4b8dcf52ff68125e635cb9cb4b0ba997d628f25e32543a71103"}, + {file = "cython-3.2.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:31a90b4a2c47bb6d56baeb926948348ec968e932c1ae2c53239164e3e8880ccf"}, + {file = "cython-3.2.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e65e4773021f8dc8532010b4fbebe782c77f9a0817e93886e518c93bd6a44e9d"}, + {file = "cython-3.2.4-cp39-abi3-win32.whl", hash = "sha256:2b1f12c0e4798293d2754e73cd6f35fa5bbdf072bdc14bc6fc442c059ef2d290"}, + {file = "cython-3.2.4-cp39-abi3-win_arm64.whl", hash = "sha256:3b8e62049afef9da931d55de82d8f46c9a147313b69d5ff6af6e9121d545ce7a"}, + {file = "cython-3.2.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f8d685a70bce39acc1d62ec3916d9b724b5ef665b0ce25ae55e1c85ee09747fc"}, + {file = "cython-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca578c9cb872c7ecffbe14815dc4590a003bc13339e90b2633540c7e1a252839"}, + {file = "cython-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b84d4e3c875915545f77c88dba65ad3741afd2431e5cdee6c9a20cefe6905647"}, + {file = "cython-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:fdfdd753ad7e18e5092b413e9f542e8d28b8a08203126090e1c15f7783b7fe57"}, + {file = "cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c"}, + {file = "cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6"}, ] [[package]] @@ -1109,7 +1109,7 @@ version = "2.6.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, @@ -1145,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "cb024ff7ebcfc9a857e32c4dc7c38d0643c39953519dc1eb693cce78ba47066a" +content-hash = "83fba2eb285e79e08bf94a50689369cbf5ca102fa8b27d963397e13a2a63037e" diff --git a/pyproject.toml b/pyproject.toml index af1f0f809..4314f041d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ifaddr = ">=0.1.7" pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.3.0" -cython = "^3.2.2" +cython = "^3.2.4" setuptools = ">=65.6.3,<81.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=3.1,<5.0" From bcfd39f79a24e7fc4c5104b5ade4639dcd5f5353 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:50:58 -1000 Subject: [PATCH 1324/1433] chore(ci): bump the github-actions group with 6 updates (#1645) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29667066a..dd654ba86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: python-version: "3.12" @@ -27,7 +27,7 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 @@ -68,7 +68,7 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python @@ -90,14 +90,14 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Setup Python 3.13 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 with: @@ -108,7 +108,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@346a2d8a8d9d38909abd0bc3d23f773110f076ad # v3 + uses: CodSpeedHQ/action@972e3437949c89e1357ebd1a2dbc852fcbc57245 # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -132,21 +132,21 @@ jobs: newest_release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release - uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 if: github.ref_name != 'master' with: no_operation_mode: true # On main branch: actual PSR + upload to PyPI & GitHub - name: Release - uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 id: release if: github.ref_name == 'master' with: @@ -248,7 +248,7 @@ jobs: pyver: cp314t steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 with: fetch-depth: 0 ref: "master" @@ -282,7 +282,7 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 @@ -295,7 +295,7 @@ jobs: CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 - - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} @@ -308,7 +308,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v4 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir From 1177054eb24acc93562c24547de302549aaec0f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:51:02 -1000 Subject: [PATCH 1325/1433] chore(pre-commit.ci): pre-commit autoupdate (#1648) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82a48c8ff..387a2d548 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.11.1 + rev: v4.13.8 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + rev: v0.15.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -59,7 +59,7 @@ repos: - id: mypy additional_dependencies: [ifaddr] - repo: https://github.com/MarcoGorelli/cython-lint - rev: v0.18.1 + rev: v0.19.0 hooks: - id: cython-lint - id: double-quote-cython-strings From a7f13b182675d26ce938058a2015278bbcaac736 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:51:10 -1000 Subject: [PATCH 1326/1433] chore(deps-dev): bump pytest-codspeed from 4.2.0 to 4.3.0 (#1654) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index 643be5fda..4887fc2e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -732,28 +732,28 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.2.0" +version = "4.3.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609828b03972966b75b9b7416fa2570c4a0f6124f67e02d35cd3658e64312a7b"}, - {file = "pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23a0c0fbf8bb4de93a3454fd9e5efcdca164c778aaef0a9da4f233d85cb7f5b8"}, - {file = "pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de87bde9fbc6fd53f0fd21dcf2599c89e0b8948d49f9bad224edce51c47e26b"}, - {file = "pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95aeb2479ca383f6b18e2cc9ebcd3b03ab184980a59a232aea6f370bbf59a1e3"}, - {file = "pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d4fefbd4ae401e2c60f6be920a0be50eef0c3e4a1f0a1c83962efd45be38b39"}, - {file = "pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309b4227f57fcbb9df21e889ea1ae191d0d1cd8b903b698fdb9ea0461dbf1dfe"}, - {file = "pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72aab8278452a6d020798b9e4f82780966adb00f80d27a25d1274272c54630d5"}, - {file = "pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:684fcd9491d810ded653a8d38de4835daa2d001645f4a23942862950664273f8"}, - {file = "pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50794dabea6ec90d4288904452051e2febace93e7edf4ca9f2bce8019dd8cd37"}, - {file = "pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0ebd87f2a99467a1cfd8e83492c4712976e43d353ee0b5f71cbb057f1393aca"}, - {file = "pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbbb2d61b85bef8fc7e2193f723f9ac2db388a48259d981bbce96319043e9830"}, - {file = "pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748411c832147bfc85f805af78a1ab1684f52d08e14aabe22932bbe46c079a5f"}, - {file = "pytest_codspeed-4.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:238e17abe8f08d8747fa6c7acff34fefd3c40f17a56a7847ca13dc8d6e8c6009"}, - {file = "pytest_codspeed-4.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0881a736285f33b9a8894da8fe8e1775aa1a4310226abe5d1f0329228efb680c"}, - {file = "pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0"}, - {file = "pytest_codspeed-4.2.0.tar.gz", hash = "sha256:04b5d0bc5a1851ba1504d46bf9d7dbb355222a69f2cd440d54295db721b331f7"}, + {file = "pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2acecc4126658abebc683b38121adec405a46e18a619d49d6154c6e60c5deb2"}, + {file = "pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:619120775e92a3f43fb4ff4c256a251b1554c904d95e2154a382484283f0388a"}, + {file = "pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbeff1eb2f2e36df088658b556fa993e6937bf64ffb07406de4db16fd2b26874"}, + {file = "pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:878aad5e4bb7b401ad8d82f3af5186030cd2bd0d0446782e10dabb9db8827466"}, + {file = "pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527a3a02eaa3e4d4583adc4ba2327eef79628f3e1c682a4b959439551a72588e"}, + {file = "pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9858c2a6e1f391d5696757e7b6e9484749a7376c46f8b4dd9aebf093479a9667"}, + {file = "pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f2fd8497456eefbd325673f677ea80d93bb1bc08a578c1fa43a09cec3d1879"}, + {file = "pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6a36a2a9da1406bc50428437f657f0bd8c842ae54bee5fb3ad30e01d50c0f5"}, + {file = "pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bec30f4fc9c4973143cd80f0d33fa780e9fa3e01e4dbe8cedf229e72f1212c62"}, + {file = "pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6584e641cadf27d894ae90b87c50377232a97cbfd76ee0c7ecd0c056fa3f7f4"}, + {file = "pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0d1f6ea594f29b745c634d66d5f5f1caa1c3abd2af82fea49d656038e8fc77"}, + {file = "pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2f5bb6d8898bea7db45e3c8b916ee48e36905b929477bb511b79c5a3ccacda4"}, + {file = "pytest_codspeed-4.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ac31ae566bf047e91c79b6d12d9a31efedad556ff9258294d6ecebeacb92fa4"}, + {file = "pytest_codspeed-4.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d19b48c9dc35b227a7ac863d4b3c58132256c2ba1aae3220aaddbf6f3f5f9a"}, + {file = "pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a"}, + {file = "pytest_codspeed-4.3.0.tar.gz", hash = "sha256:5230d9d65f39063a313ed1820df775166227ec5c20a1122968f85653d5efee48"}, ] [package.dependencies] From d47e0d1bec01d6e8f7a33114ede05b36242cdfca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:35:11 -1000 Subject: [PATCH 1327/1433] chore(pre-commit.ci): pre-commit autoupdate (#1656) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 387a2d548..773bdef7b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.8 + rev: v4.13.9 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.2 + rev: v0.15.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From 54560dd4dfc64a553df2311da235cee88dc9bf34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:52:49 -1000 Subject: [PATCH 1328/1433] chore(deps-dev): bump sphinx-rtd-theme from 3.0.2 to 3.1.0 (#1649) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4887fc2e2..9ea1276e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -914,19 +914,19 @@ test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools [[package]] name = "sphinx-rtd-theme" -version = "3.0.2" +version = "3.1.0" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, - {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, + {file = "sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89"}, + {file = "sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c"}, ] [package.dependencies] -docutils = ">0.18,<0.22" -sphinx = ">=6,<9" +docutils = ">0.18,<0.23" +sphinx = ">=6,<10" sphinxcontrib-jquery = ">=4,<5" [package.extras] @@ -1145,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "83fba2eb285e79e08bf94a50689369cbf5ca102fa8b27d963397e13a2a63037e" +content-hash = "afc14757339a8be987413f9b729e8a80e596d1cb7f4edadd048d41a796faf823" diff --git a/pyproject.toml b/pyproject.toml index 4314f041d..42212bba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ pytest-codspeed = ">=3.1,<5.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" -sphinx-rtd-theme = "^3.0.2" +sphinx-rtd-theme = "^3.1.0" [tool.ruff] target-version = "py39" From 98b5968fd4a12e6bed50cd9577026d7a99f6ada5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:52:59 -1000 Subject: [PATCH 1329/1433] chore(deps-dev): bump urllib3 from 2.6.0 to 2.6.3 in the pip group across 1 directory (#1647) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9ea1276e8..b84542c01 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1105,14 +1105,14 @@ files = [ [[package]] name = "urllib3" -version = "2.6.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ - {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, - {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] From 949203f1222b3f2c25ba4d6c36c81f6182e4bc57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:53:10 -1000 Subject: [PATCH 1330/1433] chore(ci): bump the github-actions group across 1 directory with 6 updates (#1655) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd654ba86..d8444bb6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.12" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -27,7 +27,7 @@ jobs: name: Lint Commit Messages runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 @@ -68,11 +68,11 @@ jobs: python-version: "pypy-3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: Install poetry run: pipx install poetry - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" @@ -97,9 +97,9 @@ jobs: benchmark: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: Setup Python 3.13 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: 3.13 - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 @@ -108,7 +108,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@972e3437949c89e1357ebd1a2dbc852fcbc57245 # v3 + uses: CodSpeedHQ/action@2ac572851726409c88c02a307f1ea2632a9ea59b # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -132,7 +132,7 @@ jobs: newest_release_tag: ${{ steps.release.outputs.tag }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} @@ -248,13 +248,13 @@ jobs: pyver: cp314t steps: - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: fetch-depth: 0 ref: "master" # Used to host cibuildwheel - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.12" - name: Set up QEMU @@ -282,20 +282,20 @@ jobs: echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@63fd63b352a9a8bdcc24791c9dbee952ee9a8abc # v3.3.0 + uses: pypa/cibuildwheel@298ed2fb2c105540f5ed055e8a6ad78d82dd3a7e # v3.3.1 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} @@ -308,7 +308,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v4 + - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir From fff1f2c7320a27932363ab101df01f7f1649f12b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:59:10 -1000 Subject: [PATCH 1331/1433] chore(pre-commit.ci): pre-commit autoupdate (#1657) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 773bdef7b..cd518fe13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,13 +40,13 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.4 + rev: v0.15.5 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 From c672aec1b5e711398715f719a8a1ee4b511c8af7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:59:15 -1000 Subject: [PATCH 1332/1433] chore(ci): bump the github-actions group with 5 updates (#1661) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8444bb6a..5201a5183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: - name: Test with Pytest run: poetry run pytest --durations=20 --timeout=60 -v --cov=zeroconf --cov-branch --cov-report xml --cov-report html --cov-report term-missing tests - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -108,7 +108,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@2ac572851726409c88c02a307f1ea2632a9ea59b # v3 + uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -259,7 +259,7 @@ jobs: python-version: "3.12" - name: Set up QEMU if: ${{ matrix.qemu }} - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: all # This should be temporary @@ -288,7 +288,7 @@ jobs: fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@298ed2fb2c105540f5ed055e8a6ad78d82dd3a7e # v3.3.1 + uses: pypa/cibuildwheel@ee02a1537ce3071a004a6b08c41e72f0fdc42d9a # v3.4.0 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} @@ -308,7 +308,7 @@ jobs: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir From d16c310b47e33282ee1667b4b0715ba82c50fe6f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:59:28 -1000 Subject: [PATCH 1333/1433] chore(pre-commit.ci): pre-commit autoupdate (#1659) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cd518fe13..6561eba67 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.5 + rev: v0.15.8 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] From e11c560efb78acd246acf7612b7c6e1fa0a54c59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:59:39 -1000 Subject: [PATCH 1334/1433] chore(deps-dev): bump pytest-cov from 7.0.0 to 7.1.0 (#1660) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index b84542c01..dd876c9ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -767,14 +767,14 @@ compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, + {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, + {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] @@ -1109,7 +1109,7 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, From 0c28db7c6bc4acaeb285c44e18466b939bf122ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:59:48 -1000 Subject: [PATCH 1335/1433] chore(deps-dev): bump setuptools from 80.9.0 to 82.0.1 (#1658) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 16 ++++++++-------- pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index dd876c9ea..4604720e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -844,24 +844,24 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "snowballstemmer" @@ -1145,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "afc14757339a8be987413f9b729e8a80e596d1cb7f4edadd048d41a796faf823" +content-hash = "c752fd1b3b39150e236be3eeae0fbb0c1f34b964b044befd276b39b8039de853" diff --git a/pyproject.toml b/pyproject.toml index 42212bba4..79c5ba601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=0.20.3,<1.3.0" cython = "^3.2.4" -setuptools = ">=65.6.3,<81.0.0" +setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=3.1,<5.0" From 54f72da7e43a509d599fa6e768ae27609b95d642 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:48:38 -0500 Subject: [PATCH 1336/1433] chore(pre-commit.ci): pre-commit autoupdate (#1662) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6561eba67..7bc839b8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.9 + rev: v4.13.10 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.12 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.20.2 hooks: - id: mypy additional_dependencies: [ifaddr] From 0e94c2504348106f15df8a71618fc5209553c752 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:48:51 -0500 Subject: [PATCH 1337/1433] chore(deps-dev): bump pytest-codspeed from 4.3.0 to 4.4.0 (#1664) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 36 ++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4604720e6..12f2f34fe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -732,28 +732,28 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.3.0" +version = "4.4.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2acecc4126658abebc683b38121adec405a46e18a619d49d6154c6e60c5deb2"}, - {file = "pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:619120775e92a3f43fb4ff4c256a251b1554c904d95e2154a382484283f0388a"}, - {file = "pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbeff1eb2f2e36df088658b556fa993e6937bf64ffb07406de4db16fd2b26874"}, - {file = "pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:878aad5e4bb7b401ad8d82f3af5186030cd2bd0d0446782e10dabb9db8827466"}, - {file = "pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527a3a02eaa3e4d4583adc4ba2327eef79628f3e1c682a4b959439551a72588e"}, - {file = "pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9858c2a6e1f391d5696757e7b6e9484749a7376c46f8b4dd9aebf093479a9667"}, - {file = "pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f2fd8497456eefbd325673f677ea80d93bb1bc08a578c1fa43a09cec3d1879"}, - {file = "pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6a36a2a9da1406bc50428437f657f0bd8c842ae54bee5fb3ad30e01d50c0f5"}, - {file = "pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bec30f4fc9c4973143cd80f0d33fa780e9fa3e01e4dbe8cedf229e72f1212c62"}, - {file = "pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6584e641cadf27d894ae90b87c50377232a97cbfd76ee0c7ecd0c056fa3f7f4"}, - {file = "pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0d1f6ea594f29b745c634d66d5f5f1caa1c3abd2af82fea49d656038e8fc77"}, - {file = "pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2f5bb6d8898bea7db45e3c8b916ee48e36905b929477bb511b79c5a3ccacda4"}, - {file = "pytest_codspeed-4.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ac31ae566bf047e91c79b6d12d9a31efedad556ff9258294d6ecebeacb92fa4"}, - {file = "pytest_codspeed-4.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d19b48c9dc35b227a7ac863d4b3c58132256c2ba1aae3220aaddbf6f3f5f9a"}, - {file = "pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a"}, - {file = "pytest_codspeed-4.3.0.tar.gz", hash = "sha256:5230d9d65f39063a313ed1820df775166227ec5c20a1122968f85653d5efee48"}, + {file = "pytest_codspeed-4.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3ae6f4053042c3a9ae3b05416fb42253c5e514e89391eb25e9c9e3ac8de8677"}, + {file = "pytest_codspeed-4.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83479a6719598d2910969a60cc410c7283c262c876422a9157dca2f2ab42fa1d"}, + {file = "pytest_codspeed-4.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29b1bf8a36e18d11641a5e610e23a94036b04185e3099978d81a873a5bd3635c"}, + {file = "pytest_codspeed-4.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06943110e7a8a4b54f4b13aaa3ff8db39caa02b2f61705916887649e36b9713a"}, + {file = "pytest_codspeed-4.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a5c1d51e7ca72ffe247c99b9a97a54191185e8f7a27528e2200d7416da2a68b"}, + {file = "pytest_codspeed-4.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:215170441e57bfcbefd179dfd86ccd54ed0ee235e0602a068ce4448b35f13cb2"}, + {file = "pytest_codspeed-4.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee3e1964446011ca192eebf0350227df231a5b88af57e518f2a4328fc8ca5131"}, + {file = "pytest_codspeed-4.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340dbb1cc5a21434e0e29bd68ab03c7dc7ad9bfde09d1980b7161352c4c2f048"}, + {file = "pytest_codspeed-4.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:413666266762f9cef1321ba971a9e127b97a1f1dad40ddfd2184c2bc5ac157f9"}, + {file = "pytest_codspeed-4.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e258e6c3d5a8a02ae02a64831be3acd44c19210ffbf13321bdbb8c111c5c6fe4"}, + {file = "pytest_codspeed-4.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d5dd94dcb69460f916acb9c69865d0171b98acec3ce256645d0c0275b553d7"}, + {file = "pytest_codspeed-4.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33c38e0e797c74506004f231fc53eab0e412987de281755f714018334381aa3a"}, + {file = "pytest_codspeed-4.4.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4afa9455a9b198c5e898224c751182fcf53f67f11fb27c2c3346284da1baa018"}, + {file = "pytest_codspeed-4.4.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98524af4ddd6006ea064791bb15a43957d78fab040cb6f499ca73a369da373e6"}, + {file = "pytest_codspeed-4.4.0-py3-none-any.whl", hash = "sha256:a6aab2fa73523f538e7729c20ccf4a1e8e921324c9877a816b05334135950fd9"}, + {file = "pytest_codspeed-4.4.0.tar.gz", hash = "sha256:edb7c101d9c50439a42cf02cfa9c0ac92da618841636bbebf87c3fa54669442a"}, ] [package.dependencies] @@ -1145,4 +1145,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "c752fd1b3b39150e236be3eeae0fbb0c1f34b964b044befd276b39b8039de853" +content-hash = "0e59529d7b9b577841ad42ddab051f6f4666e21fe3064319ff9ae9659f4e3364" diff --git a/pyproject.toml b/pyproject.toml index 79c5ba601..a3c3114cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ pytest-asyncio = ">=0.20.3,<1.3.0" cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" -pytest-codspeed = ">=3.1,<5.0" +pytest-codspeed = ">=4.4.0,<5.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" From 8f8b4d6526729906337f4562c7b391745bb878af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 11:34:12 -0700 Subject: [PATCH 1338/1433] docs: add CLAUDE.md orientation file and pr-workflow skill (#1672) --- .claude/skills/pr-workflow/SKILL.md | 128 +++++++++++++++++ CLAUDE.md | 209 ++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 .claude/skills/pr-workflow/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md new file mode 100644 index 000000000..4e38e7ae9 --- /dev/null +++ b/.claude/skills/pr-workflow/SKILL.md @@ -0,0 +1,128 @@ +--- +name: pr-workflow +description: Create pull requests for python-zeroconf/python-zeroconf. Use when creating PRs, submitting changes, or preparing contributions. +allowed-tools: Read, Bash, Glob, Grep +--- + +# python-zeroconf PR Workflow + +When creating a pull request for `python-zeroconf/python-zeroconf`, +follow these steps. Repo-wide conventions live in +[CLAUDE.md](../../../CLAUDE.md); this skill summarises the parts +that matter at PR-creation time. + +## 1. Create branch from origin/master + +The default branch is `master`, not `main`. `origin` already +points at `python-zeroconf/python-zeroconf` — there is no fork in +this workflow. Always re-fetch first so the branch is based on +the latest `master`: + +```bash +git fetch origin +git checkout -b origin/master +``` + +If you accidentally branch from `main`, `gh pr create` will fail +because the base branch does not exist. + +## 2. There is no PR template + +`python-zeroconf` does not ship a `.github/PULL_REQUEST_TEMPLATE.md` +— PR bodies are free-form. Aim for a body that looks roughly like: + +``` +## Summary +<1–3 sentence prose description of what changed and why> + +## Details + + +## Test plan +- [ ] +- [ ] +``` + +Cite the relevant RFC section (RFC 6762 / RFC 6763) for any +behaviour change that affects packet contents or timing — +reviewers shouldn't have to reverse-engineer why a constant moved +or a probe interval changed. + +## 3. Commit message conventions + +Commit messages are linted by `commitlint` with +`@commitlint/config-conventional`, _and_ by `commitizen` in +pre-commit (`stages: [commit-msg]`). Both must pass. + +- **Conventional Commits prefix is required.** Pick from: + `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, + `ci`, `chore`, `style`, `revert`. The `feat`/`fix`/`perf` + prefixes show up in the release-notes; `chore*` and `ci*` are + excluded by semantic-release (`exclude_commit_patterns` in + `pyproject.toml`), so use those for housekeeping. +- **Imperative-mood subject.** "fix: handle empty answer", not + "fix: handled empty answer". +- **No header length cap.** The commitlint config sets + `header-max-length: [0, "always", Infinity]`, so a slightly + longer subject is fine if it earns the space; don't pad. +- **No `Co-Authored-By` trailers from automated agents.** +- **One logical change per commit.** Let pre-commit run (ruff + lint + format, mypy, flake8, codespell, cython-lint, + pyupgrade). If a hook auto-fixes something, re-stage and + re-commit; the commit-msg hook re-runs on the new commit. + +## 4. Cython / `.pxd` discipline + +If the PR touches any module listed in `TO_CYTHONIZE` +(`build_ext.py`): + +- Update the sibling `.pxd` in the same commit if you changed a + `cdef class` layout or a `cpdef`/`cdef` signature. +- Do not hand-edit the in-tree `.c` files; the build regenerates + them, and they're excluded from sdist (`exclude = ["**/*.c"]` + in `pyproject.toml`). +- Verify the extension still builds locally: + `REQUIRE_CYTHON=1 poetry install` (re-installs in-place, + failing loudly if Cython rejects anything). +- Verify it still works without the extension: + `SKIP_CYTHON=1 poetry install && poetry run pytest tests/`. + +## 5. Push and create the PR + +```bash +git push -u origin +gh pr create --repo python-zeroconf/python-zeroconf --base master \ + --title "" \ + --body-file /tmp/pr-body.md +``` + +Always pass the body via `--body-file`, never `--body "..."` with +shell-escaping — Markdown backticks, asterisks, and angle +brackets must pass through verbatim. + +The PR title should match the commit subject (same Conventional +Commits prefix). If the PR ends up squash-merged, the title +becomes the merged commit message, so it has to satisfy +commitlint on its own. + +## 6. After the PR is open + +CI runs three jobs: + +- `lint` — `pre-commit/action`. If pre-commit passed locally + this passes too. +- `commitlint` — `wagoid/commitlint-github-action`. Validates + every commit on the PR; if you amended after pushing, force- + push the branch so the rewritten commits get linted. +- `test` — the full pytest matrix across CPython 3.9–3.14, + 3.14t (free-threaded), and PyPy 3.9 / 3.10, on Linux + macOS + + Windows. The free-threaded entry is the canary for unguarded + shared-state bugs; failures there are often genuine even when + the GIL-enabled rows pass. + +CodSpeed also runs on PRs (`CodSpeedHQ/action`) and posts a +benchmark delta as a check. A regression there is signal — if +the PR is a perf change, the comment is the evidence; if not, a +red CodSpeed check usually means the hot path picked up an extra +Python-level branch and wants a second look. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7e22c336b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,209 @@ +# Notes for LLM contributors + +A short orientation file for an LLM working in this repo. Skim +before making changes; keep edits consistent with what's described +here. Read [README.rst](README.rst) for the user-facing intro. + +## What this project is + +`python-zeroconf` is a pure-Python implementation of multicast DNS +service discovery (mDNS / DNS-SD, RFC 6762 + RFC 6763). It is the +mDNS engine behind Home Assistant and a long list of other Python +projects that need to announce or discover services on the local +network. Public API is exported from the top-level `zeroconf` +package; an async API lives at `zeroconf.asyncio`. + +There is no external protocol owner — the on-the-wire format is +the mDNS / DNS-SD RFCs. Behaviour changes that affect packet +contents or timing should cite the relevant RFC section. + +Hot paths (`_cache`, `_dns`, `_history`, `_listener`, +`_record_update`, `_updates`, `_protocol/{incoming,outgoing}`, +`_handlers/*`, `_services/*`, `_utils/{ipaddress,time}`) are +Cythonized at build time for throughput. They keep working as +pure Python — `SKIP_CYTHON=1` disables the extension build — but +production wheels ship compiled and CodSpeed benchmarks track that +path. The authoritative list of cythonized modules lives in +`build_ext.py` (`TO_CYTHONIZE`). + +## Code style + +- **Docstrings: terse, default to single-line.** A docstring is + the function's _contract_, not its narrative. Almost every + docstring should be one line — `"""Summary."""` — describing + what the function does and what the caller can pass. Multi-line + is the exception, only justified when there is non-obvious + caller-visible behaviour the type signature and parameter names + don't already convey. + + **What does NOT belong in docstrings or comments:** + - Rationale / motivation / "why we used to do X" — that's the + PR description and the commit message. Git already remembers. + - Cross-references to issue numbers ("closes #N", "follow-up + to #M") — the PR body carries those. + - Restatement of the function body in prose. If the next line + of the docstring is just describing what the next line of + code does, delete the docstring line. + - Test docstrings retelling the production-side story. A test + docstring should name what the test pins, in one sentence — + not re-explain the bug, the fix, or the surrounding flow. + +- **Comments**: same bar. Default to writing no comments. Add + one only when the _why_ is non-obvious: a hidden constraint, a + subtle invariant, a workaround for a specific bug, behaviour + that would surprise a reader. RFC citations are useful when the + reason for a timing constant or framing decision is "the spec + says so" — leave those in. If removing the comment wouldn't + confuse a future reader, don't write it. + + **Don't remove existing comments** unless the code they + describe is gone — the original author left them for a reason. + +- **Don't pad commits, docstrings, or comments with cross- + references** to old codepaths or issue numbers unless there's + a clear reason a future reader needs that link. + +- **Method order**: public API at the top, private helpers + (`_underscore_prefixed`) at the bottom. Modules whose names + start with `_` (`_cache`, `_dns`, `_handlers/`, etc.) are + internal; the supported surface is what `zeroconf/__init__.py` + and `zeroconf/asyncio.py` re-export. + +- **Line length**: 110 (ruff `line-length = 110`). + `requires-python = ">=3.9"`, `target-version = "py39"` for + ruff; pyupgrade runs `--py39-plus`. + +- **Imports**: ruff/isort sorted, `profile = "black"`, + `known_first_party = ["zeroconf", "tests"]`. Prefer + `from __future__ import annotations` so modern type syntax + works on 3.9. + +- **Generated `.c` files are not lint-targets.** `*.c` files + next to each cythonized module are Cython output — never hand- + edit them. They are excluded from sdist (`exclude = ["**/*.c"]` + in `pyproject.toml`) and regenerated by the build. + +## Commit / PR conventions + +- **Conventional Commits are enforced.** CI runs commitlint with + `@commitlint/config-conventional`, and pre-commit runs + commitizen on the commit message. The header has no length cap + (`header-max-length = [0, "always", Infinity]`), but the + _type_ prefix is required: `feat:`, `fix:`, `chore:`, `ci:`, + `docs:`, `refactor:`, `test:`, `perf:`, `build:`, etc. + `semantic-release` excludes `chore*` and `ci*` from the + changelog, so use those prefixes for housekeeping and reserve + `feat`/`fix`/`perf` for user-visible changes. +- **No `Co-Authored-By` trailers from automated agents.** Project + preference. +- Imperative-mood subject after the type prefix ("fix: handle + empty answer", not "fix: handled empty answer"). +- There is no `.github/PULL_REQUEST_TEMPLATE.md` in this repo — + the PR body is free-form. The `pr-workflow` skill (under + `.claude/skills/pr-workflow/`) walks through the conventions + that do apply: conventional-commit subject, RFC citations for + protocol-affecting changes, a test-plan section. +- Pre-commit runs ruff (lint + format), mypy, flake8, codespell, + cython-lint, and pyupgrade. Run pre-commit locally before + pushing; the CI `lint` job is just `pre-commit/action`, so a + green local pre-commit run = a green CI lint job. + +## Running tests + +```bash +poetry run pytest --durations=20 --timeout=60 -v tests +``` + +…or `make test`, which runs the same command. Test discovery +defaults from `pyproject.toml` already pass `--cov=zeroconf` +and `pythonpath = ["src"]`. `pytest-asyncio` is used in the +default per-test mode (no auto mode); async tests are marked +explicitly with `@pytest.mark.asyncio`. + +CodSpeed benchmarks live under `tests/benchmarks/` and run in CI +through `CodSpeedHQ/action`. Ad-hoc microbenchmarks for manual +profiling live under `bench/` — those don't run in CI. + +The CI matrix includes CPython 3.9 – 3.14, the free-threaded +3.14t build, and PyPy 3.9 / 3.10. Don't add anything that breaks +on the free-threaded build (no module-level mutable globals +mutated from multiple threads without locks; no +`PyDict_Next`-style escape hatches in Cython). + +## Build conventions + +- **Cython is optional but expected in wheels.** `build_ext.py` + cythonizes every module listed in `TO_CYTHONIZE`. The build is + driven by `poetry-core` (`generate-setup-file = true`, + `script = "build_ext.py"`); `BuildExt.build_extensions` + swallows build failures so source installs fall back to pure + Python. `SKIP_CYTHON=1` skips the Cython step entirely; + `REQUIRE_CYTHON=1` re-raises so a missing extension fails the + build loudly (CI wheel builds use this). +- **Modules that get Cythonized ship a sibling `.pxd`** for type + declarations. When changing the signature of a Cythonized + function — or adding a new attribute to a `cdef class` — update + the `.pxd` in the same commit, or the extension will pick up a + stale declaration and the in-tree `.c` will be regenerated with + the wrong layout. +- Adding a new module to `TO_CYTHONIZE` is a deliberate decision: + the module must be hot enough to matter, must not rely on + Python-only constructs that Cython refuses (the existing + `PERF401`, `PYI032`, `PYI041` ruff ignores exist because Cython + rejects closures and PEP 604 unions in `cpdef`), and must stay + free-threading-safe. +- `compiler_directives = {"language_level": "3"}`. The build + pipeline does not currently set `freethreading_compatible`, + but the test matrix exercises 3.14t, so any new Cython module + needs to keep working there. + +## Useful entry points + +| Path | What | +| -------------------------------- | ------------------------------------------------------------------------------ | +| `src/zeroconf/__init__.py` | Public package — re-exports `Zeroconf`, `ServiceBrowser`, `ServiceInfo`, etc. | +| `src/zeroconf/asyncio.py` | Async API: `AsyncZeroconf`, `AsyncServiceBrowser`, `AsyncZeroconfServiceTypes` | +| `src/zeroconf/_core.py` | `Zeroconf` core — socket setup, send/recv loop, registration/probing | +| `src/zeroconf/_engine.py` | Asyncio engine driving the listener | +| `src/zeroconf/_listener.py` | Cython-accelerated packet listener | +| `src/zeroconf/_cache.py` | DNS record cache (Cythonized) | +| `src/zeroconf/_dns.py` | DNS record / question classes (Cythonized) | +| `src/zeroconf/_history.py` | Outgoing-question history for known-answer suppression | +| `src/zeroconf/_record_update.py` | Record-update dataclass passed to listeners | +| `src/zeroconf/_protocol/` | `DNSIncoming` / `DNSOutgoing` wire codec (Cythonized) | +| `src/zeroconf/_handlers/` | Query / answer / multicast queueing (Cythonized) | +| `src/zeroconf/_services/` | `ServiceBrowser`, `ServiceInfo`, `ServiceRegistry`, types | +| `src/zeroconf/_updates.py` | `RecordUpdateListener` base class (Cythonized) | +| `src/zeroconf/_utils/` | `ipaddress`, `time`, `net`, `name`, `asyncio` helpers | +| `src/zeroconf/const.py` | Timeouts, intervals, multicast group constants | +| `src/zeroconf/_exceptions.py` | Public exception hierarchy | +| `tests/` | Pytest suite | +| `tests/benchmarks/` | CodSpeed benchmarks | +| `bench/` | Manual microbenchmarks (not run in CI) | +| `build_ext.py` | `TO_CYTHONIZE` list + `poetry-core` build hook | + +## Things not to do + +- **Don't hand-edit the generated `.c` files** next to Cythonized + modules. They are build output; modify the `.py` (and `.pxd`) + and let Cython regenerate. +- **Don't change a Cythonized module's `cdef class` layout or a + `cpdef`/`cdef` signature without updating its `.pxd`** — the + extension build will silently pick up a stale declaration and + the resulting wheel will crash at import time. +- **Don't add `Co-Authored-By` trailers from automated agents + to commits** in this repo. +- **Don't introduce a commit message that violates Conventional + Commits.** The commitlint job will fail the PR. +- **Don't tighten timings or constants in `const.py` without an + RFC citation in the commit message.** mDNS interop with + Avahi / Bonjour / Windows hinges on those numbers. +- **Don't bypass `BuildExt`'s exception swallowing in + `build_ext.py` without thought.** Pure-Python fallback is a + feature for source installs on platforms without a compiler + (and for the PyPy matrix entries, which never load the C + extensions). +- **Don't break the free-threaded test matrix entry (`3.14t`).** + CPython 3.14t exercises this code without the GIL; module- + level mutable state and unguarded cross-thread Cython attribute + access will surface as flakiness there before anywhere else. From 93e5e4f2f9abacba847f49f080463618211a38c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:32:21 -0700 Subject: [PATCH 1339/1433] chore(pre-commit.ci): pre-commit autoupdate (#1671) Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 4 ++-- tests/test_exceptions.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bc839b8b..706f4d2e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.10 + rev: v4.15.1 hooks: - id: commitizen stages: [commit-msg] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.2 + rev: v2.0.0 hooks: - id: mypy additional_dependencies: [ifaddr] diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index ab181db1f..94a407c19 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -24,7 +24,7 @@ def teardown_module(): class Exceptions(unittest.TestCase): - browser = None # type: Zeroconf + browser: Zeroconf @classmethod def setUpClass(cls): From 72a0152a1903320bf8bae28c73c2a2b782573ccb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:34:11 -0700 Subject: [PATCH 1340/1433] chore(deps-dev): bump pytest-codspeed from 4.4.0 to 4.5.0 (#1670) Signed-off-by: dependabot[bot] --- poetry.lock | 43 +++++++++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index 12f2f34fe..af613e1d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -732,28 +732,35 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.4.0" +version = "4.5.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3ae6f4053042c3a9ae3b05416fb42253c5e514e89391eb25e9c9e3ac8de8677"}, - {file = "pytest_codspeed-4.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83479a6719598d2910969a60cc410c7283c262c876422a9157dca2f2ab42fa1d"}, - {file = "pytest_codspeed-4.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29b1bf8a36e18d11641a5e610e23a94036b04185e3099978d81a873a5bd3635c"}, - {file = "pytest_codspeed-4.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06943110e7a8a4b54f4b13aaa3ff8db39caa02b2f61705916887649e36b9713a"}, - {file = "pytest_codspeed-4.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a5c1d51e7ca72ffe247c99b9a97a54191185e8f7a27528e2200d7416da2a68b"}, - {file = "pytest_codspeed-4.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:215170441e57bfcbefd179dfd86ccd54ed0ee235e0602a068ce4448b35f13cb2"}, - {file = "pytest_codspeed-4.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee3e1964446011ca192eebf0350227df231a5b88af57e518f2a4328fc8ca5131"}, - {file = "pytest_codspeed-4.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340dbb1cc5a21434e0e29bd68ab03c7dc7ad9bfde09d1980b7161352c4c2f048"}, - {file = "pytest_codspeed-4.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:413666266762f9cef1321ba971a9e127b97a1f1dad40ddfd2184c2bc5ac157f9"}, - {file = "pytest_codspeed-4.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e258e6c3d5a8a02ae02a64831be3acd44c19210ffbf13321bdbb8c111c5c6fe4"}, - {file = "pytest_codspeed-4.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d5dd94dcb69460f916acb9c69865d0171b98acec3ce256645d0c0275b553d7"}, - {file = "pytest_codspeed-4.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33c38e0e797c74506004f231fc53eab0e412987de281755f714018334381aa3a"}, - {file = "pytest_codspeed-4.4.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4afa9455a9b198c5e898224c751182fcf53f67f11fb27c2c3346284da1baa018"}, - {file = "pytest_codspeed-4.4.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98524af4ddd6006ea064791bb15a43957d78fab040cb6f499ca73a369da373e6"}, - {file = "pytest_codspeed-4.4.0-py3-none-any.whl", hash = "sha256:a6aab2fa73523f538e7729c20ccf4a1e8e921324c9877a816b05334135950fd9"}, - {file = "pytest_codspeed-4.4.0.tar.gz", hash = "sha256:edb7c101d9c50439a42cf02cfa9c0ac92da618841636bbebf87c3fa54669442a"}, + {file = "pytest_codspeed-4.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ddc80dda2018aae3bcac9571d47de26aacd9cfb1764b3a1704fa269474cc83f7"}, + {file = "pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:108ae3fecf8a665f017f2abc92a4d9740c57eb8432436baeb489053787427504"}, + {file = "pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8b7a880f2cac69d167affe5e85d9fc7f21beeb1c7591ef2109fbc0983b806a4"}, + {file = "pytest_codspeed-4.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6da6f26435512110736dd258021bbf7859caf4d2a21c7ed06a86b67a999fac7"}, + {file = "pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be191120b1cb0252b443ef37887c94772bab4ca0c42cad7c15bcbcfcbb656ac4"}, + {file = "pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474730e996d424b17f7301d4b846261cca92d195b9fcb7de38599be9d68ee9ac"}, + {file = "pytest_codspeed-4.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db706a7a4200e8e236c31c77935fedcc0edbf44959ab8c156297909d9e8cfd33"}, + {file = "pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac844078bd8760e7fc66debe1e90b4593dfce15f60f26b334e1137d4902df3a9"}, + {file = "pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ecd52a277a5e5f0013e29084b49f9c5f60026d0585f58b86463cb188df5029"}, + {file = "pytest_codspeed-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcc3309d046082a6e0dbd1d9f2bc5c83b0446c93ff011e3880b47c69bf8042cf"}, + {file = "pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12b49954268ed6828ce5a8d87aff13888946c254bff4ef9472bb4d5ae5272667"}, + {file = "pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbeeb76d98335037670068c0d30319415f896e9c37eca510249b74684b460925"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b73f71e7cb5c83cf5d765d5ca39d08bb1090a9d2d2268496a22ca24b1776e3a"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399e146240a52458aa4b5fc861a88551bc52eb9e2d30c8f8b328ddebc084e4f6"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4b43f59d1c31e7c193567369f767647e466f95126671c90be084c58633544f"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4ef8651294386c032d86070893f8349929280162cf22210dbd488697ce26de21"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca31f5d0e783823a78442d5434382eb32f3885153d1833eb645c92d0c499470b"}, + {file = "pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ddd1a9f2dc0615479b2ba3f445a2e3587ce1316296fc79224700e73db06408"}, + {file = "pytest_codspeed-4.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:550bf00dbe2cbd0ddae1502aeedf1896be3525daa2dc053264efae0e3f7f71b4"}, + {file = "pytest_codspeed-4.5.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba8205a4df6d10ad2fe0095a7c7a081181ae4c63e2a91d34589935a355e9fd55"}, + {file = "pytest_codspeed-4.5.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ae57afb1bbfb56e90f41e6e0df6a93d450dab4577058ec2f978dfec54e93ce"}, + {file = "pytest_codspeed-4.5.0-py3-none-any.whl", hash = "sha256:b19bfb734dcbd47b78022285a6eb9f2bf6331ef1bb8c15c2775058945d5f4ce3"}, + {file = "pytest_codspeed-4.5.0.tar.gz", hash = "sha256:deb6ab9c9b07eba56fcb7b97206c7e48aaff697b6f73a013d8dbe4f62e76afd3"}, ] [package.dependencies] @@ -1145,4 +1152,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "0e59529d7b9b577841ad42ddab051f6f4666e21fe3064319ff9ae9659f4e3364" +content-hash = "55ac531bfbed3a124eef09d77dd7db2d6ec82d9d3eb04c1ba8a4e92685a71a66" diff --git a/pyproject.toml b/pyproject.toml index a3c3114cd..509c71976 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ pytest-asyncio = ">=0.20.3,<1.3.0" cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" -pytest-codspeed = ">=4.4.0,<5.0" +pytest-codspeed = ">=4.5.0,<5.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" From 13f9048f0f9786ce18e89daef04073847735a006 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 12:56:45 -0700 Subject: [PATCH 1341/1433] docs: add SECURITY.md with private vulnerability reporting policy (#1675) --- CLAUDE.md | 11 +++++++++++ SECURITY.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 SECURITY.md diff --git a/CLAUDE.md b/CLAUDE.md index 7e22c336b..ac8186480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,17 @@ mutated from multiple threads without locks; no but the test matrix exercises 3.14t, so any new Cython module needs to keep working there. +## Reporting security issues + +Suspected security vulnerabilities go through GitHub's [private +vulnerability reporting][gh-report], not public issues or pull +requests. The policy is spelled out in [SECURITY.md](SECURITY.md). +If a user describes what sounds like a vulnerability in chat, +point them at that route instead of opening a public issue, PR, +or commit that names the bug class and the affected code path. + +[gh-report]: https://github.com/python-zeroconf/python-zeroconf/security/advisories/new + ## Useful entry points | Path | What | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..5dee00d68 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security Policy + +## Reporting a vulnerability + +Please report security vulnerabilities privately through GitHub's +[private vulnerability reporting][gh-report] for this repository. +That route sends the report directly to the maintainers and lets +us coordinate a fix, a CVE, and a release before public +disclosure. + +**Do not** open a regular GitHub issue, a pull request, or post +to a public channel (mailing list, chat room, Stack Overflow, +etc.) for a suspected vulnerability. If you are unsure whether +something is a vulnerability, use the private report — we would +rather see a false alarm than a public one. + +We aim to acknowledge new reports within a few business days. + +[gh-report]: https://github.com/python-zeroconf/python-zeroconf/security/advisories/new + +## Supported versions + +Security fixes are released against the latest `0.x` line on +PyPI. Older releases are not maintained — please upgrade to the +current release before reporting, and confirm the issue still +reproduces there. + +## Scope + +`python-zeroconf` is an mDNS / DNS-SD library. By design it +parses untrusted multicast traffic from the local network +(RFC 6762, RFC 6763). In-scope issues include: + +- Memory-safety, parsing, or denial-of-service issues triggered + by crafted mDNS / DNS-SD packets reaching `DNSIncoming`, the + record cache, the service registry, or listener callbacks. +- Logic bugs that cause the library to answer queries it should + not, leak information across interfaces, or hijack a service + name from another responder in a way the RFCs don't sanction. +- Issues in the build / packaging pipeline (`build_ext.py`, + wheel contents, signed-release flow) that could lead to a + compromised wheel on PyPI. + +Out of scope: + +- Risks inherent to running an mDNS responder on an untrusted + network — mDNS is unauthenticated by design (RFC 6762 §21). + Reports of the form "a malicious LAN peer can send packets" + are expected behaviour unless they cross one of the lines + above. +- Misconfiguration of a downstream application that uses the + library. From ba58754cf2f84807d69ab2fcc67285cae4f1c808 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 13:12:58 -0700 Subject: [PATCH 1342/1433] ci: use uv to install poetry in CI workflow (#1673) --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5201a5183..4a642d28b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - name: Install poetry - run: pipx install poetry + run: uv tool install poetry - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: @@ -102,7 +106,12 @@ jobs: uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: 3.13 - - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + - name: Install poetry + run: uv tool install poetry - name: Install Dependencies run: | REQUIRE_CYTHON=1 poetry install --only=main,dev From cbdc4047d31d99bb2c31a95e1220f3ef035c369f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 13:13:09 -0700 Subject: [PATCH 1343/1433] chore(release): keep semantic-release on 0.x series and revert version to 0.148.0 (#1674) --- pyproject.toml | 8 ++++++-- src/zeroconf/__init__.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 509c71976..e284acb6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "1.0.0" +version = "0.148.0" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" @@ -58,6 +58,7 @@ version_variables = [ ] build_command = "pip install poetry && poetry build" tag_format = "{version}" +allow_zero_version = true [tool.semantic_release.changelog] exclude_commit_patterns = [ @@ -71,8 +72,11 @@ keep_trailing_newline = true [tool.semantic_release.branches.master] match = "master" +[tool.semantic_release.branches."release-0.x"] +match = "release-0.x" + [tool.semantic_release.branches.noop] -match = "(?!master$)" +match = "(?!(master|release-0.x)$)" prerelease = true [tool.poetry.dependencies] diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 275913982..418ad50d5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "1.0.0" +__version__ = "0.148.0" __license__ = "LGPL" From dd8ede3a103e03c49d8419a9412a9a3503fc1929 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 13:13:21 -0700 Subject: [PATCH 1344/1433] chore(ci): bump the github-actions group across 1 directory with 4 updates (#1667) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a642d28b..0ff9ca13d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks - uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v3 + uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed tests/benchmarks @@ -162,7 +162,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases @@ -297,14 +297,14 @@ jobs: fetch-depth: 0 - name: Build wheels ${{ matrix.musl }} (${{ matrix.qemu }}) - uses: pypa/cibuildwheel@ee02a1537ce3071a004a6b08c41e72f0fdc42d9a # v3.4.0 + uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 # to supply options, put them in 'env', like: env: CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc REQUIRE_CYTHON: 1 - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.qemu }}-${{ matrix.pyver }} @@ -326,4 +326,4 @@ jobs: merge-multiple: true - uses: - pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From 5cfb09d89395cb507d436c45fc38edb9e44b94c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 13:53:12 -0700 Subject: [PATCH 1345/1433] docs: add Cython gotchas section to CLAUDE.md (#1679) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CLAUDE.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ac8186480..c500171eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,69 @@ mutated from multiple threads without locks; no but the test matrix exercises 3.14t, so any new Cython module needs to keep working there. +## Cython gotchas + +Non-obvious traps in the `.py` + `.pxd` setup that work fine in +pure-Python mode but break or silently misbehave in the shipped +Cython wheels. Distilled from the patterns that already exist in +this repo's `.pxd` files and from incidents in sibling Cython- +accelerated projects. + +- **`cdef`-typed module constants are not Python-importable.** + Declaring `cdef unsigned int _ANSWER_STRATEGY_POINTER` in + `query_handler.pxd` makes Cython treat + `_ANSWER_STRATEGY_POINTER = 1` in `query_handler.py` as a C int + assignment; the Python module dict never gets the binding. + `from zeroconf._handlers.query_handler import + _ANSWER_STRATEGY_POINTER` succeeds in pure-Python but raises + `ImportError` under Cython. If you need the value visible from + Python (e.g. a test wants to assert on it), define both names — + a public `ANSWER_STRATEGY_POINTER = 1` Python binding plus a + `cdef`-typed `_ANSWER_STRATEGY_POINTER = ANSWER_STRATEGY_POINTER` + alias for hot-path comparisons. + +- **Match the existing `unsigned int` convention for length, TTL, + type/class, and offset fields.** `_protocol/incoming.pxd`, + `_cache.pxd`, and `_handlers/*.pxd` already declare these as + `unsigned int` end-to-end. Introducing a `cdef int` return that + carries a value originally decoded into `unsigned int` flips + sign for any value with bit 31 set — TTL is a 32-bit DNS field + (RFC 1035 §3.2.1, interpreted as unsigned), so a large TTL + passed back through a `cdef int` boundary becomes negative and + trips `< 0` sentinel branches. Stay with `unsigned int` across + the whole call chain; if you need a real sentinel, return an + explicit value (`UINT_MAX`, a dedicated constant) and check for + it by equality. + +- **Module-level Python int constants force `PyLong_AsLong` on + every hot-path comparison.** `if record.type == _TYPE_PTR` + compiles to a Python attribute lookup + `PyLong_AsLong` per + call when `_TYPE_PTR` is just a `.py`-level binding. The repo + already follows the right pattern — `_cache.pxd` / + `record_manager.pxd` declare `cdef unsigned int _TYPE_PTR`, + `_DNS_PTR_MIN_TTL`, `_MIN_SCHEDULED_RECORD_EXPIRATION`, etc. + When adding a new size / TTL / type constant from `const.py` + to a `cdef` hot path in `_protocol/`, `_cache`, `_handlers/`, + or `_listener`, add the `cdef`-typed alias to the corresponding + `.pxd` at the same time. + +- **Sign-compare warnings in generated C are real.** `gcc`/ + `clang` warns when comparing `unsigned int` with `int` because + the signed value is implicitly converted to unsigned for the + compare — a negative value becomes a huge positive. Match the + signedness of compared operands in the `.pxd` (e.g. if the + local is `unsigned int`, declare the constant as + `cdef unsigned int`; if the local is `int`, declare it + `cdef int`). The warning predicts the unsigned -> signed + overflow class of bug. + +- **CodSpeed regressions only show up in the Cython build.** + Pure-Python (`SKIP_CYTHON=1`) tests can pass while production + wire-format hot paths regress. Trust the CodSpeed check on PRs + that touch any file in `TO_CYTHONIZE`; rebuild in place with + `REQUIRE_CYTHON=1 poetry install --only=main,dev` before + pushing if perf-sensitive code changed. + ## Reporting security issues Suspected security vulnerabilities go through GitHub's [private From 01ef6ffd9ff442b3cfb37d2793e0ca6ad5148832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 13:53:31 -0700 Subject: [PATCH 1346/1433] test: pass timeout=200 to ServiceInfo-request timeout tests (#1677) --- tests/services/test_info.py | 8 ++++---- tests/test_asyncio.py | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 660b56d29..727c5db73 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1050,12 +1050,12 @@ def test_request_timeout(): """Test that the timeout does not throw an exception and finishes close to the actual timeout.""" zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) start_time = r.current_time_millis() - assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.") is None + assert zeroconf.get_service_info("_notfound.local.", "notthere._notfound.local.", timeout=200) is None end_time = r.current_time_millis() zeroconf.close() - # 3000ms for the default timeout + # 200ms for the timeout passed above # 1000ms for loaded systems + schedule overhead - assert (end_time - start_time) < 3000 + 1000 + assert (end_time - start_time) < 200 + 1000 @pytest.mark.asyncio @@ -1888,7 +1888,7 @@ def async_send(out: DNSOutgoing, addr: str | None = None, port: int = const._MDN # patch the zeroconf send with patch.object(aiozc.zeroconf, "async_send", async_send): await aiozc.async_get_service_info( - f"willnotbefound.{type_}", type_, question_type=r.DNSQuestionType.QU + f"willnotbefound.{type_}", type_, timeout=200, question_type=r.DNSQuestionType.QU ) await aiozc.async_close() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index fe24b1486..849af7a10 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1276,12 +1276,15 @@ async def test_async_request_timeout(): aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() start_time = current_time_millis() - assert await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.") is None + assert ( + await aiozc.async_get_service_info("_notfound.local.", "notthere._notfound.local.", timeout=200) + is None + ) end_time = current_time_millis() await aiozc.async_close() - # 3000ms for the default timeout + # 200ms for the timeout passed above # 1000ms for loaded systems + schedule overhead - assert (end_time - start_time) < 3000 + 1000 + assert (end_time - start_time) < 200 + 1000 @pytest.mark.asyncio From 1b31ed5fed9db1608255799701cd6f32b494952f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 13:53:41 -0700 Subject: [PATCH 1347/1433] test: pass timeout=0 explicitly in test_event_loop_blocked (#1676) --- tests/test_core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 8c53d2070..ab351b792 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -753,11 +753,10 @@ def _background_register(): @pytest.mark.asyncio -@patch("zeroconf._core._STARTUP_TIMEOUT", 0) @patch("zeroconf._core.AsyncEngine._async_setup", new_callable=AsyncMock) async def test_event_loop_blocked(mock_start): """Test we raise NotRunningException when waiting for startup that times out.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) with pytest.raises(NotRunningException): - await aiozc.zeroconf.async_wait_for_start() + await aiozc.zeroconf.async_wait_for_start(timeout=0) assert aiozc.zeroconf.started is False From d5e1f01bb336ea19d982ce7d99f191723d3f18af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 15:16:31 -0700 Subject: [PATCH 1348/1433] test: add quick_timing fixture and apply to register-heavy tests (#1678) --- CLAUDE.md | 2 +- tests/conftest.py | 19 +++++++++++++++++++ tests/test_asyncio.py | 14 +++++++------- tests/test_core.py | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c500171eb..2995ec804 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,7 +171,7 @@ accelerated projects. `_ANSWER_STRATEGY_POINTER = 1` in `query_handler.py` as a C int assignment; the Python module dict never gets the binding. `from zeroconf._handlers.query_handler import - _ANSWER_STRATEGY_POINTER` succeeds in pure-Python but raises +_ANSWER_STRATEGY_POINTER` succeeds in pure-Python but raises `ImportError` under Cython. If you need the value visible from Python (e.g. a test wants to assert on it), define both names — a public `ANSWER_STRATEGY_POINTER = 1` Python binding plus a diff --git a/tests/conftest.py b/tests/conftest.py index 531c810be..f723e30c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +from collections.abc import Generator from unittest.mock import patch import pytest @@ -40,3 +41,21 @@ def disable_duplicate_packet_suppression(): """ with patch.object(const, "_DUPLICATE_PACKET_SUPPRESSION_INTERVAL", 0): yield + + +@pytest.fixture +def quick_timing() -> Generator[None]: + """Shorten the probe/announce/goodbye intervals for tests on loopback. + + The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms, + _UNREGISTER_TIME=125ms) exist for RFC 6762 interop on real + networks. Tests on 127.0.0.1 do not need them and pay 1-2s per + register/unregister cycle without this fixture. Opt in by adding + `quick_timing` to a test's argument list. + """ + with ( + patch.object(_core, "_CHECK_TIME", 10), + patch.object(_core, "_REGISTER_TIME", 10), + patch.object(_core, "_UNREGISTER_TIME", 10), + ): + yield diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 849af7a10..e034ddad3 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -260,7 +260,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_service_registration_same_server_different_ports() -> None: +async def test_async_service_registration_same_server_different_ports(quick_timing: None) -> None: """Test registering services with the same server with different srv records.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." @@ -327,7 +327,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_service_registration_same_server_same_ports() -> None: +async def test_async_service_registration_same_server_same_ports(quick_timing: None) -> None: """Test registering services with the same server with the exact same srv record.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." @@ -468,7 +468,7 @@ async def test_async_service_registration_name_does_not_match_type() -> None: @pytest.mark.asyncio -async def test_async_service_registration_name_strict_check() -> None: +async def test_async_service_registration_name_strict_check(quick_timing: None) -> None: """Test registering services throws when the name does not comply.""" zc = Zeroconf(interfaces=["127.0.0.1"]) aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -824,7 +824,7 @@ class MyServiceListener(ServiceListener): @pytest.mark.asyncio -async def test_async_unregister_all_services() -> None: +async def test_async_unregister_all_services(quick_timing: None) -> None: """Test unregistering all services.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." @@ -870,8 +870,8 @@ async def test_async_unregister_all_services() -> None: _clear_cache(aiozc.zeroconf) tasks = [] - tasks.append(aiozc.async_get_service_info(type_, registration_name)) - tasks.append(aiozc.async_get_service_info(type_, registration_name2)) + tasks.append(aiozc.async_get_service_info(type_, registration_name, timeout=200)) + tasks.append(aiozc.async_get_service_info(type_, registration_name2, timeout=200)) results = await asyncio.gather(*tasks) assert results[0] is None assert results[1] is None @@ -883,7 +883,7 @@ async def test_async_unregister_all_services() -> None: @pytest.mark.asyncio -async def test_async_zeroconf_service_types(): +async def test_async_zeroconf_service_types(quick_timing: None) -> None: type_ = "_test-srvc-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" diff --git a/tests/test_core.py b/tests/test_core.py index ab351b792..46e1537b6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -721,7 +721,7 @@ async def test_multiple_sync_instances_stared_from_async_close(): await asyncio.sleep(0) -def test_shutdown_while_register_in_process(): +def test_shutdown_while_register_in_process(quick_timing: None) -> None: """Test we can shutdown while registering a service in another thread.""" # instantiate a zeroconf instance From bc8ec8d59d875522f75901644d423d30d803a030 Mon Sep 17 00:00:00 2001 From: E Shattow Date: Fri, 15 May 2026 15:21:55 -0700 Subject: [PATCH 1349/1433] build: adjust actions checkout ref parameter on release (#1669) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ff9ca13d..84c603dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,10 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} + ref: ${{ github.ref }} + + - name: Create local branch name + run: git switch -C ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release From 8a7dff9ae39f58adfb2a7fe5ab6955864e9b363a Mon Sep 17 00:00:00 2001 From: E Shattow Date: Fri, 15 May 2026 15:39:02 -0700 Subject: [PATCH 1350/1433] ci: ci.yml introduce riscv64 linux and musllinux to wheels build (#1666) --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c603dc2..dccb7a8e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,6 +258,19 @@ jobs: qemu: armv7l musl: "" pyver: cp314t + # qemu is slow, make a single runner per Python version + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp310} + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp311} + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp312} + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp313} + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp314} + - {os: ubuntu-latest, qemu: riscv64, musl: "musllinux", pyver: cp314t} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp310} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp311} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp312} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp313} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp314} + - {os: ubuntu-latest, qemu: riscv64, musl: "", pyver: cp314t} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 From 1f380b1c14b0087409f47f61b876b7bf3f630d41 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Fri, 15 May 2026 23:14:03 -0700 Subject: [PATCH 1351/1433] ci: validate PR title against Conventional Commits (#1681) --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dccb7a8e3..2ec6fe051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,20 @@ jobs: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 + # PRs are squash-merged, so the PR title becomes the commit subject on + # master. Validate it against Conventional Commits so the squashed commit + # stays release-tool-friendly even when individual commits don't. + pr-title: + name: Lint PR Title + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test: strategy: fail-fast: false From 277f80da2c0fea5b256f981bd3f425906f6b7be6 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sat, 16 May 2026 10:12:45 -0700 Subject: [PATCH 1352/1433] test: fix flaky test_run_coro_with_timeout (#1683) --- tests/utils/test_asyncio.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 7989a82cf..c8fd2ad2d 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -123,19 +123,25 @@ async def test_run_coro_with_timeout() -> None: """Test running a coroutine with a timeout raises EventLoopBlocked.""" loop = asyncio.get_event_loop() task: asyncio.Task | None = None + task_created = asyncio.Event() async def _saved_sleep_task(): nonlocal task - task = asyncio.create_task(asyncio.sleep(0.2)) - assert task is not None + task = asyncio.create_task(asyncio.sleep(1)) + task_created.set() await task def _run_in_loop(): - aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 0.1) + aioutils.run_coro_with_timeout(_saved_sleep_task(), loop, 50) with pytest.raises(EventLoopBlocked), patch.object(aioutils, "_LOADED_SYSTEM_TIMEOUT", 0.0): await loop.run_in_executor(None, _run_in_loop) + # The outer .result() can raise EventLoopBlocked before the loop + # has scheduled the coroutine — wait until the inner task is + # created before asserting on it. Use an asyncio.Event so the + # wait yields back to the loop instead of blocking it. + await asyncio.wait_for(task_created.wait(), 1.0) assert task is not None # ensure the thread is shutdown task.cancel() From 2f78370c75d1082afe3191b7447aebfff1206657 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sat, 16 May 2026 12:11:05 -0700 Subject: [PATCH 1353/1433] fix(core): close owned event loop on Zeroconf.close() to stop FD leak (#1685) --- src/zeroconf/_core.py | 11 +++++++++- src/zeroconf/_services/browser.py | 12 ++++++++++- tests/test_core.py | 35 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 71e2c2f4d..18396f981 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -676,13 +676,22 @@ def _close(self) -> None: def _shutdown_threads(self) -> None: """Shutdown any threads.""" + assert self.loop is not None + if self.loop.is_closed(): + # close() is documented as idempotent — a second call after the + # loop has been torn down must be a no-op rather than raising. + return self.notify_all() if not self._loop_thread: return - assert self.loop is not None shutdown_loop(self.loop) self._loop_thread.join() self._loop_thread = None + # The loop's selector (epoll FD on Linux) and self-pipe sockets stay + # open until loop.close() is called. We own this loop because + # _start_thread() created it, so close it here to avoid leaking + # those file descriptors across Zeroconf() construct/close cycles. + self.loop.close() def close(self) -> None: """Ends the background threads, and prevent this instance from diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 897b5dd64..1cb81f2f9 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import contextlib import heapq import queue import random @@ -793,7 +794,16 @@ def cancel(self) -> None: """Cancel the browser.""" assert self.zc.loop is not None self.queue.put(None) - self.zc.loop.call_soon_threadsafe(self._async_cancel) + # While the loop is running, _async_cancel stops the query scheduler + # and cancels the query-sender task — that is the normal cleanup + # path. Skip scheduling solely because the loop is closed: a closed + # loop rejects call_soon_threadsafe with RuntimeError. The + # is_closed() check narrows the common case (loop already closed by + # Zeroconf.close()) without paying for raise/catch; suppress covers + # the residual is_closed() -> call_soon_threadsafe race window. + with contextlib.suppress(RuntimeError): + if not self.zc.loop.is_closed(): + self.zc.loop.call_soon_threadsafe(self._async_cancel) self.join() def run(self) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index 46e1537b6..9356ff1d6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,6 +12,7 @@ import unittest import unittest.mock import warnings +from pathlib import Path from typing import cast from unittest.mock import AsyncMock, Mock, patch @@ -83,6 +84,40 @@ def test_close_multiple_times(self): rv.close() rv.close() + def test_close_releases_owned_event_loop(self): + """Closing a Zeroconf that started its own loop thread closes that loop. + + Regression test for issue #1589 — without loop.close(), the selector + (epoll on Linux) and its self-pipe sockets stay open across each + Zeroconf construct/close cycle and the process eventually exhausts + its FD limit. + """ + rv = r.Zeroconf(interfaces=["127.0.0.1"]) + loop = rv.loop + assert loop is not None + assert loop.is_running() + rv.close() + assert loop.is_closed() + + @unittest.skipUnless(sys.platform.startswith("linux"), "Requires /proc//fd") + @unittest.skipUnless(Path(f"/proc/{os.getpid()}/fd").is_dir(), "/proc//fd not available") + def test_close_does_not_leak_file_descriptors(self): + """Tight loops of Zeroconf()/close() do not leak FDs (issue #1589).""" + fd_dir = Path(f"/proc/{os.getpid()}/fd") + + def _fd_count() -> int: + return sum(1 for _ in fd_dir.iterdir()) + + # Warm-up cycle so any one-shot import-time FDs land before measuring. + r.Zeroconf(interfaces=["127.0.0.1"]).close() + baseline = _fd_count() + for _ in range(10): + r.Zeroconf(interfaces=["127.0.0.1"]).close() + # Allow tiny slack for unrelated FDs the test harness may open + # (e.g. coverage), but reject the per-cycle linear growth pattern + # the bug produced (~3 FDs per cycle, so >=30 over 10 cycles). + assert _fd_count() - baseline < 10 + @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v4_v6(self): From 944d787fedba49a5bfb959994a0dac3b17e03dd3 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 16 May 2026 19:20:18 +0000 Subject: [PATCH 1354/1433] 1.0.1 Automatically generated by python-semantic-release --- pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e284acb6d..dc39cea75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.148.0" +version = "1.0.1" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 418ad50d5..8c1d01e02 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.148.0" +__version__ = "1.0.1" __license__ = "LGPL" From 7d14578f1e43e9f1bf32cf6dd35845654797404d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 12:36:51 -0700 Subject: [PATCH 1355/1433] chore: backport changelog entries fix from release-0.x (#1686) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34c53e1c..0b77aa6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG + + +## v0.148.0 (2025-10-05) + +### Features + +- Trigger semantic releases for 0.x branch + ([#1626](https://github.com/python-zeroconf/python-zeroconf/pull/1626), + [`812a2b3`](https://github.com/python-zeroconf/python-zeroconf/commit/812a2b3ff4370593a7a0c3ad67389c76c434aa9b)) + ## v0.147.3 (2025-10-04) @@ -1984,3 +1994,5 @@ Include documentation and test files in source distributions, in order to make t ## v0.15.1 (2014-07-10) + +- Initial Release From 9138f32d09745924abc2c57191a0f9e31b6aed00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 12:49:51 -0700 Subject: [PATCH 1356/1433] chore: enable ruff SIM108, SIM102, SIM202 in tests (#1687) --- pyproject.toml | 3 --- tests/__init__.py | 5 +---- tests/services/test_browser.py | 15 +++++---------- tests/test_dns.py | 4 ++-- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc39cea75..5945f2a2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,12 +170,9 @@ select = [ "FLY002", # too many to fix right now "PT018", # too many to fix right now "PLR0124", # too many to fix right now - "SIM202" , # too many to fix right now "PT012" , # too many to fix right now "TID252", # too many to fix right now "PLR0913", # skip this one - "SIM102" , # too many to fix right now - "SIM108", # too many to fix right now "T201", # too many to fix right now "PT004", # nice to have ] diff --git a/tests/__init__.py b/tests/__init__.py index a70cca600..4fa398aa0 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -95,10 +95,7 @@ def time_changed_millis(millis: float | None = None) -> None: """Call all scheduled events for a time.""" loop = asyncio.get_running_loop() loop_time = loop.time() - if millis is not None: - mock_seconds_into_future = millis / 1000 - else: - mock_seconds_into_future = loop_time + mock_seconds_into_future = millis / 1000 if millis is not None else loop_time with mock.patch("time.monotonic", return_value=mock_seconds_into_future): for task in list(loop._scheduled): # type: ignore[attr-defined] diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index e9135bb60..d9f032916 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -227,10 +227,7 @@ def mock_record_update_incoming_msg( generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) assert generated.is_response() is True - if service_state_change == r.ServiceStateChange.Removed: - ttl = 0 - else: - ttl = 120 + ttl = 0 if service_state_change == r.ServiceStateChange.Removed else 120 generated.add_answer_at_time( r.DNSText( @@ -1575,9 +1572,8 @@ async def test_close_zeroconf_without_browser_before_start_up_queries(): registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() + if name == registration_name and state_change is ServiceStateChange.Added: + service_added.set() aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf @@ -1644,9 +1640,8 @@ async def test_close_zeroconf_without_browser_after_start_up_queries(): registration_name = f"xxxyyy.{type_}" def on_service_state_change(zeroconf, service_type, state_change, name): - if name == registration_name: - if state_change is ServiceStateChange.Added: - service_added.set() + if name == registration_name and state_change is ServiceStateChange.Added: + service_added.set() aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) zeroconf_browser = aiozc.zeroconf diff --git a/tests/test_dns.py b/tests/test_dns.py index 246c8dcfb..0af88c1a6 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -76,7 +76,7 @@ def test_dns_address_repr(self): def test_dns_question_repr(self): question = r.DNSQuestion("irrelevant", const._TYPE_SRV, const._CLASS_IN | const._CLASS_UNIQUE) repr(question) - assert not question != question + assert (question != question) is False def test_dns_service_repr(self): service = r.DNSService( @@ -112,7 +112,7 @@ def test_service_info_dunder(self): addresses=[socket.inet_aton("10.0.1.2")], ) - assert not info != info + assert (info != info) is False repr(info) def test_service_info_text_properties_not_given(self): From dfd9e9e011dde9f3651cc775bf1555004cc98ab2 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 16 May 2026 19:50:36 +0000 Subject: [PATCH 1357/1433] 0.147.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 47 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b77aa6e8..b388e47eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ +## v0.147.4 (2026-05-16) + +### Bug Fixes + +- **core**: Close owned event loop on Zeroconf.close() to stop FD leak + ([#1685](https://github.com/python-zeroconf/python-zeroconf/pull/1685), + [`2f78370`](https://github.com/python-zeroconf/python-zeroconf/commit/2f78370c75d1082afe3191b7447aebfff1206657)) + +### Build System + +- Adjust actions checkout ref parameter on release + ([#1669](https://github.com/python-zeroconf/python-zeroconf/pull/1669), + [`bc8ec8d`](https://github.com/python-zeroconf/python-zeroconf/commit/bc8ec8d59d875522f75901644d423d30d803a030)) + +### Documentation + +- Add CLAUDE.md orientation file and pr-workflow skill + ([#1672](https://github.com/python-zeroconf/python-zeroconf/pull/1672), + [`8f8b4d6`](https://github.com/python-zeroconf/python-zeroconf/commit/8f8b4d6526729906337f4562c7b391745bb878af)) + +- Add Cython gotchas section to CLAUDE.md + ([#1679](https://github.com/python-zeroconf/python-zeroconf/pull/1679), + [`5cfb09d`](https://github.com/python-zeroconf/python-zeroconf/commit/5cfb09d89395cb507d436c45fc38edb9e44b94c8)) + +- Add SECURITY.md with private vulnerability reporting policy + ([#1675](https://github.com/python-zeroconf/python-zeroconf/pull/1675), + [`13f9048`](https://github.com/python-zeroconf/python-zeroconf/commit/13f9048f0f9786ce18e89daef04073847735a006)) + +### Testing + +- Add quick_timing fixture and apply to register-heavy tests + ([#1678](https://github.com/python-zeroconf/python-zeroconf/pull/1678), + [`d5e1f01`](https://github.com/python-zeroconf/python-zeroconf/commit/d5e1f01bb336ea19d982ce7d99f191723d3f18af)) + +- Fix flaky test_run_coro_with_timeout + ([#1683](https://github.com/python-zeroconf/python-zeroconf/pull/1683), + [`277f80d`](https://github.com/python-zeroconf/python-zeroconf/commit/277f80da2c0fea5b256f981bd3f425906f6b7be6)) + +- Pass timeout=0 explicitly in test_event_loop_blocked + ([#1676](https://github.com/python-zeroconf/python-zeroconf/pull/1676), + [`1b31ed5`](https://github.com/python-zeroconf/python-zeroconf/commit/1b31ed5fed9db1608255799701cd6f32b494952f)) + +- Pass timeout=200 to ServiceInfo-request timeout tests + ([#1677](https://github.com/python-zeroconf/python-zeroconf/pull/1677), + [`01ef6ff`](https://github.com/python-zeroconf/python-zeroconf/commit/01ef6ffd9ff442b3cfb37d2793e0ca6ad5148832)) + + ## v0.148.0 (2025-10-05) ### Features diff --git a/pyproject.toml b/pyproject.toml index 5945f2a2c..681b27126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "1.0.1" +version = "0.147.4" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 8c1d01e02..99c9d9edc 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "1.0.1" +__version__ = "0.147.4" __license__ = "LGPL" From c96a997b962333ab4cbb4416e668b4aec4e71a5b Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sat, 16 May 2026 12:59:22 -0700 Subject: [PATCH 1358/1433] feat(core): add use_asyncio kwarg to Zeroconf (#1684) Co-authored-by: J. Nick Koston --- src/zeroconf/_core.py | 15 ++++++++++++++- tests/test_core.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 18396f981..1814e86c1 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -154,6 +154,7 @@ def __init__( unicast: bool = False, ip_version: IPVersion | None = None, apple_p2p: bool = False, + use_asyncio: bool | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -169,6 +170,14 @@ def __init__( :param ip_version: IP versions to support. If `choice` is a list, the default is detected from it. Otherwise defaults to V4 only for backward compatibility. :param apple_p2p: use AWDL interface (only macOS) + :param use_asyncio: explicitly control whether to attach to the running + asyncio event loop (``True``) or run an internal thread with its + own loop (``False``). ``None`` (default) keeps the historic + behavior: attach if an event loop is running, otherwise start a + thread. Set to ``False`` when running inside an environment that + already has an event loop (e.g. Jupyter) but you want blocking + semantics. ``True`` raises :class:`RuntimeError` immediately if no + running event loop is found, instead of falling back to the thread. """ if ip_version is None: ip_version = autodetect_ip_version(interfaces) @@ -178,7 +187,11 @@ def __init__( if apple_p2p and sys.platform != "darwin": raise RuntimeError("Option `apple_p2p` is not supported on non-Apple platforms.") + if use_asyncio is True and get_running_loop() is None: + raise RuntimeError("use_asyncio=True requires a running asyncio event loop") + self.unicast = unicast + self._use_asyncio = use_asyncio listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets) @@ -216,7 +229,7 @@ def started(self) -> bool: def start(self) -> None: """Start Zeroconf.""" - self.loop = get_running_loop() + self.loop = None if self._use_asyncio is False else get_running_loop() if self.loop: self.engine.setup(self.loop, None) return diff --git a/tests/test_core.py b/tests/test_core.py index 9356ff1d6..134dbb88a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -154,6 +154,36 @@ def test_launch_and_close_apple_p2p_on_mac(self): rv = r.Zeroconf(apple_p2p=True) rv.close() + def test_use_asyncio_false_forces_thread_when_loop_running(self): + """use_asyncio=False starts a thread even with a running event loop.""" + + async def run() -> r.Zeroconf: + return r.Zeroconf(interfaces=["127.0.0.1"], use_asyncio=False) + + loop = asyncio.new_event_loop() + zc: r.Zeroconf | None = None + try: + zc = loop.run_until_complete(run()) + assert zc._loop_thread is not None + assert zc.loop is not loop + finally: + if zc is not None: + zc.close() + loop.close() + + def test_use_asyncio_true_requires_running_loop(self): + """use_asyncio=True without a running loop raises RuntimeError.""" + with pytest.raises(RuntimeError, match="requires a running asyncio event loop"): + r.Zeroconf(interfaces=["127.0.0.1"], use_asyncio=True) + + def test_use_asyncio_default_starts_thread_without_loop(self): + """use_asyncio=None (default) keeps the historic auto-detect behavior.""" + zc = r.Zeroconf(interfaces=["127.0.0.1"]) + try: + assert zc._loop_thread is not None + finally: + zc.close() + def test_async_updates_from_response(self): def mock_incoming_msg( service_state_change: r.ServiceStateChange, From 1ea6b940ecbfd7e7654ad022cf7f4f888cf1daa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 14:16:32 -0700 Subject: [PATCH 1359/1433] perf(build): parallelize cython extension compilation (#1689) --- build_ext.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build_ext.py b/build_ext.py index 412bff3c5..f77d3e2e6 100644 --- a/build_ext.py +++ b/build_ext.py @@ -46,6 +46,8 @@ class BuildExt(build_ext): def build_extensions(self) -> None: + if self.parallel is None: # type: ignore[has-type] + self.parallel = os.cpu_count() or 1 try: super().build_extensions() except Exception: From 327b93dc602707e25d53788f0fb14e142f4558b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 14:49:56 -0700 Subject: [PATCH 1360/1433] feat: drop Python 3.9 support (#1688) --- .github/workflows/ci.yml | 6 ---- .pre-commit-config.yaml | 2 +- CLAUDE.md | 10 +++--- README.rst | 4 +-- poetry.lock | 54 ++---------------------------- pyproject.toml | 6 ++-- src/zeroconf/_cache.py | 4 +-- src/zeroconf/_services/__init__.py | 3 +- src/zeroconf/_services/browser.py | 3 +- src/zeroconf/_utils/net.py | 4 +-- src/zeroconf/asyncio.py | 3 +- 11 files changed, 22 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ec6fe051..3e3f488d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,14 +51,12 @@ jobs: fail-fast: false matrix: python-version: - - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" - "3.14t" - - "pypy-3.9" - "pypy-3.10" os: - ubuntu-latest @@ -72,12 +70,8 @@ jobs: extension: use_cython - os: windows-latest extension: use_cython - - os: windows-latest - python-version: "pypy-3.9" - os: windows-latest python-version: "pypy-3.10" - - os: macos-latest - python-version: "pypy-3.9" - os: macos-latest python-version: "pypy-3.10" runs-on: ${{ matrix.os }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 706f4d2e6..bdfd9fd34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.12 hooks: diff --git a/CLAUDE.md b/CLAUDE.md index 2995ec804..696740b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,13 +70,13 @@ path. The authoritative list of cythonized modules lives in and `zeroconf/asyncio.py` re-export. - **Line length**: 110 (ruff `line-length = 110`). - `requires-python = ">=3.9"`, `target-version = "py39"` for - ruff; pyupgrade runs `--py39-plus`. + `requires-python = ">=3.10"`, `target-version = "py310"` for + ruff; pyupgrade runs `--py310-plus`. - **Imports**: ruff/isort sorted, `profile = "black"`, `known_first_party = ["zeroconf", "tests"]`. Prefer `from __future__ import annotations` so modern type syntax - works on 3.9. + works on 3.10. - **Generated `.c` files are not lint-targets.** `*.c` files next to each cythonized module are Cython output — never hand- @@ -124,8 +124,8 @@ CodSpeed benchmarks live under `tests/benchmarks/` and run in CI through `CodSpeedHQ/action`. Ad-hoc microbenchmarks for manual profiling live under `bench/` — those don't run in CI. -The CI matrix includes CPython 3.9 – 3.14, the free-threaded -3.14t build, and PyPy 3.9 / 3.10. Don't add anything that breaks +The CI matrix includes CPython 3.10 – 3.14, the free-threaded +3.14t build, and PyPy 3.10. Don't add anything that breaks on the free-threaded build (no module-level mutable globals mutated from multiple threads without locks; no `PyDict_Next`-style escape hatches in Cython). diff --git a/README.rst b/README.rst index c27833f80..70da57ad8 100644 --- a/README.rst +++ b/README.rst @@ -53,8 +53,8 @@ Compared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf: Python compatibility -------------------- -* CPython 3.9+ -* PyPy 3.9+ +* CPython 3.10+ +* PyPy 3.10+ Versioning ---------- diff --git a/poetry.lock b/poetry.lock index af613e1d0..1654a7db3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -467,31 +467,6 @@ files = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] -[[package]] -name = "importlib-metadata" -version = "8.6.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, - {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -765,7 +740,6 @@ files = [ [package.dependencies] cffi = ">=1.17.1" -importlib-metadata = {version = ">=8.5.0", markers = "python_version < \"3.10\""} pytest = ">=3.8" rich = ">=13.8.1" @@ -900,7 +874,6 @@ babel = ">=2.13" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} docutils = ">=0.20,<0.22" imagesize = ">=1.3" -importlib-metadata = {version = ">=6.0", markers = "python_version < \"3.10\""} Jinja2 = ">=3.1" packaging = ">=23.0" Pygments = ">=2.17" @@ -1128,28 +1101,7 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] -[[package]] -name = "zipp" -version = "3.21.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["dev", "docs"] -markers = "python_version == \"3.9\"" -files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" -python-versions = "^3.9" -content-hash = "55ac531bfbed3a124eef09d77dd7db2d6ec82d9d3eb04c1ba8a4e92685a71a66" +python-versions = "^3.10" +content-hash = "f85e2fbeab7883ab30a9affc633e0a8fcde6625fd7746282285efe4d4270da47" diff --git a/pyproject.toml b/pyproject.toml index 681b27126..0813e7c77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ { name = "Jakub Stasiak" }, { name = "J. Nick Koston" }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" [project.urls] "Repository" = "https://github.com/python-zeroconf/python-zeroconf" @@ -80,7 +80,7 @@ match = "(?!(master|release-0.x)$)" prerelease = true [tool.poetry.dependencies] -python = "^3.9" +python = "^3.10" ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] @@ -97,7 +97,7 @@ sphinx = "^7.4.7 || ^8.1.3" sphinx-rtd-theme = "^3.1.0" [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 110 [tool.ruff.lint] diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index c7ca8472b..94af31698 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -24,7 +24,7 @@ from collections.abc import Iterable from heapq import heapify, heappop, heappush -from typing import Union, cast +from typing import cast from ._dns import ( DNSAddress, @@ -40,7 +40,7 @@ from .const import _ONE_SECOND, _TYPE_PTR _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) -_UniqueRecordsType = Union[DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService] +_UniqueRecordsType = DNSAddress | DNSHinfo | DNSPointer | DNSText | DNSService _DNSRecordCacheType = dict[str, dict[DNSRecord, DNSRecord]] _DNSRecord = DNSRecord _str = str diff --git a/src/zeroconf/_services/__init__.py b/src/zeroconf/_services/__init__.py index b244552f1..cc794dc68 100644 --- a/src/zeroconf/_services/__init__.py +++ b/src/zeroconf/_services/__init__.py @@ -23,7 +23,8 @@ from __future__ import annotations import enum -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .._core import Zeroconf diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 1cb81f2f9..3c3766a17 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -30,13 +30,12 @@ import threading import time import warnings -from collections.abc import Iterable +from collections.abc import Callable, Iterable from functools import partial from types import TracebackType # used in type hints from typing import ( TYPE_CHECKING, Any, - Callable, cast, ) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index e67edf787..01c5b040d 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -30,7 +30,7 @@ import sys import warnings from collections.abc import Iterable, Sequence -from typing import Any, Union, cast +from typing import Any, cast import ifaddr @@ -44,7 +44,7 @@ class InterfaceChoice(enum.Enum): All = 2 -InterfacesType = Union[Sequence[Union[str, int, tuple[tuple[str, int, int], int]]], InterfaceChoice] +InterfacesType = Sequence[str | int | tuple[tuple[str, int, int], int]] | InterfaceChoice @enum.unique diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index a0f4a99db..45aac67ad 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -24,9 +24,8 @@ import asyncio import contextlib -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from types import TracebackType # used in type hints -from typing import Callable from ._core import Zeroconf from ._dns import DNSQuestionType From 1031559d2dba4c62bada2b6232ddf61eb0cc040d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 15:25:13 -0700 Subject: [PATCH 1361/1433] chore: trigger release after 0.148.0 tag rebase (#1690) From 023dcde947881b3f8512d02c95d4189e2e876be8 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 16 May 2026 22:32:43 +0000 Subject: [PATCH 1362/1433] 0.149.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b388e47eb..69db57499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ +## v0.149.0 (2026-05-16) + +### Features + +- Drop Python 3.9 support ([#1688](https://github.com/python-zeroconf/python-zeroconf/pull/1688), + [`327b93d`](https://github.com/python-zeroconf/python-zeroconf/commit/327b93dc602707e25d53788f0fb14e142f4558b3)) + +### Performance Improvements + +- **build**: Parallelize cython extension compilation + ([#1689](https://github.com/python-zeroconf/python-zeroconf/pull/1689), + [`1ea6b94`](https://github.com/python-zeroconf/python-zeroconf/commit/1ea6b940ecbfd7e7654ad022cf7f4f888cf1daa5)) + + ## v0.147.4 (2026-05-16) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0813e7c77..bb516295f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.147.4" +version = "0.149.0" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 99c9d9edc..c294d1307 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.147.4" +__version__ = "0.149.0" __license__ = "LGPL" From 591288ba77a872ce6ccfe040f9c73da89e180f8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 15:57:59 -0700 Subject: [PATCH 1363/1433] fix(ci): drop cp39 from cibuildwheel matrix (#1691) --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e3f488d4..d70fba9a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,10 +208,6 @@ jobs: musl: "musllinux" # qemu is slow, make a single # runner per Python version - - os: ubuntu-latest - qemu: armv7l - musl: "musllinux" - pyver: cp39 - os: ubuntu-latest qemu: armv7l musl: "musllinux" @@ -238,10 +234,6 @@ jobs: pyver: cp314t # qemu is slow, make a single # runner per Python version - - os: ubuntu-latest - qemu: armv7l - musl: "" - pyver: cp39 - os: ubuntu-latest qemu: armv7l musl: "" From 9b24b5318f8b44e970cb32c90ca5c5dcfc2bc2ca Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 16 May 2026 23:06:03 +0000 Subject: [PATCH 1364/1433] 0.149.1 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69db57499..9249b2a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.1 (2026-05-16) + +### Bug Fixes + +- **ci**: Drop cp39 from cibuildwheel matrix + ([#1691](https://github.com/python-zeroconf/python-zeroconf/pull/1691), + [`591288b`](https://github.com/python-zeroconf/python-zeroconf/commit/591288ba77a872ce6ccfe040f9c73da89e180f8d)) + + ## v0.149.0 (2026-05-16) ### Features diff --git a/pyproject.toml b/pyproject.toml index bb516295f..60ff9f55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.0" +version = "0.149.1" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c294d1307..5e1b7f0c1 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.0" +__version__ = "0.149.1" __license__ = "LGPL" From 745198b1128213915c1c89829feb950046e3de91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 17:12:25 -0700 Subject: [PATCH 1365/1433] fix(ci): drop retired macos-13 runner and skip cp39/pp39 in wheel matrix (#1693) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d70fba9a7..08fd4953b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,7 +195,6 @@ jobs: ubuntu-24.04-arm, ubuntu-latest, windows-latest, - macos-13, macos-latest, ] qemu: [""] @@ -316,8 +315,9 @@ jobs: uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* pp36-* pp37-* pp38-* cp38-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} + CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* pp36-* pp37-* pp38-* pp39-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc + CIBW_ARCHS_MACOS: "x86_64 arm64" REQUIRE_CYTHON: 1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 From 7f1ea4b8072d8b0fe07342a2986bc177c141d1fc Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 17 May 2026 00:20:56 +0000 Subject: [PATCH 1366/1433] 0.149.2 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9249b2a18..e6581ae55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.2 (2026-05-17) + +### Bug Fixes + +- **ci**: Drop retired macos-13 runner and skip cp39/pp39 in wheel matrix + ([#1693](https://github.com/python-zeroconf/python-zeroconf/pull/1693), + [`745198b`](https://github.com/python-zeroconf/python-zeroconf/commit/745198b1128213915c1c89829feb950046e3de91)) + + ## v0.149.1 (2026-05-16) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 60ff9f55c..9199b07a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.1" +version = "0.149.2" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 5e1b7f0c1..54dc4ca23 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.1" +__version__ = "0.149.2" __license__ = "LGPL" From 104c5d6674896612aa83a89fd17b90de5f38a508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 17:31:32 -0700 Subject: [PATCH 1367/1433] fix(ci): drop x86_64 mac wheels and clean up obsolete CIBW_SKIP entries (#1694) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fd4953b..d210804b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -315,9 +315,9 @@ jobs: uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 # to supply options, put them in 'env', like: env: - CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* pp36-* pp37-* pp38-* pp39-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} + CIBW_SKIP: cp38-* cp39-* pp38-* pp39-* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} CIBW_BEFORE_ALL_LINUX: apt install -y gcc || yum install -y gcc || apk add gcc - CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_ARCHS_MACOS: arm64 REQUIRE_CYTHON: 1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 From 69883daf4c1d685034e6f6ba3661bb575b6d885e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 17 May 2026 00:41:37 +0000 Subject: [PATCH 1368/1433] 0.149.3 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6581ae55..a05ee44fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.3 (2026-05-17) + +### Bug Fixes + +- **ci**: Drop x86_64 mac wheels and clean up obsolete CIBW_SKIP entries + ([#1694](https://github.com/python-zeroconf/python-zeroconf/pull/1694), + [`104c5d6`](https://github.com/python-zeroconf/python-zeroconf/commit/104c5d6674896612aa83a89fd17b90de5f38a508)) + + ## v0.149.2 (2026-05-17) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 9199b07a7..adb48def1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.2" +version = "0.149.3" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 54dc4ca23..3bd03d409 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.2" +__version__ = "0.149.3" __license__ = "LGPL" From dfa4e005fdea1489d2d0d2e5440a409dce1ad49c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 May 2026 22:34:17 -0700 Subject: [PATCH 1369/1433] ci: replace deprecated upload-to-gh-release with publish-action (#1695) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d210804b5..2b75c2feb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases - uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main + uses: python-semantic-release/publish-action@310a9983a0ae878b29f3aac778d7c77c1db27378 # v10.5.3 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} From dd341a378e904cbf9b9a09e5c2ac8ff7c9944cd0 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 17 May 2026 08:02:50 -0700 Subject: [PATCH 1370/1433] test: speed up slow loopback tests (closes #1697) (#1699) --- tests/services/test_browser.py | 4 +++- tests/services/test_types.py | 16 ++++++++-------- tests/test_asyncio.py | 18 +++++++++--------- tests/test_handlers.py | 2 ++ tests/test_init.py | 3 +++ tests/test_services.py | 3 ++- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index d9f032916..8091544ea 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -331,7 +331,9 @@ def mock_record_update_incoming_msg( service_updated_event.clear() service_text = b"path=/~matt2/" _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated)) - service_updated_event.wait(wait_time) + # Negative assertion: a duplicate update must NOT fire the listener. The wait + # always times out, so keep the budget short rather than reusing wait_time. + service_updated_event.wait(0.3) assert service_added_count == 1 assert service_updated_count == 2 assert service_removed_count == 0 diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 10056c1e5..121d97972 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -47,10 +47,10 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types finally: @@ -79,10 +79,10 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types finally: @@ -115,10 +115,10 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=2) + service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert type_ in service_types finally: @@ -147,10 +147,10 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=2) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) assert discovery_type in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=2) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) assert discovery_type in service_types finally: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e034ddad3..6a8c64734 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -126,7 +126,7 @@ def sync_code(): @pytest.mark.asyncio -async def test_async_service_registration() -> None: +async def test_async_service_registration(quick_timing: None) -> None: """Test registering services broadcasts the registration by default.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test1-srvc-type._tcp.local." @@ -193,7 +193,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_service_registration_with_server_missing() -> None: +async def test_async_service_registration_with_server_missing(quick_timing: None) -> None: """Test registering a service with the server not specified. For backwards compatibility, the server should be set to the @@ -394,7 +394,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_service_registration_name_conflict() -> None: +async def test_async_service_registration_name_conflict(quick_timing: None) -> None: """Test registering services throws on name conflict.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test-srvc2-type._tcp.local." @@ -503,7 +503,7 @@ async def test_async_service_registration_name_strict_check(quick_timing: None) @pytest.mark.asyncio -async def test_async_tasks() -> None: +async def test_async_tasks(quick_timing: None) -> None: """Test awaiting broadcast tasks""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -605,7 +605,7 @@ async def test_async_wait_unblocks_on_update() -> None: @pytest.mark.asyncio -async def test_service_info_async_request() -> None: +async def test_service_info_async_request(quick_timing: None) -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" if not has_working_ipv6() or os.environ.get("SKIP_IPV6"): pytest.skip("Requires IPv6") @@ -700,7 +700,7 @@ async def test_service_info_async_request() -> None: # Generating the race condition is almost impossible # without patching since its a TOCTOU race with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 3000) + await aiosinfo.async_request(aiozc.zeroconf, 500) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] @@ -714,7 +714,7 @@ async def test_service_info_async_request() -> None: @pytest.mark.asyncio -async def test_async_service_browser() -> None: +async def test_async_service_browser(quick_timing: None) -> None: """Test AsyncServiceBrowser.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) type_ = "_test9-srvc-type._tcp.local." @@ -906,10 +906,10 @@ async def test_async_zeroconf_service_types(quick_timing: None) -> None: await asyncio.sleep(0.2) _clear_cache(zeroconf_registrar.zeroconf) try: - service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=["127.0.0.1"], timeout=2) + service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=["127.0.0.1"], timeout=0.5) assert type_ in service_types _clear_cache(zeroconf_registrar.zeroconf) - service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=2) + service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=0.5) assert type_ in service_types finally: diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 31354980a..aeed00bcd 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -160,6 +160,7 @@ def _process_outgoing_packet(out): nbr_answers = nbr_additionals = nbr_authorities = 0 zc.close() + @pytest.mark.usefixtures("quick_timing") def test_name_conflicts(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -189,6 +190,7 @@ def test_name_conflicts(self): zc.register_service(conflicting_info) zc.close() + @pytest.mark.usefixtures("quick_timing") def test_register_and_lookup_type_by_uppercase_name(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_init.py b/tests/test_init.py index 5ccb9ef63..37534ad74 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -8,6 +8,8 @@ import unittest.mock from unittest.mock import patch +import pytest + import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, const @@ -68,6 +70,7 @@ def test_same_name(self): generated.add_question(question) r.DNSIncoming(generated.packets()[0]) + @pytest.mark.usefixtures("quick_timing") def test_verify_name_change_with_lots_of_names(self): # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_services.py b/tests/test_services.py index 7d7c3fc7d..bc4770db9 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -34,6 +34,7 @@ def teardown_module(): class ListenerTest(unittest.TestCase): + @pytest.mark.usefixtures("quick_timing") def test_integration_with_listener_class(self): sub_service_added = Event() service_added = Event() @@ -113,7 +114,7 @@ def update_service(self, zeroconf, type, name): assert service_added.is_set() # short pause to allow multicast timers to expire - time.sleep(3) + time.sleep(0.5) zeroconf_browser.add_service_listener(type_, DuplicateListener()) duplicate_service_added.wait( From d2058d95882c70fb2eb786d373a630314e656c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 10:13:35 -0700 Subject: [PATCH 1371/1433] test: drop pending multicast responses before TOCTOU assertion (#1701) --- tests/test_asyncio.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 6a8c64734..0de0ad582 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -695,6 +695,16 @@ async def test_service_info_async_request(quick_timing: None) -> None: assert aiosinfos[1] is not None assert aiosinfos[1].addresses == [socket.inet_aton("10.0.1.5")] + # Drop pending multicast responses queued under the original + # `info` / `info2` registrations. Their snapshots predate the + # `async_update_service` swap, so if they flushed after + # `_clear_cache` below they would poison `aiosinfo.server` + # with `ash-1.local.` and the `_is_complete=False` loop would + # then keep asking for an A record nobody answers. The pending + # `loop.call_at` for each queue fires harmlessly on the empty + # deque. + aiozc.zeroconf.out_queue.queue.clear() + aiozc.zeroconf.out_delay_queue.queue.clear() aiosinfo = AsyncServiceInfo(type_, registration_name) _clear_cache(aiozc.zeroconf) # Generating the race condition is almost impossible From 9b4db625e121c2d590ed8fe2f4d187d5e3bb9a73 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 17 May 2026 10:36:58 -0700 Subject: [PATCH 1372/1433] test: widen scheduling buffer in flaky get_info suppression test (#1698) Co-authored-by: J. Nick Koston --- .gitignore | 3 ++ tests/services/test_info.py | 80 ++++++++++++++----------------------- tests/test_asyncio.py | 2 +- tests/test_core.py | 6 ++- 4 files changed, 39 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index 430fbec9c..4dde1f97b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ build/ *.pyc *.pyo +.coverage +coverage.xml +htmlcov/ Thumbs.db .DS_Store .project diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 727c5db73..88f9958ae 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -436,12 +436,29 @@ def get_service_info_helper(zc, type, name): service_info_event.set() try: + # Seed TXT/A/AAAA with a far-future `than` before the + # helper thread starts. The first (QU) query bypasses + # suppression so phase 1 still observes 4 questions; the + # second (QM) query fires ~220-320ms after the first, too + # tight a window to seed reliably from the test thread on + # slow runners. async_expire only removes entries where + # now - than > _DUPLICATE_QUESTION_INTERVAL, so future- + # dated entries persist for the duration of the test. + seed_history_questions = ( + r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), + r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), + r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), + ) + far_future = r.current_time_millis() + 60_000 + for question in seed_history_questions: + zc.question_history.add_question_at_time(question, far_future, set()) + helper_thread = threading.Thread( target=get_service_info_helper, args=(zc, service_type, service_name), ) helper_thread.start() - wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5) / 1000 + wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 500) / 1000 # Expect query for SRV, TXT, A, AAAA send_event.wait(wait_time) @@ -457,64 +474,29 @@ def get_service_info_helper(zc, type, name): # by the question history last_sent = None send_event.clear() - for _ in range(3): - send_event.wait( - wait_time * 0.25 - ) # Wait long enough to be inside the question history window - now = r.current_time_millis() - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), - now, - set(), - ) - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), - now, - set(), - ) - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), - now, - set(), - ) - send_event.wait(wait_time * 0.25) + send_event.wait(wait_time) assert last_sent is not None assert len(last_sent.questions) == 1 # type: ignore[unreachable] assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions assert service_info is None + # Future-date SRV too: the SRV entry added by the previous + # QM query has `than = now`, so it expires after + # _DUPLICATE_QUESTION_INTERVAL — before the next scheduled + # QM query (~1s + jitter later). + zc.question_history.add_question_at_time( + r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN), + r.current_time_millis() + 60_000, + set(), + ) + wait_time = ( - const._DUPLICATE_QUESTION_INTERVAL + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 5 + const._DUPLICATE_QUESTION_INTERVAL + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 500 ) / 1000 # Expect no queries as all are suppressed by the question history last_sent = None send_event.clear() - for _ in range(3): - send_event.wait( - wait_time * 0.25 - ) # Wait long enough to be inside the question history window - now = r.current_time_millis() - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN), - now, - set(), - ) - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN), - now, - set(), - ) - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN), - now, - set(), - ) - zc.question_history.add_question_at_time( - r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN), - now, - set(), - ) - send_event.wait(wait_time * 0.25) + send_event.wait(wait_time) # All questions are suppressed so no query should be sent assert last_sent is None assert service_info is None diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 0de0ad582..1981911d7 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -710,7 +710,7 @@ async def test_service_info_async_request(quick_timing: None) -> None: # Generating the race condition is almost impossible # without patching since its a TOCTOU race with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 500) + await aiosinfo.async_request(aiozc.zeroconf, 3000) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] diff --git a/tests/test_core.py b/tests/test_core.py index 134dbb88a..d9bac566a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -496,6 +496,10 @@ def test_get_service_info_failure_path(): zc.close() +@pytest.mark.skipif( + sys.platform == "win32", + reason="multicast loopback on the 127.0.0.1-only socket is unreliable on GH Actions Windows runners", +) def test_sending_unicast(): """Test sending unicast response.""" zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -517,8 +521,6 @@ def test_sending_unicast(): assert zc.cache.get(entry) is None zc.send(generated) - - # Handle slow github CI runners on windows for _ in range(10): time.sleep(0.05) if zc.cache.get(entry) is not None: From 653c38559c468672cf907d808d432dec0fb06968 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 17 May 2026 11:54:34 -0700 Subject: [PATCH 1373/1433] test: speed up test_async_wait_unblocks_on_update (#1702) --- tests/test_asyncio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 1981911d7..5e57acd80 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -569,7 +569,7 @@ def update_service(self, zeroconf: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_wait_unblocks_on_update() -> None: +async def test_async_wait_unblocks_on_update(quick_timing: None) -> None: """Test async_wait will unblock on update.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) From d03ea364dea5866e0301ff11f6b78714ecf991e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 12:48:28 -0700 Subject: [PATCH 1374/1433] test: speed up slow loopback tests (closes #1700) (#1703) --- tests/__init__.py | 17 +++++++++++++++ tests/services/test_browser.py | 38 ++++++++++++++++++++++++---------- tests/test_asyncio.py | 21 ++++++++++++------- tests/test_core.py | 12 +++++------ tests/test_engine.py | 18 +++++++++++++--- tests/test_handlers.py | 19 +++++++++++------ tests/test_services.py | 7 ++++--- tests/test_updates.py | 2 +- 8 files changed, 97 insertions(+), 37 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 4fa398aa0..6a7bbb98b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -91,6 +91,23 @@ def _clear_cache(zc: Zeroconf) -> None: zc.question_history.clear() +def _backdate_cache(zc: Zeroconf, ms: int = 1100) -> None: + """Backdate every cached record's `created` time by `ms` milliseconds. + + rfc6762#section-10.2 keys off "received more than one second ago", so + backdating is equivalent to sleeping `ms` in real time without the + wall-clock wait. + + Iterate `store.values()`, not the dict directly — when a record is + re-added with an equal hash, the key stays the original object while + the value is replaced with the latest; mutating the key would update + stale objects no one reads. + """ + for store in zc.cache.cache.values(): + for record in store.values(): + record.created -= ms + + def time_changed_millis(millis: float | None = None) -> None: """Call all scheduled events for a time.""" loop = asyncio.get_running_loop() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 8091544ea..c976e456b 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -556,7 +556,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): @pytest.mark.asyncio -async def test_asking_default_is_asking_qm_questions_after_the_first_qu(): +async def test_asking_default_is_asking_qm_questions_after_the_first_qu(quick_timing: None) -> None: """Verify the service browser's first questions are QU and refresh queries are QM.""" service_added = asyncio.Event() service_removed = asyncio.Event() @@ -658,7 +658,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): @pytest.mark.asyncio -async def test_ttl_refresh_cancelled_rescue_query(): +async def test_ttl_refresh_cancelled_rescue_query(quick_timing: None) -> None: """Verify seeing a name again cancels the rescue query.""" service_added = asyncio.Event() service_removed = asyncio.Event() @@ -846,7 +846,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name): await aiozc.async_close() -def test_legacy_record_update_listener(): +def test_legacy_record_update_listener(quick_timing: None) -> None: """Test a RecordUpdateListener that does not implement update_records.""" # instantiate a zeroconf instance @@ -1499,10 +1499,15 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de # Force the ttl to be 1 second now = current_time_millis() for cache_record in list(zc.cache.cache.values()): - for record in cache_record: + for record in cache_record.values(): zc.cache._async_set_created_ttl(record, now, 1) - time.sleep(0.3) + # Wait for the add callback to fire from the original inject_response. + for _ in range(30): + time.sleep(0.01) + if len(callbacks) == 1: + break + info.port = 400 info._dns_service_cache = None # we are mutating the record so clear the cache @@ -1511,8 +1516,8 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de mock_incoming_msg([info.dns_service()]), ) - for _ in range(10): - time.sleep(0.05) + for _ in range(30): + time.sleep(0.01) if len(callbacks) == 2: break @@ -1521,8 +1526,19 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de ("update", type_, registration_name), ] - for _ in range(25): - time.sleep(0.05) + # Re-add every cached record with `created` in the past so the + # next reaper tick (0.01s) expires them and fires the remove + # callback, instead of waiting the full TTL in real time. + # Going through `_async_set_created_ttl` updates the expiration + # heap; mutating `record.created` directly would leave the heap + # entry pointing at the original `when` so the reaper never wakes. + past = current_time_millis() - 2000 + for cache_record in list(zc.cache.cache.values()): + for record in list(cache_record.values()): + zc.cache._async_set_created_ttl(record, past, 1) + + for _ in range(30): + time.sleep(0.01) if len(callbacks) == 3: break @@ -1567,7 +1583,7 @@ def test_scheduled_ptr_query_dunder_methods(): @pytest.mark.asyncio -async def test_close_zeroconf_without_browser_before_start_up_queries(): +async def test_close_zeroconf_without_browser_before_start_up_queries(quick_timing: None) -> None: """Test that we stop sending startup queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() type_ = "_http._tcp.local." @@ -1634,7 +1650,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): @pytest.mark.asyncio -async def test_close_zeroconf_without_browser_after_start_up_queries(): +async def test_close_zeroconf_without_browser_after_start_up_queries(quick_timing: None) -> None: """Test that we stop sending rescue queries if zeroconf is closed out from under the browser.""" service_added = asyncio.Event() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 5e57acd80..5d9fb8653 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -708,16 +708,21 @@ async def test_service_info_async_request(quick_timing: None) -> None: aiosinfo = AsyncServiceInfo(type_, registration_name) _clear_cache(aiozc.zeroconf) # Generating the race condition is almost impossible - # without patching since its a TOCTOU race + # without patching since its a TOCTOU race. 1500ms covers + # the initial _LISTENER_TIME + random delay (200-320ms) and + # leaves plenty of margin for the loopback response to land + # before the loop times out. with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 3000) + await aiosinfo.async_request(aiozc.zeroconf, 1500) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] task = await aiozc.async_unregister_service(new_info) await task - aiosinfo = await aiozc.async_get_service_info(type_, registration_name) + # Cap timeout: the service is gone, so this is expected to return None; + # waiting the default 3000ms is pure overhead. + aiosinfo = await aiozc.async_get_service_info(type_, registration_name, timeout=200) assert aiosinfo is None await aiozc.async_close() @@ -784,7 +789,7 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None: @pytest.mark.asyncio -async def test_async_context_manager() -> None: +async def test_async_context_manager(quick_timing: None) -> None: """Test using an async context manager.""" type_ = "_test10-sr-type._tcp.local." name = "xxxyyy" @@ -984,7 +989,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de @pytest.mark.asyncio -async def test_integration(): +async def test_integration(quick_timing: None) -> None: service_added = asyncio.Event() service_removed = asyncio.Event() unexpected_ttl = asyncio.Event() @@ -1176,9 +1181,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf_info, "async_send", send): aiosinfo = AsyncServiceInfo(type_, registration_name) - # Patch _is_complete so we send multiple times + # Patch _is_complete so we send multiple times. 500ms covers + # the QU query at 0ms plus the QM query at ~_LISTENER_TIME + + # max random delay (~320ms). with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 1200) + await aiosinfo.async_request(aiozc.zeroconf, 500) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] diff --git a/tests/test_core.py b/tests/test_core.py index d9bac566a..b14a8297d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -24,7 +24,7 @@ from zeroconf._protocol.incoming import DNSIncoming from zeroconf.asyncio import AsyncZeroconf -from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 +from . import _backdate_cache, _clear_cache, _inject_response, _wait_for_start, has_working_ipv6 log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -301,7 +301,7 @@ def mock_split_incoming_msg( # all old records with that name, rrtype, and rrclass that were received # more than one second ago are declared invalid, # and marked to expire from the cache in one second. - time.sleep(1.1) + _backdate_cache(zeroconf) # service updated. currently only text record can be updated service_text = b"path=/~humingchun/" @@ -310,12 +310,12 @@ def mock_split_incoming_msg( assert dns_text is not None assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' - time.sleep(1.1) + _backdate_cache(zeroconf) # The split message only has a SRV and A record. # This should not evict TXT records from the cache _inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated)) - time.sleep(1.1) + _backdate_cache(zeroconf) dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN) assert dns_text is not None assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/' @@ -426,7 +426,7 @@ def test_goodbye_all_services(): zc.close() -def test_register_service_with_custom_ttl(): +def test_register_service_with_custom_ttl(quick_timing: None) -> None: """Test a registering a service with a custom ttl.""" # instantiate a zeroconf instance @@ -453,7 +453,7 @@ def test_register_service_with_custom_ttl(): zc.close() -def test_logging_packets(caplog): +def test_logging_packets(caplog: pytest.LogCaptureFixture, quick_timing: None) -> None: """Test packets are only logged with debug logging.""" # instantiate a zeroconf instance diff --git a/tests/test_engine.py b/tests/test_engine.py index b7a94c866..d86de1818 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -39,9 +39,17 @@ async def test_reaper(): original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) record_with_10s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 10, b"a") record_with_1s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b") + # Backdate the short-lived record so it expires at the next + # reaper tick instead of waiting the full TTL in real time. + record_with_1s_ttl.created -= 2000 zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) now = r.current_time_millis() + # Add the question at `past` so the reaper's next tick will see + # `current_time - past > _DUPLICATE_QUESTION_INTERVAL` and prune it, + # while the initial `suppresses(now, ...)` check still sees the + # question as recent (since `now - past == 999`, not strictly `> 999`). + past = now - 999 other_known_answers: set[r.DNSRecord] = { r.DNSPointer( "_hap._tcp.local.", @@ -51,10 +59,10 @@ async def test_reaper(): "known-to-other._hap._tcp.local.", ) } - zeroconf.question_history.add_question_at_time(question, now, other_known_answers) + zeroconf.question_history.add_question_at_time(question, past, other_known_answers) assert zeroconf.question_history.suppresses(question, now, other_known_answers) entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) - await asyncio.sleep(1.2) + await asyncio.sleep(0.1) entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names()))) assert zeroconf.cache.get(record_with_1s_ttl) is None await aiozc.async_close() @@ -77,6 +85,10 @@ async def test_reaper_aborts_when_done(): assert zeroconf.cache.get(record_with_10s_ttl) is not None assert zeroconf.cache.get(record_with_1s_ttl) is not None await aiozc.async_close() - await asyncio.sleep(1.2) + # Backdate to immediate expiry so we don't have to wait the full + # TTL; the assertion is that the reaper has stopped, so a + # short sleep is enough to give it a chance to (incorrectly) run. + record_with_1s_ttl.created -= 2000 + await asyncio.sleep(0.1) assert zeroconf.cache.get(record_with_10s_ttl) is not None assert zeroconf.cache.get(record_with_1s_ttl) is not None diff --git a/tests/test_handlers.py b/tests/test_handlers.py index aeed00bcd..8e46b35f7 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -215,15 +215,18 @@ def test_register_and_lookup_type_by_uppercase_name(self): out = r.DNSOutgoing(const._FLAGS_QR_QUERY) out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN)) zc.send(out) - time.sleep(1) info = ServiceInfo(type_, registration_name) - info.load_from_cache(zc) + for _ in range(50): + time.sleep(0.02) + info.load_from_cache(zc) + if info.addresses: + break assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] assert info.properties == {b"version": b"1.0"} zc.close() -def test_ptr_optimization(): +def test_ptr_optimization(quick_timing: None) -> None: # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -597,7 +600,7 @@ async def test_probe_answered_immediately_with_uppercase_name(): zc.close() -def test_qu_response(): +def test_qu_response(quick_timing: None) -> None: """Handle multicast incoming with the QU bit set.""" # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -1351,8 +1354,12 @@ async def test_cache_flush_bit(): else: assert entry.ttl == 1 - # Wait for the ttl 1 records to expire - await asyncio.sleep(1.1) + # Backdate the ttl=1 records so they are already expired when + # load_from_cache runs — equivalent to sleeping 1.1s without the wait. + for store in zc.cache.cache.values(): + for cached in store.values(): + if cached.ttl == 1: + cached.created -= 1100 loaded_info = r.ServiceInfo(type_, registration_name) loaded_info.load_from_cache(zc) diff --git a/tests/test_services.py b/tests/test_services.py index bc4770db9..922046e39 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,7 +5,6 @@ import logging import os import socket -import time import unittest from threading import Event from typing import Any @@ -113,8 +112,10 @@ def update_service(self, zeroconf, type, name): service_added.wait(1) assert service_added.is_set() - # short pause to allow multicast timers to expire - time.sleep(0.5) + # Drain pending multicast announces from the registrar instead + # of sleeping for them — same pattern as PR #1701. + zeroconf_registrar.out_queue.queue.clear() + zeroconf_registrar.out_delay_queue.queue.clear() zeroconf_browser.add_service_listener(type_, DuplicateListener()) duplicate_service_added.wait( diff --git a/tests/test_updates.py b/tests/test_updates.py index d8b160835..9112ed7b9 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -29,7 +29,7 @@ def teardown_module(): log.setLevel(original_logging_level) -def test_legacy_record_update_listener(): +def test_legacy_record_update_listener(quick_timing: None) -> None: """Test a RecordUpdateListener that does not implement update_records.""" # instantiate a zeroconf instance From 8cae923bc9e0b68c71bcf2f67a13de1cfed7c821 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 13:41:50 -0700 Subject: [PATCH 1375/1433] ci: scope release concurrency group per ref (#1705) --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b75c2feb..fb8b0018b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,9 @@ jobs: runs-on: ubuntu-latest environment: release - concurrency: release + concurrency: + group: release-${{ github.head_ref || github.ref }} + cancel-in-progress: false permissions: id-token: write contents: write From cab5fa8eee7da51dad599d8d2a8d60f5379d6ae7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 13:42:00 -0700 Subject: [PATCH 1376/1433] ci: cache poetry venv keyed on lockfile and cython sources (#1704) --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb8b0018b..6db5dbae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true +env: + POETRY_VIRTUALENVS_IN_PROJECT: "true" + jobs: lint: runs-on: ubuntu-latest @@ -89,13 +92,21 @@ jobs: python-version: ${{ matrix.python-version }} cache: "poetry" allow-prereleases: true + - name: Cache poetry venv + id: cache-venv + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + .venv + src/zeroconf/**/*.so + key: venv-v1-${{ runner.os }}-py${{ matrix.python-version }}-${{ matrix.extension }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} - name: Install Dependencies no cython - if: ${{ matrix.extension == 'skip_cython' }} + if: ${{ matrix.extension == 'skip_cython' && steps.cache-venv.outputs.cache-hit != 'true' }} env: SKIP_CYTHON: 1 run: poetry install --only=main,dev - name: Install Dependencies with cython - if: ${{ matrix.extension != 'skip_cython' }} + if: ${{ matrix.extension != 'skip_cython' && steps.cache-venv.outputs.cache-hit != 'true' }} env: REQUIRE_CYTHON: 1 run: poetry install --only=main,dev @@ -120,7 +131,16 @@ jobs: enable-cache: true - name: Install poetry run: uv tool install poetry + - name: Cache poetry venv + id: cache-venv + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + .venv + src/zeroconf/**/*.so + key: venv-v1-${{ runner.os }}-benchmark-py3.13-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} - name: Install Dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' run: | REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash From 0deb56b78fe6cd701a43ce34dccd4b69d6dd6d36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 15:18:26 -0700 Subject: [PATCH 1377/1433] fix(core): release sockets when close runs before engine setup completes (#1706) --- src/zeroconf/_engine.py | 33 +++++++++++++++++++++++++++---- tests/services/test_browser.py | 5 ++++- tests/services/test_info.py | 4 ++++ tests/test_asyncio.py | 1 + tests/test_core.py | 9 ++++++--- tests/test_engine.py | 36 ++++++++++++++++++++++++++++++++++ tests/test_handlers.py | 2 ++ tests/utils/test_asyncio.py | 1 + 8 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 8a371e1e2..0e1c01a15 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -114,10 +114,17 @@ async def _async_create_endpoints(self) -> None: lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value] sock=s, ) + # Register the wrapped transport before releasing the engine's + # handle so a concurrent shutdown always sees ``s`` in exactly + # one place; do not add an ``await`` between these two steps. self.protocols.append(cast(AsyncListener, protocol)) self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) if s in sender_sockets: self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + if s is self._listen_socket: + self._listen_socket = None + if s in self._respond_sockets: + self._respond_sockets.remove(s) def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" @@ -139,19 +146,37 @@ def _async_schedule_next_cache_cleanup(self) -> None: async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" assert self._setup_task is not None - await self._setup_task + # Swallow CancelledError only if the setup task itself was + # cancelled (close-before-start); outer-task cancellation must + # propagate. + try: + await self._setup_task + except asyncio.CancelledError: + if not self._setup_task.cancelled(): + raise self._async_shutdown() await asyncio.sleep(0) # flush out any call soons - assert self._cleanup_timer is not None - self._cleanup_timer.cancel() + if self._cleanup_timer is not None: + self._cleanup_timer.cancel() def _async_shutdown(self) -> None: - """Shutdown transports and sockets.""" + """Shutdown transports and sockets; safe to call repeatedly.""" assert self.running_future is not None assert self.loop is not None self.running_future = self.loop.create_future() + # Cancel pending setup so it can't wrap fresh transports after + # shutdown has started. + if self._setup_task is not None and not self._setup_task.done(): + self._setup_task.cancel() for wrapped_transport in itertools.chain(self.senders, self.readers): wrapped_transport.transport.close() + # Anything still here was never adopted by a transport. + if self._listen_socket is not None: + self._listen_socket.close() + self._listen_socket = None + for s in self._respond_sockets: + s.close() + self._respond_sockets = [] def close(self) -> None: """Close from sync context. diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index c976e456b..32c122a4e 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1033,7 +1033,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de def test_service_browser_listeners_no_update_service(): - """Test that the ServiceBrowser ServiceListener that does not implement update_service.""" + """A listener that ignores update events records only add/remove callbacks.""" # instantiate a zeroconf instance zc = Zeroconf(interfaces=["127.0.0.1"]) @@ -1051,6 +1051,9 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de if name == registration_name: callbacks.append(("remove", type_, name)) + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] + pass + listener = MyServiceListener() browser = r.ServiceBrowser(zc, type_, None, listener) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 88f9958ae..a20513506 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1529,6 +1529,7 @@ async def test_bad_ip_addresses_ignored_in_cache(): info = ServiceInfo(type_, registration_name) info.load_from_cache(aiozc.zeroconf) assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"] + await aiozc.async_close() @pytest.mark.asyncio @@ -1804,6 +1805,7 @@ async def test_address_resolver(): aiozc.zeroconf.async_send(outgoing) assert await resolve_task assert resolver.addresses == [b"\x7f\x00\x00\x01"] + await aiozc.async_close() @pytest.mark.asyncio @@ -1828,6 +1830,7 @@ async def test_address_resolver_ipv4(): aiozc.zeroconf.async_send(outgoing) assert await resolve_task assert resolver.addresses == [b"\x7f\x00\x00\x01"] + await aiozc.async_close() @pytest.mark.asyncio @@ -1854,6 +1857,7 @@ async def test_address_resolver_ipv6(): aiozc.zeroconf.async_send(outgoing) assert await resolve_task assert resolver.ip_addresses_by_version(IPVersion.All) == [ip_address("fe80::52e:c2f2:bc5f:e9c6")] + await aiozc.async_close() @pytest.mark.asyncio diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 5d9fb8653..2d6c172bd 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -500,6 +500,7 @@ async def test_async_service_registration_name_strict_check(quick_timing: None) await aiozc.async_unregister_service(info) await aiozc.async_close() + zc.close() @pytest.mark.asyncio diff --git a/tests/test_core.py b/tests/test_core.py index b14a8297d..16f765d46 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -824,6 +824,9 @@ def _background_register(): async def test_event_loop_blocked(mock_start): """Test we raise NotRunningException when waiting for startup that times out.""" aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) - with pytest.raises(NotRunningException): - await aiozc.zeroconf.async_wait_for_start(timeout=0) - assert aiozc.zeroconf.started is False + try: + with pytest.raises(NotRunningException): + await aiozc.zeroconf.async_wait_for_start(timeout=0) + assert aiozc.zeroconf.started is False + finally: + await aiozc.async_close() diff --git a/tests/test_engine.py b/tests/test_engine.py index d86de1818..750d3393b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -73,6 +73,42 @@ async def test_reaper(): assert record_with_1s_ttl not in entries +@pytest.mark.asyncio +async def test_setup_releases_socket_ownership() -> None: + """Engine releases its pending-socket refs once each socket has a transport.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + try: + await aiozc.zeroconf.async_wait_for_start() + engine = aiozc.zeroconf.engine + assert engine._listen_socket is None + assert engine._respond_sockets == [] + assert engine.readers + assert engine.senders + finally: + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_async_close_propagates_outer_cancellation() -> None: + """Outer-task cancellation while awaiting setup propagates to the caller.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + try: + await aiozc.zeroconf.async_wait_for_start() + engine = aiozc.zeroconf.engine + loop = asyncio.get_running_loop() + original_task = engine._setup_task + fake_task = loop.create_future() + fake_task.set_exception(asyncio.CancelledError()) + engine._setup_task = fake_task # type: ignore[assignment] + try: + with pytest.raises(asyncio.CancelledError): + await engine._async_close() + finally: + engine._setup_task = original_task + finally: + await aiozc.async_close() + + @pytest.mark.asyncio async def test_reaper_aborts_when_done(): """Ensure cache cleanup stops when zeroconf is done.""" diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 8e46b35f7..ed6029e38 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1799,6 +1799,8 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli zc.record_manager.async_updates_from_response(incoming) assert info2.dns_pointer() in incoming.answers() + await aiozc.async_close() + @pytest.mark.asyncio async def test_response_aggregation_random_delay(): diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index c8fd2ad2d..361faa2b8 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -105,6 +105,7 @@ def _run_coro() -> None: assert loop.is_running() is False runcoro_thread.join() + loop.close() def test_cumulative_timeouts_less_than_close_plus_buffer(): From 6db1f91a352cd2d15ad581a17780079549232e60 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 17 May 2026 22:23:34 +0000 Subject: [PATCH 1378/1433] 0.149.4 Automatically generated by python-semantic-release --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05ee44fe..1ebfa2039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ +## v0.149.4 (2026-05-17) + +### Bug Fixes + +- **core**: Release sockets when close runs before engine setup completes + ([#1706](https://github.com/python-zeroconf/python-zeroconf/pull/1706), + [`0deb56b`](https://github.com/python-zeroconf/python-zeroconf/commit/0deb56b78fe6cd701a43ce34dccd4b69d6dd6d36)) + +### Testing + +- Drop pending multicast responses before TOCTOU assertion + ([#1701](https://github.com/python-zeroconf/python-zeroconf/pull/1701), + [`d2058d9`](https://github.com/python-zeroconf/python-zeroconf/commit/d2058d95882c70fb2eb786d373a630314e656c15)) + +- Speed up slow loopback tests (closes #1697) + ([#1699](https://github.com/python-zeroconf/python-zeroconf/pull/1699), + [`dd341a3`](https://github.com/python-zeroconf/python-zeroconf/commit/dd341a378e904cbf9b9a09e5c2ac8ff7c9944cd0)) + +- Speed up slow loopback tests (closes #1700) + ([#1703](https://github.com/python-zeroconf/python-zeroconf/pull/1703), + [`d03ea36`](https://github.com/python-zeroconf/python-zeroconf/commit/d03ea364dea5866e0301ff11f6b78714ecf991e8)) + +- Speed up test_async_wait_unblocks_on_update + ([#1702](https://github.com/python-zeroconf/python-zeroconf/pull/1702), + [`653c385`](https://github.com/python-zeroconf/python-zeroconf/commit/653c38559c468672cf907d808d432dec0fb06968)) + +- Widen scheduling buffer in flaky get_info suppression test + ([#1698](https://github.com/python-zeroconf/python-zeroconf/pull/1698), + [`9b4db62`](https://github.com/python-zeroconf/python-zeroconf/commit/9b4db625e121c2d590ed8fe2f4d187d5e3bb9a73)) + + ## v0.149.3 (2026-05-17) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index adb48def1..10fe7fb76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.3" +version = "0.149.4" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 3bd03d409..0b31bb7cf 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.3" +__version__ = "0.149.4" __license__ = "LGPL" From ee3c7d74ff45327a3a6d520b86a691e21e2bc219 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 17:12:37 -0700 Subject: [PATCH 1379/1433] =?UTF-8?q?test:=20cap=20helper=20get=5Fservice?= =?UTF-8?q?=5Finfo=20timeout=20in=20suppression=20test=20(~3.0s=20?= =?UTF-8?q?=E2=86=92=20~1.85s)=20(#1708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/services/test_info.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index a20513506..9e5a69417 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -430,9 +430,9 @@ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: return r.DNSIncoming(generated.packets()[0]) - def get_service_info_helper(zc, type, name): + def get_service_info_helper(zc, type, name, timeout): nonlocal service_info - service_info = zc.get_service_info(type, name) + service_info = zc.get_service_info(type, name, timeout) service_info_event.set() try: @@ -453,9 +453,14 @@ def get_service_info_helper(zc, type, name): for question in seed_history_questions: zc.question_history.add_question_at_time(question, far_future, set()) + # No answers ever come back (all queries are suppressed), + # so cap the helper at the worst-case sum of the three + # phase waits below plus margin instead of the 3000ms + # default. Phase 3 waits ~1.6s (the 999ms QM gap plus + # jitter and 500ms buffer); 1500ms covers it. helper_thread = threading.Thread( target=get_service_info_helper, - args=(zc, service_type, service_name), + args=(zc, service_type, service_name, 1500), ) helper_thread.start() wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 500) / 1000 From 4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 17:21:24 -0700 Subject: [PATCH 1380/1433] test: speed up service-info request tests with quick_request_timing fixture (#1709) --- tests/__init__.py | 7 +++++++ tests/conftest.py | 19 +++++++++++++++++++ tests/services/test_info.py | 25 ++++++++++++++++++------- tests/test_asyncio.py | 11 ++++++----- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 6a7bbb98b..23bc4e2d1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -35,6 +35,13 @@ _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution +# get_service_info / async_request timeout for tests using the +# `quick_request_timing` fixture. The fixture cuts the initial-query +# delay to ~15ms (10ms _LISTENER_TIME + 1-5ms jitter), so 50ms is +# ample headroom for tests that only need to observe the first one +# or two queries. +QUICK_REQUEST_TIMEOUT_MS = 50 + class QuestionHistoryWithoutSuppression(QuestionHistory): def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index f723e30c3..65569d654 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from zeroconf import _core, const from zeroconf._handlers import query_handler +from zeroconf._services import info as service_info @pytest.fixture(autouse=True) @@ -59,3 +60,21 @@ def quick_timing() -> Generator[None]: patch.object(_core, "_UNREGISTER_TIME", 10), ): yield + + +@pytest.fixture +def quick_request_timing() -> Generator[None]: + """Shorten the initial-query delay used by AsyncServiceInfo.async_request. + + The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762 + §5.2) help spread queries from multiple clients on real networks. + On loopback they're pure overhead — get_service_info-style tests + wait ~250ms before the first query even fires. Opt in by adding + `quick_request_timing` to a test's argument list, then drop the + test's own timeouts (which had to accommodate that delay). + """ + with ( + patch.object(service_info, "_LISTENER_TIME", 10), + patch.object(service_info, "_AVOID_SYNC_DELAY_RANDOM_INTERVAL", (1, 5)), + ): + yield diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 9e5a69417..3e9ecf807 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -23,7 +23,7 @@ from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf -from .. import _inject_response, has_working_ipv6 +from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6 log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -511,6 +511,7 @@ def get_service_info_helper(zc, type, name, timeout): zc.remove_all_service_listeners() zc.close() + @pytest.mark.usefixtures("quick_request_timing") def test_get_info_single(self): zc = r.Zeroconf(interfaces=["127.0.0.1"]) @@ -556,6 +557,9 @@ def get_service_info_helper(zc, type, name): args=(zc, service_type, service_name), ) helper_thread.start() + # Positive wait — the first query fires within + # `_LISTENER_TIME` + jitter (~15ms under + # `quick_request_timing`, ~320ms without). wait_time = 1 # Expect query for SRV, TXT, A, AAAA @@ -568,7 +572,10 @@ def get_service_info_helper(zc, type, name): assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions assert service_info is None - # Expect no further queries + # Expect no further queries — under `quick_request_timing` + # the next query would have fired ~15ms after the previous + # send, so 100ms is plenty of headroom for the negative + # assertion. last_sent = None send_event.clear() _inject_response( @@ -602,7 +609,7 @@ def get_service_info_helper(zc, type, name): ] ), ) - send_event.wait(wait_time) + send_event.wait(0.1) assert last_sent is None assert service_info is not None @@ -985,7 +992,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict(): assert info_service.dns_text().text == b"\x0epath=/~paulsm/" -def test_asking_qu_questions(): +def test_asking_qu_questions(quick_request_timing): """Verify explicitly asking QU questions.""" type_ = "_quservice._tcp.local." zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) @@ -1004,12 +1011,14 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU) + zeroconf.get_service_info( + f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QU + ) assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] zeroconf.close() -def test_asking_qm_questions(): +def test_asking_qm_questions(quick_request_timing): """Verify explicitly asking QM questions.""" type_ = "_quservice._tcp.local." zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) @@ -1028,7 +1037,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf, "async_send", send): - zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM) + zeroconf.get_service_info( + f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QM + ) assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr] zeroconf.close() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 2d6c172bd..76d626898 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -43,6 +43,7 @@ from zeroconf.const import _LISTENER_TIME from . import ( + QUICK_REQUEST_TIMEOUT_MS, QuestionHistoryWithoutSuppression, _clear_cache, has_working_ipv6, @@ -1139,7 +1140,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): @pytest.mark.asyncio -async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(): +async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(quick_request_timing): """Verify the service info first question is QU and subsequent ones are QM questions.""" type_ = "_quservice._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) @@ -1182,11 +1183,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the zeroconf send with patch.object(zeroconf_info, "async_send", send): aiosinfo = AsyncServiceInfo(type_, registration_name) - # Patch _is_complete so we send multiple times. 500ms covers - # the QU query at 0ms plus the QM query at ~_LISTENER_TIME + - # max random delay (~320ms). + # Patch _is_complete so we send multiple times. Under + # `quick_request_timing` both the QU query at 0ms and the QM + # query at ~15ms land well inside QUICK_REQUEST_TIMEOUT_MS. with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 500) + await aiosinfo.async_request(aiozc.zeroconf, QUICK_REQUEST_TIMEOUT_MS) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] From 91aa21d52a0873f5fc12d43675b1b521dfe20519 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 17:45:54 -0700 Subject: [PATCH 1381/1433] test: fix race in test_register_and_lookup_type_by_uppercase_name (#1712) --- tests/test_handlers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ed6029e38..f14c1e1ad 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -219,7 +219,11 @@ def test_register_and_lookup_type_by_uppercase_name(self): for _ in range(50): time.sleep(0.02) info.load_from_cache(zc) - if info.addresses: + # Wait for both A and TXT records — they arrive as separate + # cache updates and the listener may schedule the assertions + # between the two. Breaking on just `info.addresses` makes + # the test flaky under PyPy / skip_cython. + if info.addresses and info.properties: break assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")] assert info.properties == {b"version": b"1.0"} From 963d3d70e1cde056967eba0d8747ddcd247ae707 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 18:41:33 -0700 Subject: [PATCH 1382/1433] test: eliminate test_get_info_single race by injecting from the send mock (#1716) --- tests/services/test_info.py | 141 +++++++++++++++++------------------- 1 file changed, 66 insertions(+), 75 deletions(-) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 3e9ecf807..dae42afd9 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -522,97 +522,88 @@ def test_get_info_single(self): service_address = "10.0.1.2" service_info = None - send_event = Event() service_info_event = Event() - last_sent: r.DNSOutgoing | None = None + ttl = 120 + response_records = [ + r.DNSText( + service_name, + const._TYPE_TXT, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + service_text, + ), + r.DNSService( + service_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + 0, + 0, + 80, + service_server, + ), + r.DNSAddress( + service_server, + const._TYPE_A, + const._CLASS_IN | const._CLASS_UNIQUE, + ttl, + socket.inet_pton(socket.AF_INET, service_address), + ), + ] - def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): - """Sends an outgoing packet.""" - nonlocal last_sent + def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: + generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return r.DNSIncoming(generated.packets()[0]) - last_sent = out - send_event.set() + sent_queries: list[r.DNSOutgoing] = [] + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): + """Capture each query and, on the first one, fill the cache + inline so the next iteration of `async_request` finds + `_is_complete=True` and exits without sending another query. + + Running the inject from inside `send` keeps it on the event + loop thread and atomic with the first send — eliminating the + test-thread → `run_coroutine_threadsafe` race that flaked + under PyPy + use_cython when `quick_request_timing` shortens + the inter-iteration delay to ~15ms. + """ + sent_queries.append(out) + if len(sent_queries) == 1: + zc.record_manager.async_updates_from_response(mock_incoming_msg(response_records)) + + def get_service_info_helper(zc, type, name): + nonlocal service_info + service_info = zc.get_service_info(type, name) + service_info_event.set() # patch the zeroconf send with patch.object(zc, "async_send", send): - - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packets()[0]) - - def get_service_info_helper(zc, type, name): - nonlocal service_info - service_info = zc.get_service_info(type, name) - service_info_event.set() - try: - ttl = 120 helper_thread = threading.Thread( target=get_service_info_helper, args=(zc, service_type, service_name), ) helper_thread.start() - # Positive wait — the first query fires within - # `_LISTENER_TIME` + jitter (~15ms under - # `quick_request_timing`, ~320ms without). - wait_time = 1 - - # Expect query for SRV, TXT, A, AAAA - send_event.wait(wait_time) - assert last_sent is not None - assert len(last_sent.questions) == 4 - assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions - assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions - assert service_info is None - # Expect no further queries — under `quick_request_timing` - # the next query would have fired ~15ms after the previous - # send, so 100ms is plenty of headroom for the negative - # assertion. - last_sent = None - send_event.clear() - _inject_response( - zc, - mock_incoming_msg( - [ - r.DNSText( - service_name, - const._TYPE_TXT, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - service_text, - ), - r.DNSService( - service_name, - const._TYPE_SRV, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - 0, - 0, - 80, - service_server, - ), - r.DNSAddress( - service_server, - const._TYPE_A, - const._CLASS_IN | const._CLASS_UNIQUE, - ttl, - socket.inet_pton(socket.AF_INET, service_address), - ), - ] - ), - ) - send_event.wait(0.1) - assert last_sent is None + # Helper should complete promptly — the inline inject in + # `send` populates the cache before the request loop's + # next iteration. + service_info_event.wait(1) assert service_info is not None + # First (and only) query: QU for SRV/TXT/A/AAAA. + assert len(sent_queries) == 1 + first_sent = sent_queries[0] + assert len(first_sent.questions) == 4 + assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in first_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in first_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in first_sent.questions + assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in first_sent.questions + finally: helper_thread.join() zc.remove_all_service_listeners() From 64d143d2ee7874ee1d9cef0dd2799c008b4aa791 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 18:41:52 -0700 Subject: [PATCH 1383/1433] test: drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback (#1710) --- tests/__init__.py | 7 +++++++ tests/services/test_types.py | 20 +++++++++++--------- tests/test_asyncio.py | 15 +++++++++++---- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 23bc4e2d1..9f58de1d5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -42,6 +42,13 @@ # or two queries. QUICK_REQUEST_TIMEOUT_MS = 50 +# Timeout for ZeroconfServiceTypes.find() / AsyncZeroconfServiceTypes.async_find() +# in loopback integration tests. `find()` is just `time.sleep(timeout)` — +# it doesn't short-circuit on the first matching response — so the +# timeout becomes a lower bound on the test runtime. On loopback the +# registrar's response lands within a few ms; 200ms is ~50x headroom. +LOOPBACK_FIND_TIMEOUT = 0.2 + class QuestionHistoryWithoutSuppression(QuestionHistory): def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool: diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 121d97972..69e6d5a6f 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -11,7 +11,7 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, ZeroconfServiceTypes -from .. import _clear_cache, has_working_ipv6 +from .. import LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6 log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -47,10 +47,10 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT) assert type_ in service_types finally: @@ -79,10 +79,10 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT) assert type_ in service_types finally: @@ -115,10 +115,12 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5) + service_types = ZeroconfServiceTypes.find( + ip_version=r.IPVersion.V6Only, timeout=LOOPBACK_FIND_TIMEOUT + ) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT) assert type_ in service_types finally: @@ -147,10 +149,10 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress ) zeroconf_registrar.registry.async_add(info) try: - service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5) + service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT) assert discovery_type in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT) assert discovery_type in service_types finally: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 76d626898..e4449b6cf 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -43,6 +43,7 @@ from zeroconf.const import _LISTENER_TIME from . import ( + LOOPBACK_FIND_TIMEOUT, QUICK_REQUEST_TIMEOUT_MS, QuestionHistoryWithoutSuppression, _clear_cache, @@ -919,14 +920,20 @@ async def test_async_zeroconf_service_types(quick_timing: None) -> None: ) task = await zeroconf_registrar.async_register_service(info) await task - # Ensure we do not clear the cache until after the last broadcast is processed - await asyncio.sleep(0.2) + # Wait for the last announce broadcast before clearing. With + # `quick_timing` the broadcasts use _REGISTER_TIME=10ms apart so + # 50ms is plenty. + await asyncio.sleep(0.05) _clear_cache(zeroconf_registrar.zeroconf) try: - service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=["127.0.0.1"], timeout=0.5) + service_types = await AsyncZeroconfServiceTypes.async_find( + interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT + ) assert type_ in service_types _clear_cache(zeroconf_registrar.zeroconf) - service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=0.5) + service_types = await AsyncZeroconfServiceTypes.async_find( + aiozc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT + ) assert type_ in service_types finally: From f9e23592137f30fdf7ef710dba065da31c79b1cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 19:48:44 -0700 Subject: [PATCH 1384/1433] fix: bound DNS compression-pointer chain depth in DNSIncoming (#1719) --- src/zeroconf/_protocol/incoming.pxd | 2 +- src/zeroconf/_protocol/incoming.py | 14 ++++++++++---- tests/test_protocol.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index feaa2a02e..eface8d16 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -83,7 +83,7 @@ cdef class DNSIncoming: link_py_int=object, linked_labels=cython.list ) - cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers) + cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers, unsigned int depth) @cython.locals(offset="unsigned int") cdef void _read_header(self) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index 2d977b642..d772f470f 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -60,7 +60,7 @@ MAX_DNS_LABELS = 128 MAX_NAME_LENGTH = 253 -DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError) +DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError, RecursionError) _seen_logs: dict[str, int | tuple] = {} @@ -409,7 +409,7 @@ def _read_name(self) -> str: labels: list[str] = [] seen_pointers: set[int] = set() original_offset = self.offset - self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers) + self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers, 0) self._name_cache[original_offset] = labels name = ".".join(labels) + "." if len(name) > MAX_NAME_LENGTH: @@ -418,8 +418,14 @@ def _read_name(self) -> str: ) return name - def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int: + def _decode_labels_at_offset( + self, off: _int, labels: list[str], seen_pointers: set[int], depth: _int + ) -> int: # This is a tight loop that is called frequently, small optimizations can make a difference. + if depth > MAX_DNS_LABELS: + raise IncomingDecodeError( + f"DNS compression pointer chain exceeds {MAX_DNS_LABELS} at {off} from {self.source}" + ) view = self.view while off < self._data_len: length = view[off] @@ -457,7 +463,7 @@ def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: if not linked_labels: linked_labels = [] seen_pointers.add(link_py_int) - self._decode_labels_at_offset(link, linked_labels, seen_pointers) + self._decode_labels_at_offset(link, linked_labels, seen_pointers, depth + 1) self._name_cache[link_py_int] = linked_labels labels.extend(linked_labels) if len(labels) > MAX_DNS_LABELS: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index edd87c2e7..bac2b447b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1011,6 +1011,28 @@ def test_label_compression_attack(): assert len(parsed.answers()) == 1 +def test_dns_compression_pointer_chain_depth_attack() -> None: + """Test our wire parser rejects deeply chained compression pointers without recursing.""" + # Build a packet with one question whose name is a 1500-deep chain of forward + # compression pointers, ending in a root label. Each pointer is 2 bytes, + # so chain length easily exceeds CPython's default recursion limit. + header = b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" + # Question at offset 12: pointer to offset 18 (past the question's type/class). + question_name = bytes([0xC0, 18]) + question_type_class = b"\x00\x01\x00\x01" + chain_depth = 1500 + chain = bytearray() + for i in range(chain_depth): + target = 18 + 2 * (i + 1) + chain.append(0xC0 | (target >> 8)) + chain.append(target & 0xFF) + chain.append(0x00) + packet = header + question_name + question_type_class + bytes(chain) + parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353)) + assert parsed.valid is False + assert parsed.questions == [] + + def test_dns_compression_loop_attack(): """Test our wire parser does not loop forever when dns compression is in a loop.""" packet = ( From 65b22cb8de06dcb40a8ee21b04ce84e9aa0dac91 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 18 May 2026 02:54:48 +0000 Subject: [PATCH 1385/1433] 0.149.5 Automatically generated by python-semantic-release --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebfa2039..f01decf02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ +## v0.149.5 (2026-05-18) + +### Bug Fixes + +- Bound DNS compression-pointer chain depth in DNSIncoming + ([#1719](https://github.com/python-zeroconf/python-zeroconf/pull/1719), + [`f9e2359`](https://github.com/python-zeroconf/python-zeroconf/commit/f9e23592137f30fdf7ef710dba065da31c79b1cf)) + +### Testing + +- Cap helper get_service_info timeout in suppression test (~3.0s → ~1.85s) + ([#1708](https://github.com/python-zeroconf/python-zeroconf/pull/1708), + [`ee3c7d7`](https://github.com/python-zeroconf/python-zeroconf/commit/ee3c7d74ff45327a3a6d520b86a691e21e2bc219)) + +- Drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback + ([#1710](https://github.com/python-zeroconf/python-zeroconf/pull/1710), + [`64d143d`](https://github.com/python-zeroconf/python-zeroconf/commit/64d143d2ee7874ee1d9cef0dd2799c008b4aa791)) + +- Eliminate test_get_info_single race by injecting from the send mock + ([#1716](https://github.com/python-zeroconf/python-zeroconf/pull/1716), + [`963d3d7`](https://github.com/python-zeroconf/python-zeroconf/commit/963d3d70e1cde056967eba0d8747ddcd247ae707)) + +- Fix race in test_register_and_lookup_type_by_uppercase_name + ([#1712](https://github.com/python-zeroconf/python-zeroconf/pull/1712), + [`91aa21d`](https://github.com/python-zeroconf/python-zeroconf/commit/91aa21d52a0873f5fc12d43675b1b521dfe20519)) + +- Speed up service-info request tests with quick_request_timing fixture + ([#1709](https://github.com/python-zeroconf/python-zeroconf/pull/1709), + [`4bae30a`](https://github.com/python-zeroconf/python-zeroconf/commit/4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31)) + + ## v0.149.4 (2026-05-17) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 10fe7fb76..7cf822383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.4" +version = "0.149.5" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 0b31bb7cf..c02cb04b9 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.4" +__version__ = "0.149.5" __license__ = "LGPL" From 95561e28b24922358f1991e38e3a86d70d72dcec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 20:58:18 -0700 Subject: [PATCH 1386/1433] fix: bound _seen_logs and stop retaining exc_info (#1717) --- src/zeroconf/_logger.py | 89 +++++++++++++++++++----------- src/zeroconf/_protocol/incoming.py | 12 +--- tests/benchmarks/test_mark_seen.py | 39 +++++++++++++ tests/test_logger.py | 87 +++++++++++++++++++++++++++-- tests/test_protocol.py | 34 ++++++++++++ 5 files changed, 214 insertions(+), 47 deletions(-) create mode 100644 tests/benchmarks/test_mark_seen.py diff --git a/src/zeroconf/_logger.py b/src/zeroconf/_logger.py index 0d734dfde..99990cf68 100644 --- a/src/zeroconf/_logger.py +++ b/src/zeroconf/_logger.py @@ -25,7 +25,7 @@ import logging import sys -from typing import Any, ClassVar, cast +from typing import Any log = logging.getLogger(__name__.split(".", maxsplit=1)[0]) log.addHandler(logging.NullHandler()) @@ -39,50 +39,73 @@ def set_logger_level_if_unset() -> None: set_logger_level_if_unset() -class QuietLogger: - _seen_logs: ClassVar[dict[str, int | tuple]] = {} +_MAX_SEEN_LOGS = 512 +_seen_logs: dict[str, None] = {} + + +def _evict_oldest(seen: dict[str, None]) -> bool: + """Pop the oldest entry from ``seen``; return False if it raced. + + Individual dict ops (``pop`` with a default, ``next``) are atomic + on the free-threaded build, but the compound ``iter`` → ``next`` + used to pick the FIFO victim can raise ``RuntimeError`` if + another thread mutates the dict between the two ops. The caller + breaks its drain loop on False so concurrent mutation can't make + it spin. + """ + try: + seen.pop(next(iter(seen)), None) + except (RuntimeError, StopIteration): + return False + return True + + +def _mark_seen(seen: dict[str, None], key: str) -> bool: + """Record ``key`` in ``seen`` and return True if it was newly added. + + Bounds the dict so callers passing attacker-influenced keys (peer + addresses, packet offsets) cannot grow it without bound. Evicts + the oldest entries on overflow (dict preserves insertion order on + Python 3.7+), so ``_MAX_SEEN_LOGS`` is a recency window. + + The dict is shared across all ``Zeroconf`` instances in the + process; on the free-threaded build (3.14t) and under multi- + instance sync use, callers can race the ``len < cap`` check and + both insert, leaving the dict transiently above the cap. The + drain loop runs on every call (steady-state-at-cap hits are a + single ``len`` + compare past the membership check because the + helper short-circuits) so a contention burst is corrected by the + next caller regardless of whether it's a hit or a miss. + """ + inserting = key not in seen + # Hit (``inserting`` is False): drain only if drifted above cap. + # Miss (``inserting`` is True): drain to ``cap - 1`` to make room + # for the new key. Bool subtracts as 0/1 to pick the right limit. + while len(seen) > _MAX_SEEN_LOGS - inserting and _evict_oldest(seen): + pass + if inserting: + seen[key] = None + return inserting + +class QuietLogger: @classmethod def log_exception_warning(cls, *logger_data: Any) -> None: - exc_info = sys.exc_info() - exc_str = str(exc_info[1]) - if exc_str not in cls._seen_logs: - # log at warning level the first time this is seen - cls._seen_logs[exc_str] = exc_info - logger = log.warning - else: - logger = log.debug + first_time = _mark_seen(_seen_logs, str(sys.exc_info()[1])) + logger = log.warning if first_time else log.debug logger(*(logger_data or ["Exception occurred"]), exc_info=True) @classmethod def log_exception_debug(cls, *logger_data: Any) -> None: - log_exc_info = False - exc_info = sys.exc_info() - exc_str = str(exc_info[1]) - if exc_str not in cls._seen_logs: - # log the trace only on the first time - cls._seen_logs[exc_str] = exc_info - log_exc_info = True - log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info) + first_time = _mark_seen(_seen_logs, str(sys.exc_info()[1])) + log.debug(*(logger_data or ["Exception occurred"]), exc_info=first_time) @classmethod def log_warning_once(cls, *args: Any) -> None: - msg_str = args[0] - if msg_str not in cls._seen_logs: - cls._seen_logs[msg_str] = 0 - logger = log.warning - else: - logger = log.debug - cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 + logger = log.warning if _mark_seen(_seen_logs, args[0]) else log.debug logger(*args) @classmethod def log_exception_once(cls, exc: Exception, *args: Any) -> None: - msg_str = args[0] - if msg_str not in cls._seen_logs: - cls._seen_logs[msg_str] = 0 - logger = log.warning - else: - logger = log.debug - cls._seen_logs[msg_str] = cast(int, cls._seen_logs[msg_str]) + 1 + logger = log.warning if _mark_seen(_seen_logs, args[0]) else log.debug logger(*args, exc_info=exc) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index d772f470f..ffbbb59f5 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -37,7 +37,7 @@ DNSText, ) from .._exceptions import IncomingDecodeError -from .._logger import log +from .._logger import _mark_seen, log from .._utils.time import current_time_millis from ..const import ( _FLAGS_QR_MASK, @@ -63,7 +63,7 @@ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError, RecursionError) -_seen_logs: dict[str, int | tuple] = {} +_seen_logs: dict[str, None] = {} _str = str _int = int @@ -182,13 +182,7 @@ def _initial_parse(self) -> None: @classmethod def _log_exception_debug(cls, *logger_data: Any) -> None: - log_exc_info = False - exc_info = sys.exc_info() - exc_str = str(exc_info[1]) - if exc_str not in _seen_logs: - # log the trace only on the first time - _seen_logs[exc_str] = exc_info - log_exc_info = True + log_exc_info = _mark_seen(_seen_logs, str(sys.exc_info()[1])) log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info) def answers(self) -> list[DNSRecord]: diff --git a/tests/benchmarks/test_mark_seen.py b/tests/benchmarks/test_mark_seen.py new file mode 100644 index 000000000..4f82da8cd --- /dev/null +++ b/tests/benchmarks/test_mark_seen.py @@ -0,0 +1,39 @@ +"""Benchmark for _logger._mark_seen.""" + +from __future__ import annotations + +from pytest_codspeed import BenchmarkFixture + +from zeroconf._logger import _MAX_SEEN_LOGS, _mark_seen + + +def test_mark_seen_hit(benchmark: BenchmarkFixture) -> None: + """Benchmark the cache-hit path (same key repeated).""" + seen: dict[str, None] = {"warm": None} + + @benchmark + def _hit() -> None: + for _ in range(1000): + _mark_seen(seen, "warm") + + +def test_mark_seen_fill(benchmark: BenchmarkFixture) -> None: + """Benchmark filling from empty up to the cap (no evictions).""" + keys = [f"key-{i}" for i in range(_MAX_SEEN_LOGS)] + + @benchmark + def _fill() -> None: + seen: dict[str, None] = {} + for k in keys: + _mark_seen(seen, k) + + +def test_mark_seen_churn(benchmark: BenchmarkFixture) -> None: + """Benchmark sustained eviction (every call past the cap drops oldest).""" + keys = [f"churn-{i}" for i in range(_MAX_SEEN_LOGS * 4)] + + @benchmark + def _churn() -> None: + seen: dict[str, None] = {} + for k in keys: + _mark_seen(seen, k) diff --git a/tests/test_logger.py b/tests/test_logger.py index 4e09aa3b1..8042e49c3 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -5,7 +5,8 @@ import logging from unittest.mock import call, patch -from zeroconf._logger import QuietLogger, set_logger_level_if_unset +from zeroconf import _logger +from zeroconf._logger import _MAX_SEEN_LOGS, QuietLogger, _mark_seen, set_logger_level_if_unset def test_loading_logger(): @@ -25,7 +26,7 @@ def test_loading_logger(): def test_log_warning_once(): """Test we only log with warning level once.""" - QuietLogger._seen_logs = {} + _logger._seen_logs.clear() quiet_logger = QuietLogger() with ( patch("zeroconf._logger.log.warning") as mock_log_warning, @@ -48,7 +49,7 @@ def test_log_warning_once(): def test_log_exception_warning(): """Test we only log with warning level once.""" - QuietLogger._seen_logs = {} + _logger._seen_logs.clear() quiet_logger = QuietLogger() with ( patch("zeroconf._logger.log.warning") as mock_log_warning, @@ -71,7 +72,7 @@ def test_log_exception_warning(): def test_llog_exception_debug(): """Test we only log with a trace once.""" - QuietLogger._seen_logs = {} + _logger._seen_logs.clear() quiet_logger = QuietLogger() with patch("zeroconf._logger.log.debug") as mock_log_debug: quiet_logger.log_exception_debug("the exception") @@ -84,9 +85,85 @@ def test_llog_exception_debug(): assert mock_log_debug.mock_calls == [call("the exception", exc_info=False)] +def test_mark_seen_absorbs_runtime_error_during_eviction() -> None: + """Concurrent mutation can make ``iter(seen)`` raise ``RuntimeError``. + + Free-threaded (3.14t) and multi-instance sync callers share + ``_seen_logs``; if another thread mutates it between ``iter()`` + and ``next()`` the iterator raises ``RuntimeError``. + ``_mark_seen`` must absorb that and still insert the new key. + """ + + class RacyDict(dict[str, None]): + def __iter__(self): # type: ignore[override] + raise RuntimeError("dictionary changed size during iteration") + + seen: dict[str, None] = RacyDict() + for i in range(_MAX_SEEN_LOGS): + seen[f"k-{i}"] = None + assert _mark_seen(seen, "new-key") is True + assert "new-key" in seen + + +def test_mark_seen_drains_drift_above_cap() -> None: + """``_mark_seen`` drains a drifted-over-cap dict back to the cap. + + Concurrent inserts on the free-threaded build can leave the dict + transiently above ``_MAX_SEEN_LOGS`` (e.g. two threads both passed + the ``len < cap`` check and both inserted). The next non-racing + call must drain the accumulated overshoot, not just evict one + entry — otherwise the cap silently inflates with thread count. + """ + seen: dict[str, None] = {} + drift = 10 + for i in range(_MAX_SEEN_LOGS + drift): + seen[f"k-{i}"] = None + assert len(seen) == _MAX_SEEN_LOGS + drift + assert _mark_seen(seen, "new-key") is True + assert len(seen) == _MAX_SEEN_LOGS + assert "new-key" in seen + for i in range(drift + 1): + assert f"k-{i}" not in seen + + +def test_mark_seen_drains_drift_on_hit_path() -> None: + """``_mark_seen`` drains drift even when ``key`` is already cached. + + A hit-heavy workload after a contention burst (e.g. the same + exception text deduplicated repeatedly) must still correct the + overshoot — otherwise the dict can sit permanently above the cap + until a miss happens to come along. + """ + seen: dict[str, None] = {} + drift = 10 + for i in range(_MAX_SEEN_LOGS + drift): + seen[f"k-{i}"] = None + # Hit on a non-oldest key — survives the drift drain. + hit_key = f"k-{_MAX_SEEN_LOGS}" + assert _mark_seen(seen, hit_key) is False + assert len(seen) == _MAX_SEEN_LOGS + assert hit_key in seen + for i in range(drift): + assert f"k-{i}" not in seen + + +def test_seen_logs_is_bounded() -> None: + """``_seen_logs`` stays at the cap and evicts oldest-first (FIFO).""" + _logger._seen_logs.clear() + overflow = 5 + with patch("zeroconf._logger.log.warning"), patch("zeroconf._logger.log.debug"): + for i in range(_MAX_SEEN_LOGS + overflow): + QuietLogger.log_warning_once(f"warning-{i}") + assert len(_logger._seen_logs) == _MAX_SEEN_LOGS + for i in range(overflow): + assert f"warning-{i}" not in _logger._seen_logs + for i in range(_MAX_SEEN_LOGS, _MAX_SEEN_LOGS + overflow): + assert f"warning-{i}" in _logger._seen_logs + + def test_log_exception_once(): """Test we only log with warning level once.""" - QuietLogger._seen_logs = {} + _logger._seen_logs.clear() quiet_logger = QuietLogger() exc = Exception() with ( diff --git a/tests/test_protocol.py b/tests/test_protocol.py index bac2b447b..782b77aa0 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -14,6 +14,8 @@ import zeroconf as r from zeroconf import DNSHinfo, DNSIncoming, DNSText, const, current_time_millis +from zeroconf._logger import _MAX_SEEN_LOGS +from zeroconf._protocol import incoming as _incoming_module from . import has_working_ipv6 @@ -962,6 +964,38 @@ def test_dns_compression_generic_failure(caplog): assert "Received invalid packet from ('1.2.3.4', 5353)" in caplog.text +def test_seen_logs_is_bounded(): + """Corrupt packets from varying peers fill ``_seen_logs`` exactly to the cap.""" + packet = ( + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x06domain\x05local\x00\x00\x01" + b"\x80\x01\x00\x00\x00\x01\x00\x04\xc0\xa8\xd0\x05-\x0c\x00\x01\x80\x01\x00\x00" + b"\x00\x01\x00\x04\xc0\xa8\xd0\x06" + ) + overflow = 5 + _incoming_module._seen_logs.clear() + # Snapshot the actual key the parser inserted per port. This is whatever + # ``str(exc_info()[1])`` produces today — the test stays agnostic to the + # exception text format so a future normalization of the message (see + # the discussion on #1714) doesn't break the assertions, while still + # pinning that the parser exception path actually entered the dict. + keys_per_port: list[str] = [] + for port in range(_MAX_SEEN_LOGS + overflow): + r.DNSIncoming(packet, ("1.2.3.4", port)) + keys_per_port.append(next(reversed(_incoming_module._seen_logs))) + # Bound is hit exactly. + assert len(_incoming_module._seen_logs) == _MAX_SEEN_LOGS + # Each port produced a distinct dedup key — a regression that dropped + # the per-packet-varying component (e.g. self.source) from the exception + # text would collapse all 517 calls to one key and fail this. + assert len(set(keys_per_port)) == _MAX_SEEN_LOGS + overflow + # FIFO eviction by key identity (no substring matching on the message + # format): the earliest ports' keys are gone, the latest ports' remain. + for port in range(overflow): + assert keys_per_port[port] not in _incoming_module._seen_logs + for port in range(_MAX_SEEN_LOGS, _MAX_SEEN_LOGS + overflow): + assert keys_per_port[port] in _incoming_module._seen_logs + + def test_label_length_attack(): """Test our wire parser does not loop forever when the name exceeds 253 chars.""" packet = ( From 9683fe6ade7ee080e161fb30c7325024d1d3e02f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 18 May 2026 04:03:37 +0000 Subject: [PATCH 1387/1433] 0.149.6 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01decf02..f96b7991f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.6 (2026-05-18) + +### Bug Fixes + +- Bound _seen_logs and stop retaining exc_info + ([#1717](https://github.com/python-zeroconf/python-zeroconf/pull/1717), + [`95561e2`](https://github.com/python-zeroconf/python-zeroconf/commit/95561e28b24922358f1991e38e3a86d70d72dcec)) + + ## v0.149.5 (2026-05-18) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 7cf822383..0c367f40c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.5" +version = "0.149.6" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c02cb04b9..f7674eb77 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.5" +__version__ = "0.149.6" __license__ = "LGPL" From 0ff3c6b9dd40e01263ce88803139c3ba68349682 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 17 May 2026 21:18:45 -0700 Subject: [PATCH 1388/1433] test: shave ServiceBrowser first-query delay on loopback (#1720) --- tests/__init__.py | 8 +++++--- tests/conftest.py | 14 +++++++++----- tests/services/test_types.py | 8 ++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 9f58de1d5..7f7b2711f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -45,9 +45,11 @@ # Timeout for ZeroconfServiceTypes.find() / AsyncZeroconfServiceTypes.async_find() # in loopback integration tests. `find()` is just `time.sleep(timeout)` — # it doesn't short-circuit on the first matching response — so the -# timeout becomes a lower bound on the test runtime. On loopback the -# registrar's response lands within a few ms; 200ms is ~50x headroom. -LOOPBACK_FIND_TIMEOUT = 0.2 +# timeout becomes a lower bound on the test runtime. Callers MUST use +# the `quick_timing` fixture, which shrinks the browser's first-query +# delay from RFC 6762 §5.2's 20-120ms window to 1-5ms; with that shave +# the registrar's response lands inside ~10ms and 75ms is ~7x headroom. +LOOPBACK_FIND_TIMEOUT = 0.075 class QuestionHistoryWithoutSuppression(QuestionHistory): diff --git a/tests/conftest.py b/tests/conftest.py index 65569d654..31c2a17bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from zeroconf import _core, const from zeroconf._handlers import query_handler +from zeroconf._services import browser as service_browser from zeroconf._services import info as service_info @@ -46,18 +47,21 @@ def disable_duplicate_packet_suppression(): @pytest.fixture def quick_timing() -> Generator[None]: - """Shorten the probe/announce/goodbye intervals for tests on loopback. + """Shorten the probe/announce/goodbye/first-query intervals for tests on loopback. The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms, - _UNREGISTER_TIME=125ms) exist for RFC 6762 interop on real - networks. Tests on 127.0.0.1 do not need them and pay 1-2s per - register/unregister cycle without this fixture. Opt in by adding - `quick_timing` to a test's argument list. + _UNREGISTER_TIME=125ms, _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms) + exist for RFC 6762 interop on real networks (§8.1 thundering-herd + avoidance for probing, §5.2 for the initial-query delay). Tests on + 127.0.0.1 do not need them and pay 1-2s per register/unregister + cycle and 20-120ms per ServiceBrowser startup without this fixture. + Opt in by adding `quick_timing` to a test's argument list. """ with ( patch.object(_core, "_CHECK_TIME", 10), patch.object(_core, "_REGISTER_TIME", 10), patch.object(_core, "_UNREGISTER_TIME", 10), + patch.object(service_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (1, 5)), ): yield diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 69e6d5a6f..7e9b12af0 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -28,7 +28,7 @@ def teardown_module(): log.setLevel(original_logging_level) -def test_integration_with_listener(disable_duplicate_packet_suppression): +def test_integration_with_listener(quick_timing, disable_duplicate_packet_suppression): type_ = "_test-listen-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -59,7 +59,7 @@ def test_integration_with_listener(disable_duplicate_packet_suppression): @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") -def test_integration_with_listener_v6_records(disable_duplicate_packet_suppression): +def test_integration_with_listener_v6_records(quick_timing, disable_duplicate_packet_suppression): type_ = "_test-listenv6rec-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -95,7 +95,7 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi sys.platform == "darwin" and os.environ.get("GITHUB_ACTIONS") == "true", "IPv6 multicast not working on macOS GitHub Actions", ) -def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): +def test_integration_with_listener_ipv6(quick_timing, disable_duplicate_packet_suppression): type_ = "_test-listenv6ip-type._tcp.local." name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -127,7 +127,7 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression): zeroconf_registrar.close() -def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppression): +def test_integration_with_subtype_and_listener(quick_timing, disable_duplicate_packet_suppression): subtype_ = "_subtype._sub" type_ = "_listen._tcp.local." name = "xxxyyy" From 0ad3f37b5b852b8f614d322283d148efb2cef6e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 21:48:40 -0700 Subject: [PATCH 1389/1433] fix: bound DNSCache record count to prevent unbounded LAN-driven growth (#1718) --- src/zeroconf/_cache.pxd | 11 +- src/zeroconf/_cache.py | 79 +++++---- src/zeroconf/const.py | 6 + tests/benchmarks/test_cache_bound.py | 68 ++++++++ tests/test_cache.py | 241 ++++++++++++++++++++++++++- 5 files changed, 374 insertions(+), 31 deletions(-) create mode 100644 tests/benchmarks/test_cache_bound.py diff --git a/src/zeroconf/_cache.pxd b/src/zeroconf/_cache.pxd index 05a40c0f3..023304bc4 100644 --- a/src/zeroconf/_cache.pxd +++ b/src/zeroconf/_cache.pxd @@ -19,6 +19,7 @@ cdef object _UNIQUE_RECORD_TYPES cdef unsigned int _TYPE_PTR cdef cython.uint _ONE_SECOND cdef unsigned int _MIN_SCHEDULED_RECORD_EXPIRATION +cdef unsigned int _MAX_CACHE_RECORDS @cython.locals(record_cache=dict) @@ -31,6 +32,7 @@ cdef class DNSCache: cdef public cython.dict service_cache cdef public list _expire_heap cdef public dict _expirations + cdef public unsigned int _total_records cpdef bint async_add_records(self, object entries) @@ -60,10 +62,17 @@ cdef class DNSCache: service_store=cython.dict, service_record=DNSService, when=object, - new=bint + new=bint, + is_new=bint ) cdef bint _async_add(self, DNSRecord record) + @cython.locals(record=DNSRecord, when_record=tuple) + cdef void _async_evict_oldest(self) + + @cython.locals(expire_heap_len="unsigned int") + cdef void _maybe_rebuild_heap(self) + @cython.locals(service_record=DNSService) cdef void _async_remove(self, DNSRecord record) diff --git a/src/zeroconf/_cache.py b/src/zeroconf/_cache.py index 94af31698..df60982b7 100644 --- a/src/zeroconf/_cache.py +++ b/src/zeroconf/_cache.py @@ -37,7 +37,7 @@ DNSText, ) from ._utils.time import current_time_millis -from .const import _ONE_SECOND, _TYPE_PTR +from .const import _MAX_CACHE_RECORDS, _ONE_SECOND, _TYPE_PTR _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService) _UniqueRecordsType = DNSAddress | DNSHinfo | DNSPointer | DNSText | DNSService @@ -72,6 +72,7 @@ def __init__(self) -> None: self._expire_heap: list[tuple[float, DNSRecord]] = [] self._expirations: dict[DNSRecord, float] = {} self.service_cache: _DNSRecordCacheType = {} + self._total_records: int = 0 # Functions prefixed with async_ are NOT threadsafe and must # be run in the event loop. @@ -89,15 +90,34 @@ def _async_add(self, record: _DNSRecord) -> bool: # replaces any existing records that are __eq__ to each other which # removes the risk that accessing the cache from the wrong # direction would return the old incorrect entry. - if (store := self.cache.get(record.key)) is None: + store = self.cache.get(record.key) + is_new = store is None or record not in store + # Bound total cache size; evict closest-to-expiration entry to + # make room before inserting a new record. Prevents a LAN-local + # flood of unique-name records from growing the cache without + # bound (RFC 6762 §10 advisory caching, defense-in-depth). + if is_new and self._total_records >= _MAX_CACHE_RECORDS: + self._async_evict_oldest() + # The victim may have been the last record under + # ``record.key``, in which case ``_remove_key`` deleted + # the bucket. Re-fetch before creating below. + store = self.cache.get(record.key) + if store is None: store = self.cache[record.key] = {} - new = record not in store and not isinstance(record, DNSNsec) + new = is_new and not isinstance(record, DNSNsec) + if is_new: + self._total_records += 1 store[record] = record when = record.created + (record.ttl * 1000) if self._expirations.get(record) != when: - # Avoid adding duplicates to the heap heappush(self._expire_heap, (when, record)) self._expirations[record] = when + # Re-adds of an existing record with a new TTL push a fresh + # entry but leave the prior tuple behind as stale, so a peer + # that just replays cached records can grow ``_expire_heap`` + # without ever tripping the cap. Rebuild when stale entries + # dominate. + self._maybe_rebuild_heap() if isinstance(record, DNSService): service_record = record @@ -106,6 +126,28 @@ def _async_add(self, record: _DNSRecord) -> bool: service_store[service_record] = service_record return new + def _async_evict_oldest(self) -> None: + """Drop the closest-to-expiration record to make room for a new one.""" + while self._expire_heap: + when_record = heappop(self._expire_heap) + record = when_record[1] + if self._expirations.get(record) != when_record[0]: + continue + self._async_remove(record) + return + + def _maybe_rebuild_heap(self) -> None: + """Rebuild ``_expire_heap`` when stale entries dominate live ones.""" + expire_heap_len = len(self._expire_heap) + if ( + expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION + and expire_heap_len > len(self._expirations) * 2 + ): + self._expire_heap = [ + entry for entry in self._expire_heap if self._expirations.get(entry[1]) == entry[0] + ] + heapify(self._expire_heap) + def async_add_records(self, entries: Iterable[DNSRecord]) -> bool: """Add multiple records. @@ -129,6 +171,7 @@ def _async_remove(self, record: _DNSRecord) -> None: _remove_key(self.service_cache, service_record.server_key, service_record) _remove_key(self.cache, record.key, record) self._expirations.pop(record, None) + self._total_records -= 1 def async_remove_records(self, entries: Iterable[DNSRecord]) -> None: """Remove multiple records. @@ -145,43 +188,23 @@ def async_expire(self, now: _float) -> list[DNSRecord]: :param now: The current time in milliseconds. """ - if not (expire_heap_len := len(self._expire_heap)): + if not self._expire_heap: return [] expired: list[DNSRecord] = [] - # Find any expired records and add them to the to-delete list while self._expire_heap: when_record = self._expire_heap[0] when = when_record[0] if when > now: break heappop(self._expire_heap) - # Check if the record hasn't been re-added to the heap - # with a different expiration time as it will be removed - # later when it reaches the top of the heap and its - # expiration time is met. + # Skip entries left behind by a TTL re-add; the live tuple is + # later in the heap and will be removed when it reaches the top. record = when_record[1] if self._expirations.get(record) == when: expired.append(record) - # If the expiration heap grows larger than the number expirations - # times two, we clean it up to avoid keeping expired entries in - # the heap and consuming memory. We guard this with a minimum - # threshold to avoid cleaning up the heap too often when there are - # only a few scheduled expirations. - if ( - expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION - and expire_heap_len > len(self._expirations) * 2 - ): - # Remove any expired entries from the expiration heap - # that do not match the expiration time in the expirations - # as it means the record has been re-added to the heap - # with a different expiration time. - self._expire_heap = [ - entry for entry in self._expire_heap if self._expirations.get(entry[1]) == entry[0] - ] - heapify(self._expire_heap) - + self._maybe_rebuild_heap() self.async_remove_records(expired) return expired diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 1db39a465..a17e46857 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -59,6 +59,12 @@ # level of rate limit and safe guards so we use 1/4 of the recommended value _DNS_PTR_MIN_TTL = 1125 +# Upper bound on the number of records the DNSCache will hold before it +# starts evicting the closest-to-expiration entry to make room for new +# arrivals. Bounds the memory a malicious LAN peer can force the cache +# to retain by multicasting many unique-name records. +_MAX_CACHE_RECORDS = 10000 + _DNS_PACKET_HEADER_LEN = 12 _MAX_MSG_TYPICAL = 1460 # unused diff --git a/tests/benchmarks/test_cache_bound.py b/tests/benchmarks/test_cache_bound.py new file mode 100644 index 000000000..774129e3b --- /dev/null +++ b/tests/benchmarks/test_cache_bound.py @@ -0,0 +1,68 @@ +"""Benchmark for the DNSCache record-count bound + overflow eviction.""" + +from __future__ import annotations + +from collections.abc import Iterator +from itertools import count + +from pytest_codspeed import BenchmarkFixture + +from zeroconf import DNSAddress, DNSCache, current_time_millis +from zeroconf.const import _CLASS_IN, _MAX_CACHE_RECORDS, _TYPE_A + + +def _make_records(count_: int, now: float, prefix: str = "bench") -> list[DNSAddress]: + return [ + DNSAddress( + f"{prefix}-{i}.local.", + _TYPE_A, + _CLASS_IN, + 120, + bytes(((i >> 24) & 0xFF, (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF)), + created=now + i, + ) + for i in range(count_) + ] + + +def _unbounded_records(now: float, prefix: str = "evict") -> Iterator[DNSAddress]: + """Unbounded generator of unique-name DNSAddress records.""" + for i in count(): + yield DNSAddress( + f"{prefix}-{i}.local.", + _TYPE_A, + _CLASS_IN, + 120, + bytes(((i >> 24) & 0xFF, (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF)), + created=now + i, + ) + + +def test_cache_add_below_cap(benchmark: BenchmarkFixture) -> None: + """Adding records while the cache is well below the cap (no eviction).""" + now = current_time_millis() + records = _make_records(1000, now) + + @benchmark + def _add() -> None: + cache = DNSCache() + cache.async_add_records(records) + + +def test_cache_add_at_cap_evicts(benchmark: BenchmarkFixture) -> None: + """Steady-state add at the cap: every measured insert forces one eviction. + + Pre-fills the cache to ``_MAX_CACHE_RECORDS`` outside the timed body so + only the eviction-path adds are measured. Each benchmark iteration + pulls one fresh unique record from an unbounded generator, keeping the + cache permanently at the cap. The generator avoids the iteration-count + cap that a pre-built pool would impose for very fast operations. + """ + now = current_time_millis() + cache = DNSCache() + cache.async_add_records(_make_records(_MAX_CACHE_RECORDS, now, prefix="fill")) + pool = _unbounded_records(now + _MAX_CACHE_RECORDS) + + @benchmark + def _evict_one() -> None: + cache.async_add_records([next(pool)]) diff --git a/tests/test_cache.py b/tests/test_cache.py index 9d55435d5..aeb3a2abf 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -439,7 +439,9 @@ async def test_cache_heap_multi_name_cleanup() -> None: ) cache.async_add_records([record]) - assert len(cache._expire_heap) == min_records_to_cleanup + 5 + # ``_async_add`` rebuilds ``_expire_heap`` proactively when stale entries + # dominate (heap > 2x expirations), so the heap is already capped at + # ~one entry per unique record long before ``async_expire`` is called. assert len(cache.async_entries_with_name(name)) == 1 assert len(cache.async_entries_with_name(name2)) == 5 @@ -473,7 +475,8 @@ async def test_cache_heap_pops_order() -> None: ) cache.async_add_records([record]) - assert len(cache._expire_heap) == min_records_to_cleanup + 5 + # ``_async_add`` proactively rebuilds the heap when stale entries dominate, + # so the heap holds only one entry per unique record by this point. assert len(cache.async_entries_with_name(name)) == 1 assert len(cache.async_entries_with_name(name2)) == 5 @@ -482,3 +485,237 @@ async def test_cache_heap_pops_order() -> None: ts, _ = heappop(cache._expire_heap) assert ts >= start_ts start_ts = ts + + +def _addr(name: str, idx: int, *, ttl: int = 120, created: float | None = None) -> r.DNSAddress: + """Build a DNSAddress with idx-derived payload for the bound/eviction tests.""" + return r.DNSAddress( + name, + const._TYPE_A, + const._CLASS_IN, + ttl, + bytes((idx & 0xFF, (idx >> 8) & 0xFF, 0, 1)), + created=r.current_time_millis() if created is None else created, + ) + + +def test_cache_size_is_bounded() -> None: + """A flood of unique-name records is capped at ``_MAX_CACHE_RECORDS``.""" + cache = r.DNSCache() + now = r.current_time_millis() + overflow = 1000 + flood_size = const._MAX_CACHE_RECORDS + overflow + + cache.async_add_records(_addr(f"flood-{i}.local.", i, created=now + i) for i in range(flood_size)) + + total = sum(len(store) for store in cache.cache.values()) + assert total == const._MAX_CACHE_RECORDS + assert cache._total_records == const._MAX_CACHE_RECORDS + # FIFO-ish: the earliest-created records (closest to expiration) get + # evicted first, so the names that remain are from the tail. + for i in range(overflow): + assert f"flood-{i}.local." not in cache.cache + for i in range(flood_size - overflow, flood_size): + assert f"flood-{i}.local." in cache.cache + + +def test_cache_eviction_empty_heap_returns_without_evicting() -> None: + """Eviction tolerates an empty ``_expire_heap`` (invariant-violation safety net).""" + cache = r.DNSCache() + # By the cache invariant every record in ``_total_records`` has a heap + # entry, so eviction should never see an empty heap. Force the broken + # state directly to pin the defensive behaviour: ``_async_evict_oldest`` + # returns without raising and the subsequent insert still lands. Since + # eviction can't free space, the counter is allowed to drift past the + # cap by exactly one — pinned so a future change to the recovery + # semantics (e.g., refusing the add or clamping) fails this test. + cache._total_records = const._MAX_CACHE_RECORDS + cache._expire_heap = [] + cache.async_add_records([_addr("post-empty.local.", 0)]) + assert "post-empty.local." in cache.cache + assert cache._total_records == const._MAX_CACHE_RECORDS + 1 + + +def test_cache_eviction_skips_stale_heap_entries() -> None: + """Eviction skips stale heap entries left by TTL re-adds.""" + cache = r.DNSCache() + now = r.current_time_millis() + cache.async_add_records( + _addr(f"stale-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS) + ) + assert cache._total_records == const._MAX_CACHE_RECORDS + + # Re-add the closest-to-expiration record with a longer TTL; the prior + # ``(when, record)`` tuple stays as stale, eviction must skip it. + victim_name = "stale-0.local." + cache.async_add_records([_addr(victim_name, 0, ttl=7200, created=now)]) + assert cache._total_records == const._MAX_CACHE_RECORDS + + cache.async_add_records([_addr("trigger.local.", 0xFFFF, created=now + const._MAX_CACHE_RECORDS)]) + assert cache._total_records == const._MAX_CACHE_RECORDS + assert victim_name in cache.cache + assert "stale-1.local." not in cache.cache + + +def test_cache_eviction_victim_shares_key_with_new_record() -> None: + """Inserting a record whose key collides with the eviction victim keeps it reachable.""" + cache = r.DNSCache() + now = r.current_time_millis() + cache.async_add_records( + _addr(f"filler-{i}.local.", i, created=now + 1000 + i) for i in range(const._MAX_CACHE_RECORDS - 1) + ) + + # Insert at "shared.local." with the earliest expiration so eviction + # picks it. ``_remove_key`` then deletes ``cache["shared.local."]``. + shared_key = "shared.local." + cache.async_add_records([_addr(shared_key, 0x0102, created=now)]) + assert cache._total_records == const._MAX_CACHE_RECORDS + + # Adding a new record under the SAME key: a pre-eviction-captured + # ``store`` would write into an orphaned dict; the fix re-resolves. + new_shared = _addr(shared_key, 0x0506, created=now + 999) + cache.async_add_records([new_shared]) + + assert shared_key in cache.cache, "new record orphaned: cache bucket missing" + assert new_shared in cache.cache[shared_key] + assert cache.async_get_unique(new_shared) == new_shared + total = sum(len(store) for store in cache.cache.values()) + assert total == cache._total_records + + +def test_cache_dnsnsec_at_cap_evicts_prior_record() -> None: + """A single DNSNsec arriving at the cap evicts one prior record and stays reachable.""" + cache = r.DNSCache() + now = r.current_time_millis() + cache.async_add_records( + _addr(f"fill-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS) + ) + assert cache._total_records == const._MAX_CACHE_RECORDS + + nsec = r.DNSNsec( + "nsec-arrival.local.", + const._TYPE_NSEC, + const._CLASS_IN, + 120, + "nsec-arrival.local.", + [const._TYPE_A], + ) + cache.async_add_records([nsec]) + + assert cache._total_records == const._MAX_CACHE_RECORDS + assert nsec in cache.cache[nsec.key] + # The earliest-created fill record is gone (FIFO-ish eviction). + assert "fill-0.local." not in cache.cache + + +def test_cache_dnsnsec_flood_is_bounded() -> None: + """DNSNsec records honour ``_MAX_CACHE_RECORDS`` (no bypass via the ``new`` flag).""" + cache = r.DNSCache() + overflow = 100 + cache.async_add_records( + r.DNSNsec( + f"nsec-{i}.local.", + const._TYPE_NSEC, + const._CLASS_IN, + 120, + f"nsec-{i}.local.", + [const._TYPE_A], + ) + for i in range(const._MAX_CACHE_RECORDS + overflow) + ) + assert cache._total_records == const._MAX_CACHE_RECORDS + total = sum(len(store) for store in cache.cache.values()) + assert total == const._MAX_CACHE_RECORDS + + +def test_cache_re_add_flood_does_not_grow_heap_unbounded() -> None: + """Replaying cached records with shifting TTLs cannot grow ``_expire_heap`` unbounded.""" + cache = r.DNSCache() + now = r.current_time_millis() + # Stay below the cache cap so eviction never fires; the attack here is + # heap growth via re-add, not cap saturation. Clear the + # ``_MIN_SCHEDULED_RECORD_EXPIRATION`` floor so the rebuild engages. + record_count = 200 + cache.async_add_records(_addr(f"flood-{i}.local.", i, created=now) for i in range(record_count)) + assert cache._total_records == record_count + + # 10 cycles x ``record_count`` stale pushes each. Without + # ``_maybe_rebuild_heap`` firing inside ``_async_add``, the heap would + # grow to ~11 x record_count. + for cycle in range(10): + cache.async_add_records( + _addr(f"flood-{i}.local.", i, ttl=7200 + cycle, created=now) for i in range(record_count) + ) + + # Heap is bounded near the rebuild threshold; ``+ record_count`` of slack + # to stay resilient to where in a re-add cycle the rebuild last fired. + assert len(cache._expire_heap) <= 2 * len(cache._expirations) + record_count + assert cache._total_records == record_count + + +def test_cache_eviction_decrements_total_records() -> None: + """Natural removal (goodbyes, expirations) keeps ``_total_records`` in sync.""" + cache = r.DNSCache() + now = r.current_time_millis() + records = [_addr(f"sync-{i}.local.", i, created=now) for i in range(50)] + cache.async_add_records(records) + assert cache._total_records == 50 + + cache.async_remove_records(records[:20]) + assert cache._total_records == 30 + + cache.async_expire(now + (200 * 1000)) + assert cache._total_records == 0 + assert not cache.cache + + +def test_cache_total_records_invariant_under_mixed_ops() -> None: + """``_total_records`` stays equal to the sum of bucket sizes across all touched paths.""" + cache = r.DNSCache() + now = r.current_time_millis() + + def actual() -> int: + return sum(len(store) for store in cache.cache.values()) + + addrs = [_addr(f"mix-{i}.local.", i, created=now + i) for i in range(20)] + cache.async_add_records(addrs) + assert cache._total_records == actual() == 20 + + # Re-add of an identical record: no increment. + cache.async_add_records([addrs[0]]) + assert cache._total_records == actual() == 20 + + # DNSService writes service_cache too — counter still matches cache size. + svc = r.DNSService("svc.local.", const._TYPE_SRV, const._CLASS_IN, 120, 0, 0, 80, "host.local.") + cache.async_add_records([svc]) + assert cache._total_records == actual() == 21 + cache.async_remove_records([svc]) + assert cache._total_records == actual() == 20 + + # DNSNsec is stored but excluded from the "new" return; counter tracks it anyway. + nsec = r.DNSNsec("nsec.local.", const._TYPE_NSEC, const._CLASS_IN, 120, "nsec.local.", [const._TYPE_A]) + cache.async_add_records([nsec]) + assert cache._total_records == actual() == 21 + cache.async_remove_records([nsec]) + assert cache._total_records == actual() == 20 + + # Shared-key insert/remove: emptying the bucket drops the cache key but + # counter decrements only by the records that left. + shared_a = _addr("shared.local.", 0x0101, created=now) + shared_b = _addr("shared.local.", 0x0202, created=now) + cache.async_add_records([shared_a, shared_b]) + assert cache._total_records == actual() == 22 + cache.async_remove_records([shared_a, shared_b]) + assert cache._total_records == actual() == 20 + assert "shared.local." not in cache.cache + + cache.async_expire(now + (200 * 1000)) + assert cache._total_records == actual() == 0 + assert not cache.cache + + # Full-cap eviction loop: counter never grows past the cap, never drifts. + cap_records = [_addr(f"cap-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS + 50)] + for rec in cap_records: + cache.async_add_records([rec]) + assert cache._total_records == actual() + assert cache._total_records == const._MAX_CACHE_RECORDS From e32f52d69d838ec1d2e97856a786691eaa6a4632 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 18 May 2026 04:59:25 +0000 Subject: [PATCH 1390/1433] 0.149.7 Automatically generated by python-semantic-release --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f96b7991f..288cff0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## v0.149.7 (2026-05-18) + +### Bug Fixes + +- Bound DNSCache record count to prevent unbounded LAN-driven growth + ([#1718](https://github.com/python-zeroconf/python-zeroconf/pull/1718), + [`0ad3f37`](https://github.com/python-zeroconf/python-zeroconf/commit/0ad3f37b5b852b8f614d322283d148efb2cef6e4)) + +### Testing + +- Shave ServiceBrowser first-query delay on loopback + ([#1720](https://github.com/python-zeroconf/python-zeroconf/pull/1720), + [`0ff3c6b`](https://github.com/python-zeroconf/python-zeroconf/commit/0ff3c6b9dd40e01263ce88803139c3ba68349682)) + + ## v0.149.6 (2026-05-18) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0c367f40c..2a838b487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.6" +version = "0.149.7" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f7674eb77..f7a1a1626 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.6" +__version__ = "0.149.7" __license__ = "LGPL" From fcd1ffb4af9b3b26bc0ecae30641251a4c9a4ba6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 17 May 2026 22:23:04 -0700 Subject: [PATCH 1391/1433] test: give IPv6-only loopback find() its own timeout (#1721) --- tests/__init__.py | 8 ++++++++ tests/services/test_types.py | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 7f7b2711f..4ce5f77e8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -51,6 +51,14 @@ # the registrar's response lands inside ~10ms and 75ms is ~7x headroom. LOOPBACK_FIND_TIMEOUT = 0.075 +# IPv6-only `find()` on Linux GitHub runners can hit `[Errno 101] Network +# is unreachable` on the `::1` socket and falls back to the `fe80::` link- +# local interface, which adds latency the IPv4 loopback path never pays. +# PyPy widens that further with JIT warmup. The 75ms budget that works on +# IPv4 loopback is too tight for the V6Only path under those conditions +# — give it more headroom. +IPV6_LOOPBACK_FIND_TIMEOUT = 0.5 + class QuestionHistoryWithoutSuppression(QuestionHistory): def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool: diff --git a/tests/services/test_types.py b/tests/services/test_types.py index 7e9b12af0..6e3fe70cd 100644 --- a/tests/services/test_types.py +++ b/tests/services/test_types.py @@ -11,7 +11,7 @@ import zeroconf as r from zeroconf import ServiceInfo, Zeroconf, ZeroconfServiceTypes -from .. import LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6 +from .. import IPV6_LOOPBACK_FIND_TIMEOUT, LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6 log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -116,11 +116,11 @@ def test_integration_with_listener_ipv6(quick_timing, disable_duplicate_packet_s zeroconf_registrar.registry.async_add(info) try: service_types = ZeroconfServiceTypes.find( - ip_version=r.IPVersion.V6Only, timeout=LOOPBACK_FIND_TIMEOUT + ip_version=r.IPVersion.V6Only, timeout=IPV6_LOOPBACK_FIND_TIMEOUT ) assert type_ in service_types _clear_cache(zeroconf_registrar) - service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT) + service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=IPV6_LOOPBACK_FIND_TIMEOUT) assert type_ in service_types finally: From 9c0e9f3e34f38966ab487cb72362a8e855810002 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 23:26:58 -0700 Subject: [PATCH 1392/1433] chore(deps-dev): bump pytest-codspeed from 4.5.0 to 5.0.2 (#1727) Signed-off-by: dependabot[bot] --- poetry.lock | 157 +++++++++++-------------------------------------- pyproject.toml | 2 +- 2 files changed, 36 insertions(+), 123 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1654a7db3..3e38f9508 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -34,7 +34,7 @@ description = "Backport of asyncio.Runner, a context manager that controls event optional = false python-versions = "<3.11,>=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, @@ -52,86 +52,6 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" - [[package]] name = "charset-normalizer" version = "3.4.1" @@ -419,7 +339,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -633,18 +553,6 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - [[package]] name = "pygments" version = "2.19.1" @@ -707,39 +615,44 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" -version = "4.5.0" +version = "5.0.2" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_codspeed-4.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ddc80dda2018aae3bcac9571d47de26aacd9cfb1764b3a1704fa269474cc83f7"}, - {file = "pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:108ae3fecf8a665f017f2abc92a4d9740c57eb8432436baeb489053787427504"}, - {file = "pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8b7a880f2cac69d167affe5e85d9fc7f21beeb1c7591ef2109fbc0983b806a4"}, - {file = "pytest_codspeed-4.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6da6f26435512110736dd258021bbf7859caf4d2a21c7ed06a86b67a999fac7"}, - {file = "pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be191120b1cb0252b443ef37887c94772bab4ca0c42cad7c15bcbcfcbb656ac4"}, - {file = "pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474730e996d424b17f7301d4b846261cca92d195b9fcb7de38599be9d68ee9ac"}, - {file = "pytest_codspeed-4.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db706a7a4200e8e236c31c77935fedcc0edbf44959ab8c156297909d9e8cfd33"}, - {file = "pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac844078bd8760e7fc66debe1e90b4593dfce15f60f26b334e1137d4902df3a9"}, - {file = "pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ecd52a277a5e5f0013e29084b49f9c5f60026d0585f58b86463cb188df5029"}, - {file = "pytest_codspeed-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcc3309d046082a6e0dbd1d9f2bc5c83b0446c93ff011e3880b47c69bf8042cf"}, - {file = "pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12b49954268ed6828ce5a8d87aff13888946c254bff4ef9472bb4d5ae5272667"}, - {file = "pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbeeb76d98335037670068c0d30319415f896e9c37eca510249b74684b460925"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b73f71e7cb5c83cf5d765d5ca39d08bb1090a9d2d2268496a22ca24b1776e3a"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399e146240a52458aa4b5fc861a88551bc52eb9e2d30c8f8b328ddebc084e4f6"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4b43f59d1c31e7c193567369f767647e466f95126671c90be084c58633544f"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4ef8651294386c032d86070893f8349929280162cf22210dbd488697ce26de21"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca31f5d0e783823a78442d5434382eb32f3885153d1833eb645c92d0c499470b"}, - {file = "pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ddd1a9f2dc0615479b2ba3f445a2e3587ce1316296fc79224700e73db06408"}, - {file = "pytest_codspeed-4.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:550bf00dbe2cbd0ddae1502aeedf1896be3525daa2dc053264efae0e3f7f71b4"}, - {file = "pytest_codspeed-4.5.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba8205a4df6d10ad2fe0095a7c7a081181ae4c63e2a91d34589935a355e9fd55"}, - {file = "pytest_codspeed-4.5.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ae57afb1bbfb56e90f41e6e0df6a93d450dab4577058ec2f978dfec54e93ce"}, - {file = "pytest_codspeed-4.5.0-py3-none-any.whl", hash = "sha256:b19bfb734dcbd47b78022285a6eb9f2bf6331ef1bb8c15c2775058945d5f4ce3"}, - {file = "pytest_codspeed-4.5.0.tar.gz", hash = "sha256:deb6ab9c9b07eba56fcb7b97206c7e48aaff697b6f73a013d8dbe4f62e76afd3"}, + {file = "pytest_codspeed-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbd1e86900e7ebbbf3cdf5a48124412d2b75283ab1378994ac27ba3308e262fc"}, + {file = "pytest_codspeed-5.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d394d0d27ead72d0b00906e3832f4dcb9aadb81887a4f379c534c32c0ab965b7"}, + {file = "pytest_codspeed-5.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ee33ac4c3bd7317b6956c0b6cb250f759e02072bb14fd0324de0df71d5d488f"}, + {file = "pytest_codspeed-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799ca9e54d6958d1b388371d00f928fcc4e1e68427d312348dd413a1bba5e0b"}, + {file = "pytest_codspeed-5.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b42d2aae3ac94192b8843fa7578eae584223bcb6334c50ca9f0e9ebafd40053b"}, + {file = "pytest_codspeed-5.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:793d423dc76fd52b67495318681be18c541a7cfe30432ab2f272cd393422c56b"}, + {file = "pytest_codspeed-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c20756925af58ad9d5b584d66a9b8dc709f9b243e6d8fd377e2a1b5a99bf9229"}, + {file = "pytest_codspeed-5.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6d24532a8fee7018b9a33df51e1a14e27ae6b2b0772e6ad477ce5c561ab06a5"}, + {file = "pytest_codspeed-5.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2c09ec82a2def144816c6ffb311252c6ff0624189b3b5e674d889920b6d926c"}, + {file = "pytest_codspeed-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d223b0fe74625e633c86934a1da3ed1607f694fb3981a598bcfc02811e54808e"}, + {file = "pytest_codspeed-5.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82d3c9db57ccaef5177e1096b4dbbf8f3fde8d25c568e38d31a259474c94e5b4"}, + {file = "pytest_codspeed-5.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0621a458c52e77aa113c8d6e14037b90ce3cb5a8dd10a7656b71641999baef8c"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b87b6a5e3c0e05ea043790aae08791dd6b3e7f487b18ec1bce145a60c78a130"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22332fefae895fc80a36ac8a6d5b314663efcad9e833aed8452388441b95c50f"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dedc9e4542832a3487aedf0b448217f34fdc794676b9e0daeaf408a343322c2b"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0fd7db3e6fb6bd28abbf0059dd54ee6233f5faf5c08597b1e9624821417e8d99"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5ce30d2bcfbeb329b61f3435369720ed122caa1dd898464acbcd7edc63cf04"}, + {file = "pytest_codspeed-5.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:687e5aa0fd101adbfe98f36dc253cd4e3b77d90ad96260e6e7e78bde4319c357"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:a14a6515cd315745b4b5b4739a72b287782c00a35f2927e55c310499b79d6bc2"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3658d3b42a15c6f40fa385629a8a8655dbedadd5d7bb5a01bc342b47f73da252"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d15eaa6ca380d0d7cb5b7b8692f362a8aac3832dff6867a0c7068fb8c7a4ef1"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53473907ee2a7569b5ce6ffbfd2ba1793d284a37ff5c8670ed3149133c3ed37b"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b033d25f40c47733234f29c10629f14d004540c743a5c30718e2aa768d7cbb3"}, + {file = "pytest_codspeed-5.0.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33245c1fd96b1a4299604f6791e7fded376605c140ad778db7032dcd46a74d1c"}, + {file = "pytest_codspeed-5.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:40b12cbf88eb69583d7063a4f5c986a7eed14f750a49764ef39a565ffa33d540"}, + {file = "pytest_codspeed-5.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439cd9d87ad449b7db327724b8fdc4a1ae79090b166b77c4e5e15102a371f6c7"}, + {file = "pytest_codspeed-5.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd07e12c38f6974c969e76d070aba448c92fad66601cda4fd289afa52c81ef13"}, + {file = "pytest_codspeed-5.0.2-py3-none-any.whl", hash = "sha256:a88fcddd08bdb1afe043ac4f992e032baee92c88990a611111e0c00d77927cfe"}, + {file = "pytest_codspeed-5.0.2.tar.gz", hash = "sha256:93fea30b2d7266343dd505a182bdf1eb47f96f5fa2929f1d9aff01d3b60e1589"}, ] [package.dependencies] -cffi = ">=1.17.1" pytest = ">=3.8" rich = ">=13.8.1" @@ -1068,7 +981,7 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] -markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version < \"3.11\""} +markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version == \"3.10\""} [[package]] name = "typing-extensions" @@ -1104,4 +1017,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "f85e2fbeab7883ab30a9affc633e0a8fcde6625fd7746282285efe4d4270da47" +content-hash = "d4472263c7d9d85d8c676f9e20a321d30cea5aada4e33a74d7ae9fca76d4ba00" diff --git a/pyproject.toml b/pyproject.toml index 2a838b487..e8720183f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,7 @@ pytest-asyncio = ">=0.20.3,<1.3.0" cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" -pytest-codspeed = ">=4.5.0,<5.0" +pytest-codspeed = ">=5.0.2,<6.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" From c45d24667ab04ea69a7b903b4601d348f3388060 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:04:13 -0700 Subject: [PATCH 1393/1433] chore(deps-dev): bump sphinx from 7.4.7 to 8.1.3 (#1730) Signed-off-by: dependabot[bot] --- poetry.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3e38f9508..58d2b6a9a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -771,18 +771,18 @@ files = [ [[package]] name = "sphinx" -version = "7.4.7" +version = "8.1.3" description = "Python documentation generator" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, - {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, + {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, + {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, ] [package.dependencies] -alabaster = ">=0.7.14,<0.8.0" +alabaster = ">=0.7.14" babel = ">=2.13" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} docutils = ">=0.20,<0.22" @@ -792,17 +792,17 @@ packaging = ">=23.0" Pygments = ">=2.17" requests = ">=2.30.0" snowballstemmer = ">=2.2" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" +sphinxcontrib-applehelp = ">=1.0.7" +sphinxcontrib-devhelp = ">=1.0.6" +sphinxcontrib-htmlhelp = ">=2.0.6" +sphinxcontrib-jsmath = ">=1.0.1" +sphinxcontrib-qthelp = ">=1.0.6" sphinxcontrib-serializinghtml = ">=1.1.9" tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] +lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] [[package]] From 31194a3526b92459ac75e3e0a94d77fe5da9afa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:04:26 -0700 Subject: [PATCH 1394/1433] chore(deps-dev): bump pytest-asyncio from 1.2.0 to 1.3.0 (#1728) Signed-off-by: dependabot[bot] --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 58d2b6a9a..689cb8541 100644 --- a/poetry.lock +++ b/poetry.lock @@ -594,19 +594,19 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.2.0" +version = "1.3.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, - {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<9" +pytest = ">=8.2,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] @@ -1017,4 +1017,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "d4472263c7d9d85d8c676f9e20a321d30cea5aada4e33a74d7ae9fca76d4ba00" +content-hash = "cf9998a505679233036eb2fbf225f015bbee49415d75e15b1b8dc4442e11f521" diff --git a/pyproject.toml b/pyproject.toml index e8720183f..e8f6aa5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] pytest = ">=7.2,<9.0" pytest-cov = ">=4,<8" -pytest-asyncio = ">=0.20.3,<1.3.0" +pytest-asyncio = ">=1.3.0,<1.4.0" cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" From d2cb664756490098e25479508b83ed563699d9c8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 10:35:12 -0700 Subject: [PATCH 1395/1433] chore(pre-commit.ci): pre-commit autoupdate (#1734) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bdfd9fd34..c1b1dbb97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: repos: - repo: https://github.com/commitizen-tools/commitizen - rev: v4.15.1 + rev: v4.16.2 hooks: - id: commitizen stages: [commit-msg] @@ -40,7 +40,7 @@ repos: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 + rev: v0.15.13 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -54,7 +54,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.0.0 + rev: v2.1.0 hooks: - id: mypy additional_dependencies: [ifaddr] From cf48b4034384dc101fbc90205e27fc74fd225d4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 11:03:00 -0700 Subject: [PATCH 1396/1433] chore(deps-dev): bump pytest from 8.4.2 to 9.0.3 (#1729) --- poetry.lock | 14 +++++++------- pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 689cb8541..c6c0075ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -570,21 +570,21 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} @@ -1017,4 +1017,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "cf9998a505679233036eb2fbf225f015bbee49415d75e15b1b8dc4442e11f521" +content-hash = "d5c5962b015fd229695b0a8024a848e7f11878a3efc36cb9a48c2279f48a9777" diff --git a/pyproject.toml b/pyproject.toml index e8f6aa5a2..0e8d5ae99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ python = "^3.10" ifaddr = ">=0.1.7" [tool.poetry.group.dev.dependencies] -pytest = ">=7.2,<9.0" +pytest = ">=9.0.3,<10.0" pytest-cov = ">=4,<8" pytest-asyncio = ">=1.3.0,<1.4.0" cython = "^3.2.4" From 692a2c4040db70bdb7a8352e756bb844be0b30b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 13:11:47 -0500 Subject: [PATCH 1397/1433] chore(deps-dev): bump the pip group across 1 directory with 3 updates (#1736) Signed-off-by: dependabot[bot] --- poetry.lock | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index c6c0075ec..08dbfd5f0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -350,18 +350,18 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.10" +version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, + {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "ifaddr" @@ -696,25 +696,26 @@ pytest = ">=7.0.0" [[package]] name = "requests" -version = "2.32.4" +version = "2.33.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["docs"] files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, + {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rich" @@ -998,14 +999,14 @@ files = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" -groups = ["docs"] +python-versions = ">=3.10" +groups = ["dev", "docs"] files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] From 228af178657a70dd2e76420a8187345de6ede46f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 11:58:55 -0700 Subject: [PATCH 1398/1433] test: widen safety margin in test_response_aggregation_timings_multiple (#1737) --- tests/test_handlers.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index f14c1e1ad..fe65d6732 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1783,19 +1783,19 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression - # The delay should increase with two packets and - # 900ms is beyond the maximum aggregation delay - # when there is no network protection delay - await asyncio.sleep(0.9) + # The minimum protected send_after is 1000ms + 20ms random; sleep + # well under that so coarse timers on slow runners cannot push the + # send into this window and flake the assertion. + await asyncio.sleep(0.5) calls = send_mock.mock_calls assert len(calls) == 0 # 1000ms (1s network protection delays) - # - 900ms (already slept) + # - 500ms (already slept) # + 120ms (maximum random delay) # + 200ms (maximum protected aggregation delay) # + 20ms (execution time) - await asyncio.sleep(millis_to_seconds(1000 - 900 + 120 + 200 + 20)) + await asyncio.sleep(millis_to_seconds(1000 - 500 + 120 + 200 + 20)) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] From 633c3654c86cc133beefecfdca2323fda8e0d30a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 13:54:18 -0700 Subject: [PATCH 1399/1433] ci: set UV_PYTHON_PREFERENCE to only-managed (#1739) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6db5dbae5..867bbafa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ concurrency: env: POETRY_VIRTUALENVS_IN_PROJECT: "true" + UV_PYTHON_PREFERENCE: only-managed # avoid ancient system Python jobs: lint: From 93c31120ec553e59120ba420a590240902287c04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 13:55:20 -0700 Subject: [PATCH 1400/1433] ci: replace per-commit conventional commits check with pr title check (#1740) --- .claude/skills/pr-workflow/SKILL.md | 54 +++++++++++++++-------------- .github/workflows/ci.yml | 23 +++++------- .pre-commit-config.yaml | 5 --- CLAUDE.md | 22 +++++++----- commitlint.config.mjs | 8 ----- 5 files changed, 50 insertions(+), 62 deletions(-) delete mode 100644 commitlint.config.mjs diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 4e38e7ae9..72bbef708 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -49,28 +49,29 @@ behaviour change that affects packet contents or timing — reviewers shouldn't have to reverse-engineer why a constant moved or a probe interval changed. -## 3. Commit message conventions - -Commit messages are linted by `commitlint` with -`@commitlint/config-conventional`, _and_ by `commitizen` in -pre-commit (`stages: [commit-msg]`). Both must pass. - -- **Conventional Commits prefix is required.** Pick from: - `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, - `ci`, `chore`, `style`, `revert`. The `feat`/`fix`/`perf` - prefixes show up in the release-notes; `chore*` and `ci*` are - excluded by semantic-release (`exclude_commit_patterns` in - `pyproject.toml`), so use those for housekeeping. +## 3. PR title conventions + +PRs are squash-merged, so the PR title becomes the commit on +`master`. Only the PR title is linted (by the `pr-title` CI job +running `amannn/action-semantic-pull-request`); per-commit +messages on the PR branch are not checked. + +- **Conventional Commits prefix is required on the PR title.** + Pick from: `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, + `build`, `ci`, `chore`, `style`, `revert`. The + `feat`/`fix`/`perf` prefixes show up in the release-notes; + `chore*` and `ci*` are excluded by semantic-release + (`exclude_commit_patterns` in `pyproject.toml`), so use those + for housekeeping. - **Imperative-mood subject.** "fix: handle empty answer", not "fix: handled empty answer". -- **No header length cap.** The commitlint config sets - `header-max-length: [0, "always", Infinity]`, so a slightly - longer subject is fine if it earns the space; don't pad. +- **Lowercase first character after the prefix** (enforced by + `subjectPattern: ^(?![A-Z]).+$`). - **No `Co-Authored-By` trailers from automated agents.** -- **One logical change per commit.** Let pre-commit run (ruff +- **One logical change per PR.** Let pre-commit run (ruff lint + format, mypy, flake8, codespell, cython-lint, pyupgrade). If a hook auto-fixes something, re-stage and - re-commit; the commit-msg hook re-runs on the new commit. + re-commit. ## 4. Cython / `.pxd` discipline @@ -101,10 +102,10 @@ Always pass the body via `--body-file`, never `--body "..."` with shell-escaping — Markdown backticks, asterisks, and angle brackets must pass through verbatim. -The PR title should match the commit subject (same Conventional -Commits prefix). If the PR ends up squash-merged, the title -becomes the merged commit message, so it has to satisfy -commitlint on its own. +The PR title is what gets enforced — it becomes the squash-merge +commit subject on `master`, so it has to parse as a Conventional +Commit on its own. Per-commit messages on the branch are not +linted. ## 6. After the PR is open @@ -112,11 +113,12 @@ CI runs three jobs: - `lint` — `pre-commit/action`. If pre-commit passed locally this passes too. -- `commitlint` — `wagoid/commitlint-github-action`. Validates - every commit on the PR; if you amended after pushing, force- - push the branch so the rewritten commits get linted. -- `test` — the full pytest matrix across CPython 3.9–3.14, - 3.14t (free-threaded), and PyPy 3.9 / 3.10, on Linux + macOS + +- `pr-title` — `amannn/action-semantic-pull-request`. Validates + the PR title against Conventional Commits. If it fails, fix + the title in the GitHub UI or with `gh pr edit --title "..."`; + the workflow re-runs on the edit, no push needed. +- `test` — the full pytest matrix across CPython 3.10–3.14, + 3.14t (free-threaded), and PyPy 3.10, on Linux + macOS + Windows. The free-threaded entry is the canary for unguarded shared-state bugs; failures there are often genuine even when the GIL-enabled rows pass. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 867bbafa5..9fef6415a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,20 +25,10 @@ jobs: python-version: "3.12" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 - # Make sure commit messages follow the conventional commits convention: + # Make sure the PR title follows the conventional commits convention: # https://www.conventionalcommits.org - commitlint: - name: Lint Commit Messages - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - with: - fetch-depth: 0 - - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 - - # PRs are squash-merged, so the PR title becomes the commit subject on - # master. Validate it against Conventional Commits so the squashed commit - # stays release-tool-friendly even when individual commits don't. + # PRs are squash-merged, so the PR title becomes the commit on master and + # drives python-semantic-release's version bump. pr-title: name: Lint PR Title runs-on: ubuntu-latest @@ -49,6 +39,12 @@ jobs: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + starts with a lowercase character. test: strategy: @@ -156,7 +152,6 @@ jobs: needs: - test - lint - - commitlint if: ${{ github.repository_owner }} == "python-zeroconf" runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1b1dbb97..1e6df8db7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,11 +8,6 @@ ci: autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" repos: - - repo: https://github.com/commitizen-tools/commitizen - rev: v4.16.2 - hooks: - - id: commitizen - stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/CLAUDE.md b/CLAUDE.md index 696740b59..e64b2207d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,13 +85,17 @@ path. The authoritative list of cythonized modules lives in ## Commit / PR conventions -- **Conventional Commits are enforced.** CI runs commitlint with - `@commitlint/config-conventional`, and pre-commit runs - commitizen on the commit message. The header has no length cap - (`header-max-length = [0, "always", Infinity]`), but the - _type_ prefix is required: `feat:`, `fix:`, `chore:`, `ci:`, - `docs:`, `refactor:`, `test:`, `perf:`, `build:`, etc. - `semantic-release` excludes `chore*` and `ci*` from the +- **Conventional Commits PR title, lowercase subject.** PRs are + squash-merged, so the **PR title** becomes the commit on + `master` and is the only string that has to parse as a + Conventional Commit. The repo enforces this via the `pr-title` + CI job in `ci.yml` using `amannn/action-semantic-pull-request`. + Accepted types: `feat`, `fix`, `chore`, `ci`, `docs`, + `refactor`, `test`, `perf`, `build`, etc. The subject (text + after `type(scope):`) must start lowercase (enforced by + `subjectPattern: ^(?![A-Z]).+$`). Per-commit messages on the + PR branch are **not** linted; they get collapsed at squash- + merge. `semantic-release` excludes `chore*` and `ci*` from the changelog, so use those prefixes for housekeeping and reserve `feat`/`fix`/`perf` for user-visible changes. - **No `Co-Authored-By` trailers from automated agents.** Project @@ -267,8 +271,8 @@ or commit that names the bug class and the affected code path. the resulting wheel will crash at import time. - **Don't add `Co-Authored-By` trailers from automated agents to commits** in this repo. -- **Don't introduce a commit message that violates Conventional - Commits.** The commitlint job will fail the PR. +- **Don't introduce a PR title that violates Conventional + Commits.** The `pr-title` job will fail the PR. - **Don't tighten timings or constants in `const.py` without an RFC citation in the commit message.** mDNS interop with Avahi / Bonjour / Windows hinges on those numbers. diff --git a/commitlint.config.mjs b/commitlint.config.mjs deleted file mode 100644 index deb029abf..000000000 --- a/commitlint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export default { - extends: ["@commitlint/config-conventional"], - rules: { - "header-max-length": [0, "always", Infinity], - "body-max-line-length": [0, "always", Infinity], - "footer-max-line-length": [0, "always", Infinity], - }, -}; From 69246065085fa25f9baf20abbf0eaddcb4a4d88c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 14:34:32 -0700 Subject: [PATCH 1401/1433] test: widen LOOPBACK_FIND_TIMEOUT under PyPy (#1742) --- tests/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 4ce5f77e8..7ba0082fa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio +import platform import socket import time from functools import cache @@ -35,6 +36,8 @@ _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution +_IS_PYPY = platform.python_implementation() == "PyPy" + # get_service_info / async_request timeout for tests using the # `quick_request_timing` fixture. The fixture cuts the initial-query # delay to ~15ms (10ms _LISTENER_TIME + 1-5ms jitter), so 50ms is @@ -49,7 +52,9 @@ # the `quick_timing` fixture, which shrinks the browser's first-query # delay from RFC 6762 §5.2's 20-120ms window to 1-5ms; with that shave # the registrar's response lands inside ~10ms and 75ms is ~7x headroom. -LOOPBACK_FIND_TIMEOUT = 0.075 +# PyPy's JIT is still warming up the first time this path runs early in +# the suite, so the round trip is too slow for 75ms; give it more room. +LOOPBACK_FIND_TIMEOUT = 0.3 if _IS_PYPY else 0.075 # IPv6-only `find()` on Linux GitHub runners can hit `[Errno 101] Network # is unreachable` on the `::1` socket and falls back to the `fe80::` link- From 33fba3387180a8b2f30881e7d46e9f0965220a7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 14:54:05 -0700 Subject: [PATCH 1402/1433] ci: build armv7l wheels on native ARM runners (#1741) --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fef6415a..f48a33421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,53 +225,53 @@ jobs: musl: "musllinux" # qemu is slow, make a single # runner per Python version - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp310 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp311 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp312 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp313 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp314 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "musllinux" pyver: cp314t # qemu is slow, make a single # runner per Python version - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp310 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp311 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp312 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp313 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp314 - - os: ubuntu-latest + - os: ubuntu-24.04-arm qemu: armv7l musl: "" pyver: cp314t From 1d83550c58ed0ea69b611a907cd4bdfcb2eef535 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Tue, 19 May 2026 14:54:19 -0700 Subject: [PATCH 1403/1433] fix: bound NSEC bitmap length against record end (#1731) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- src/zeroconf/_protocol/incoming.pxd | 1 + src/zeroconf/_protocol/incoming.py | 14 +++++ tests/test_protocol.py | 79 +++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/src/zeroconf/_protocol/incoming.pxd b/src/zeroconf/_protocol/incoming.pxd index eface8d16..ac8c6e21f 100644 --- a/src/zeroconf/_protocol/incoming.pxd +++ b/src/zeroconf/_protocol/incoming.pxd @@ -128,6 +128,7 @@ cdef class DNSIncoming: byte="unsigned int", i="unsigned int", bitmap_length="unsigned int", + bitmap_end="unsigned int", ) cdef list _read_bitmap(self, unsigned int end) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index ffbbb59f5..ce854e56f 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -388,9 +388,23 @@ def _read_bitmap(self, end: _int) -> list[int]: offset = self.offset offset_plus_one = offset + 1 offset_plus_two = offset + 2 + # RFC 4034 §4.1.2: each window block is window-number byte + + # bitmap-length byte (1..32) + bitmap. A bitmap_length that walks + # past the record's declared end would otherwise leave self.offset + # pointing inside (or past) the next record header, corrupting + # every subsequent record in the same packet. + if offset_plus_two > end: + raise IncomingDecodeError( + f"NSEC bitmap window header truncated at offset {offset} from {self.source}" + ) window = view[offset] bitmap_length = view[offset_plus_one] bitmap_end = offset_plus_two + bitmap_length + if bitmap_length == 0 or bitmap_length > 32 or bitmap_end > end: + raise IncomingDecodeError( + f"NSEC bitmap length {bitmap_length} invalid or overruns record end " + f"at offset {offset} from {self.source}" + ) for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]): for bit in range(8): if byte & (0x80 >> bit): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 782b77aa0..81421a8b3 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -807,6 +807,85 @@ def test_parse_packet_with_nsec_record(): assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local." +def test_nsec_bitmap_length_overruns_record_end(): + """Reject NSEC bitmap whose declared length runs past the record boundary.""" + # 0 questions, 2 answers. Answer 1 is a malformed NSEC: rdlength=9, but the + # bitmap window claims length=255 — overrunning the record. Answer 2 is a + # PTR that must still parse because the offset for the next record stays + # pinned to the NSEC's declared end. + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x2f\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x09" + b"\xc0\x0c" + b"\x00\xff" + b"\x80\x00\x00\x00\x00" + b"\xc0\x0c" + b"\x00\x0c\x00\x01" + b"\x00\x00\x11\x94" + b"\x00\x02" + b"\xc0\x0c" + ) + parsed = r.DNSIncoming(packet) + answers = parsed.answers() + ptrs = [a for a in answers if isinstance(a, r.DNSPointer)] + assert len(ptrs) == 1 + assert ptrs[0].alias == "test.local." + # The malformed NSEC must not surface — if it did, it would carry rdtypes + # synthesized from bytes past the record boundary. + assert not any(isinstance(a, r.DNSNsec) for a in answers) + + +def test_nsec_bitmap_zero_length_window_rejected(): + """A bitmap window with length=0 violates RFC 4034 §4.1.2 and must be rejected.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x2f\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x04" + b"\xc0\x0c" + b"\x00\x00" + b"\xc0\x0c" + b"\x00\x0c\x00\x01" + b"\x00\x00\x11\x94" + b"\x00\x02" + b"\xc0\x0c" + ) + parsed = r.DNSIncoming(packet) + answers = parsed.answers() + ptrs = [a for a in answers if isinstance(a, r.DNSPointer)] + assert len(ptrs) == 1 + assert not any(isinstance(a, r.DNSNsec) for a in answers) + + +def test_nsec_bitmap_truncated_window_header_rejected(): + """Reject NSEC bitmap with a trailing byte too short to hold a window header.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x2f\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x06" + b"\xc0\x0c" + b"\x00\x01\x80" + b"\xff" + b"\xc0\x0c" + b"\x00\x0c\x00\x01" + b"\x00\x00\x11\x94" + b"\x00\x02" + b"\xc0\x0c" + ) + parsed = r.DNSIncoming(packet) + answers = parsed.answers() + ptrs = [a for a in answers if isinstance(a, r.DNSPointer)] + assert len(ptrs) == 1 + assert ptrs[0].alias == "test.local." + assert not any(isinstance(a, r.DNSNsec) for a in answers) + + def test_records_same_packet_share_fate(): """Test records in the same packet all have the same created time.""" out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) From 69bbcd12b19de846388819e9debfad62e9e880e7 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 19 May 2026 21:59:16 +0000 Subject: [PATCH 1404/1433] 0.149.8 Automatically generated by python-semantic-release --- CHANGELOG.md | 23 +++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288cff0cb..cc14f70df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ +## v0.149.8 (2026-05-19) + +### Bug Fixes + +- Bound NSEC bitmap length against record end + ([#1731](https://github.com/python-zeroconf/python-zeroconf/pull/1731), + [`1d83550`](https://github.com/python-zeroconf/python-zeroconf/commit/1d83550c58ed0ea69b611a907cd4bdfcb2eef535)) + +### Testing + +- Give IPv6-only loopback find() its own timeout + ([#1721](https://github.com/python-zeroconf/python-zeroconf/pull/1721), + [`fcd1ffb`](https://github.com/python-zeroconf/python-zeroconf/commit/fcd1ffb4af9b3b26bc0ecae30641251a4c9a4ba6)) + +- Widen LOOPBACK_FIND_TIMEOUT under PyPy + ([#1742](https://github.com/python-zeroconf/python-zeroconf/pull/1742), + [`6924606`](https://github.com/python-zeroconf/python-zeroconf/commit/69246065085fa25f9baf20abbf0eaddcb4a4d88c)) + +- Widen safety margin in test_response_aggregation_timings_multiple + ([#1737](https://github.com/python-zeroconf/python-zeroconf/pull/1737), + [`228af17`](https://github.com/python-zeroconf/python-zeroconf/commit/228af178657a70dd2e76420a8187345de6ede46f)) + + ## v0.149.7 (2026-05-18) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0e8d5ae99..82aca1ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.7" +version = "0.149.8" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index f7a1a1626..a9d8b3500 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.7" +__version__ = "0.149.8" __license__ = "LGPL" From 0e5e637172ab7991e8e1f13be7e4e5d228ce8b8b Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Tue, 19 May 2026 17:05:55 -0700 Subject: [PATCH 1405/1433] fix: bound QuestionHistory size to prevent LAN-driven OOM (#1733) --- src/zeroconf/_history.pxd | 6 ++++- src/zeroconf/_history.py | 20 +++++++++++++- src/zeroconf/const.py | 6 +++++ tests/test_history.py | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index d1bb7baf0..3105f592d 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -4,13 +4,17 @@ from ._dns cimport DNSQuestion cdef cython.double _DUPLICATE_QUESTION_INTERVAL +cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES cdef class QuestionHistory: - cdef cython.dict _history + cdef public cython.dict _history cpdef void add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers) + @cython.locals(oldest=DNSQuestion, oldest_entry=cython.tuple, oldest_than=double) + cdef void _evict_to_make_room(self, double now) + @cython.locals(than=double, previous_question=cython.tuple, previous_known_answers=cython.set) cpdef bint suppresses(self, DNSQuestion question, double now, cython.set known_answers) diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index 1b6f3fadf..2a8274ee5 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -23,7 +23,7 @@ from __future__ import annotations from ._dns import DNSQuestion, DNSRecord -from .const import _DUPLICATE_QUESTION_INTERVAL +from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES # The QuestionHistory is used to implement Duplicate Question Suppression # https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 @@ -40,6 +40,8 @@ def __init__(self) -> None: def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None: """Remember a question with known answers.""" + if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES: + self._evict_to_make_room(now) self._history[question] = (now, known_answers) def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> bool: @@ -75,3 +77,19 @@ def async_expire(self, now: _float) -> None: def clear(self) -> None: """Clear the history.""" self._history.clear() + + def _evict_to_make_room(self, now: _float) -> None: + """Drop expired or oldest entries when the history is at cap. + + Peeks at the oldest insertion (dict is ordered) — only runs the + full O(n) async_expire sweep if it could actually reclaim + something, else a sustained flood at cap turns each insert into + a wasted scan. Falls back to oldest-first eviction. + """ + oldest = next(iter(self._history)) + oldest_entry = self._history[oldest] + oldest_than = oldest_entry[0] + if now - oldest_than > _DUPLICATE_QUESTION_INTERVAL: + self.async_expire(now) + while len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES: + del self._history[next(iter(self._history))] diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index a17e46857..595d8021d 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -65,6 +65,12 @@ # to retain by multicasting many unique-name records. _MAX_CACHE_RECORDS = 10000 +# Upper bound on the number of entries QuestionHistory will hold between +# the periodic 10s cache-cleanup ticks. Bounds the memory a malicious LAN +# peer can force the duplicate-question-suppression history to retain by +# flooding distinct questions (RFC 6762 §7.3, defense-in-depth). +_MAX_QUESTION_HISTORY_ENTRIES = 10000 + _DNS_PACKET_HEADER_LEN = 12 _MAX_MSG_TYPICAL = 1460 # unused diff --git a/tests/test_history.py b/tests/test_history.py index e9254168e..71743eba3 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -78,3 +78,58 @@ def test_question_expire(): # Verify the question not longer suppressed since the cache has expired assert not history.suppresses(question, now, other_known_answers) + + +def test_question_history_bounded(): + """History keeps a hard cap so a LAN flood cannot grow it without bound.""" + history = QuestionHistory() + now = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + for i in range(cap + 500): + q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, now, answers) + + assert len(history._history) <= cap + + +def test_question_history_evicts_oldest_first(): + """When at cap, the oldest insertion is dropped first.""" + history = QuestionHistory() + now = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + first = r.DNSQuestion("_first._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(first, now, answers) + + # Add `cap` more fresh, non-expired entries — one past the cap — so the + # final insertion forces oldest-first eviction of `first`. + for i in range(cap): + q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, now, answers) + + assert first not in history._history + assert len(history._history) <= cap + + +def test_question_history_opportunistic_expire(): + """Adding past the cap first drops expired entries before evicting fresh ones.""" + history = QuestionHistory() + old = r.current_time_millis() + answers: set[r.DNSRecord] = set() + + cap = const._MAX_QUESTION_HISTORY_ENTRIES + for i in range(cap): + q = r.DNSQuestion(f"_stale{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(q, old, answers) + + # All prior entries are now stale (>999ms old). Adding one more should + # trigger opportunistic expiry rather than evicting only the oldest one. + fresh_now = old + const._DUPLICATE_QUESTION_INTERVAL + 1 + fresh = r.DNSQuestion("_fresh._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + history.add_question_at_time(fresh, fresh_now, answers) + + assert fresh in history._history + assert len(history._history) == 1 From 7f0c476bf202b794a961986fc26bce008d8e86b2 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 00:12:15 +0000 Subject: [PATCH 1406/1433] 0.149.9 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc14f70df..c92f3e19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.9 (2026-05-20) + +### Bug Fixes + +- Bound QuestionHistory size to prevent LAN-driven OOM + ([#1733](https://github.com/python-zeroconf/python-zeroconf/pull/1733), + [`0e5e637`](https://github.com/python-zeroconf/python-zeroconf/commit/0e5e637172ab7991e8e1f13be7e4e5d228ce8b8b)) + + ## v0.149.8 (2026-05-19) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 82aca1ddf..0cfcd1ac2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.8" +version = "0.149.9" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a9d8b3500..37e4a727b 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.8" +__version__ = "0.149.9" __license__ = "LGPL" From 068c3f68aeaaaf085d5fa197f7bff304ab80f847 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Tue, 19 May 2026 19:28:35 -0700 Subject: [PATCH 1407/1433] test: add codspeed benchmarks for listener duplicate-packet dedup (#1744) --- tests/benchmarks/test_listener_dedup.py | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/benchmarks/test_listener_dedup.py diff --git a/tests/benchmarks/test_listener_dedup.py b/tests/benchmarks/test_listener_dedup.py new file mode 100644 index 000000000..b1b500ba2 --- /dev/null +++ b/tests/benchmarks/test_listener_dedup.py @@ -0,0 +1,128 @@ +"""Benchmarks for the listener duplicate-packet suppression hot path. + +These pin the cost of ``AsyncListener._process_datagram_at_time`` under +three packet-stream shapes that exercise the dedup branch differently: + +- ``test_dedup_hit_same_payload`` — N copies of one payload (steady-state + dedup hit). +- ``test_alternating_payloads`` — A, B, A, B, ... The single-slot + remembered-last-packet dedup misses on every packet because each one + differs from its immediate predecessor; a bounded recency window + dedups after the second packet. This is the flood shape from + issue #1724. +- ``test_unique_payloads`` — N distinct payloads (no dedup hit possible + on either implementation). Measures the store/evict overhead on the + miss path. + +Downstream work is held constant across implementations by overriding +``handle_query_or_defer`` on a subclass with a no-op, so the only +remaining variable is the dedup decision itself. +""" + +from __future__ import annotations + +import pytest +from pytest_codspeed import BenchmarkFixture + +from zeroconf import DNSOutgoing, DNSQuestion, const +from zeroconf._listener import AsyncListener +from zeroconf._utils.time import current_time_millis +from zeroconf.asyncio import AsyncZeroconf + + +class _InertListener(AsyncListener): + """AsyncListener that skips response generation. + + The dedup branch is the only piece that diverges between the + single-slot and bounded-window implementations. Stubbing query + handling keeps the per-packet cost outside the dedup branch + constant so the benchmark isolates the change under test. + """ + + def handle_query_or_defer(self, *args: object, **kwargs: object) -> None: # type: ignore[override] + return None + + +def _make_query_packet(name: str) -> bytes: + out = DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + out.add_question(DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN)) + return out.packets()[0] + + +_ITERATIONS = 200 +_ADDRS: tuple[str, int] = ("192.0.2.1", 5353) + + +def _build_listener(aiozc: AsyncZeroconf) -> _InertListener: + zc = aiozc.zeroconf + # A non-empty registry keeps the realistic code path live (the early + # ``has_entries`` exit would otherwise bypass the per-packet work we + # want to measure). Toggling the flag directly avoids the event-loop + # round-trip that ``async_register_service`` would impose. + zc.registry.has_entries = True + listener = _InertListener(zc) + listener.transport = object() # type: ignore[assignment] + return listener + + +@pytest.mark.asyncio +async def test_dedup_hit_same_payload(benchmark: BenchmarkFixture) -> None: + """Steady-state dedup hit: same payload repeated.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + listener = _build_listener(aiozc) + packet = _make_query_packet("a._http._tcp.local.") + data_len = len(packet) + # Prime the dedup state so the first iteration is already a hit. + listener._process_datagram_at_time(False, data_len, current_time_millis(), packet, _ADDRS) + + @benchmark + def _run() -> None: + # Single fresh timestamp keeps every call inside the + # suppression interval so each one is a dedup hit. + t = current_time_millis() + for _ in range(_ITERATIONS): + listener._process_datagram_at_time(False, data_len, t, packet, _ADDRS) + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_alternating_payloads(benchmark: BenchmarkFixture) -> None: + """Flood shape from issue #1724: A, B, A, B, ...""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + listener = _build_listener(aiozc) + packet_a = _make_query_packet("a._http._tcp.local.") + packet_b = _make_query_packet("b._http._tcp.local.") + len_a = len(packet_a) + len_b = len(packet_b) + + @benchmark + def _run() -> None: + t = current_time_millis() + for i in range(_ITERATIONS): + if i & 1: + listener._process_datagram_at_time(False, len_b, t, packet_b, _ADDRS) + else: + listener._process_datagram_at_time(False, len_a, t, packet_a, _ADDRS) + + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_unique_payloads(benchmark: BenchmarkFixture) -> None: + """Stream of distinct payloads — no dedup hit on either implementation.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + listener = _build_listener(aiozc) + packets = [_make_query_packet(f"x{i}._http._tcp.local.") for i in range(_ITERATIONS)] + lengths = [len(p) for p in packets] + + @benchmark + def _run() -> None: + t = current_time_millis() + for packet, data_len in zip(packets, lengths, strict=True): + listener._process_datagram_at_time(False, data_len, t, packet, _ADDRS) + + await aiozc.async_close() From 0e201f781c96ea77b7388f306f54b21101c6833f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 May 2026 21:57:51 -0500 Subject: [PATCH 1408/1433] ci: key venv cache on resolved python patch version (#1745) Co-authored-by: Bluetooth Devices Bot --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f48a33421..2ac9d2125 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,7 @@ jobs: - name: Install poetry run: uv tool install poetry - name: Set up Python + id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: ${{ matrix.python-version }} @@ -96,7 +97,7 @@ jobs: path: | .venv src/zeroconf/**/*.so - key: venv-v1-${{ runner.os }}-py${{ matrix.python-version }}-${{ matrix.extension }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} + key: venv-v1-${{ runner.os }}-py${{ steps.setup-python.outputs.python-version }}-${{ matrix.extension }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} - name: Install Dependencies no cython if: ${{ matrix.extension == 'skip_cython' && steps.cache-venv.outputs.cache-hit != 'true' }} env: @@ -119,6 +120,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: Setup Python 3.13 + id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: 3.13 @@ -135,7 +137,7 @@ jobs: path: | .venv src/zeroconf/**/*.so - key: venv-v1-${{ runner.os }}-benchmark-py3.13-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} + key: venv-v1-${{ runner.os }}-benchmark-py${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/zeroconf/**/*.py', 'src/zeroconf/**/*.pxd') }} - name: Install Dependencies if: steps.cache-venv.outputs.cache-hit != 'true' run: | From 37edde2f5d9688e9d6c6573ee41a7cd25a54111e Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 05:16:14 -0700 Subject: [PATCH 1409/1433] fix: accept uppercase .local. trailer in service_type_name (#1747) --- src/zeroconf/_utils/name.py | 6 ++++-- tests/utils/test_name.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_utils/name.py b/src/zeroconf/_utils/name.py index de35f7afb..165ce99d7 100644 --- a/src/zeroconf/_utils/name.py +++ b/src/zeroconf/_utils/name.py @@ -83,7 +83,9 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis # https://datatracker.ietf.org/doc/html/rfc6763#section-7.2 raise BadTypeInNameException(f"Full name ({type_}) must be > 256 bytes") - if type_.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): + # RFC 1035 §2.3.3 / RFC 6762 §16 — DNS name comparisons are case-insensitive. + type_lower = type_.lower() + if type_lower.endswith((_TCP_PROTOCOL_LOCAL_TRAILER, _NONTCP_PROTOCOL_LOCAL_TRAILER)): remaining = type_[: -len(_TCP_PROTOCOL_LOCAL_TRAILER)].split(".") trailer = type_[-len(_TCP_PROTOCOL_LOCAL_TRAILER) :] has_protocol = True @@ -92,7 +94,7 @@ def service_type_name(type_: str, *, strict: bool = True) -> str: # pylint: dis f"Type '{type_}' must end with " f"'{_TCP_PROTOCOL_LOCAL_TRAILER}' or '{_NONTCP_PROTOCOL_LOCAL_TRAILER}'" ) - elif type_.endswith(_LOCAL_TRAILER): + elif type_lower.endswith(_LOCAL_TRAILER): remaining = type_[: -len(_LOCAL_TRAILER)].split(".") trailer = type_[-len(_LOCAL_TRAILER) + 1 :] has_protocol = False diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 1feb77131..0fdce1691 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -61,6 +61,28 @@ def test_service_type_name_non_strict_compliant_names(instance_name, service_typ assert instance_name_from_service_info(info, strict=False) == instance_name +@pytest.mark.parametrize( + "type_, expected", + ( + ("_http._tcp.LOCAL.", "_http._tcp.LOCAL."), + ("_http._TCP.local.", "_http._TCP.local."), + ("_HTTP._tcp.local.", "_HTTP._tcp.local."), + ("Instance._http._tcp.LOCAL.", "_http._tcp.LOCAL."), + ("_ntp._udp.LOCAL.", "_ntp._udp.LOCAL."), + ("_ntp._UDP.local.", "_ntp._UDP.local."), + ("Instance._ntp._udp.LOCAL.", "_ntp._udp.LOCAL."), + ), +) +def test_service_type_name_uppercase_trailer(type_, expected): + """RFC 1035 §2.3.3 / RFC 6762 §16 — DNS names are case-insensitive.""" + assert nameutils.service_type_name(type_) == expected + + +def test_service_type_name_uppercase_local_non_strict(): + """Non-strict mode accepts uppercase .LOCAL. trailer without a protocol label.""" + assert nameutils.service_type_name("Localhost.LOCAL.", strict=False) == "LOCAL." + + def test_possible_types(): """Test possible types from name.""" assert nameutils.possible_types(".") == set() From a0962385a5079d6204fac7744fee9a9d67233eec Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 05:18:43 -0700 Subject: [PATCH 1410/1433] fix: bound TC-deferral assembly window to first-arrival + max delay (#1732) --- src/zeroconf/_listener.pxd | 6 +- src/zeroconf/_listener.py | 35 +++++++++++- tests/test_core.py | 113 +++++++++++++++++++++++++++---------- 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 4cbc5d007..37dc8828b 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -29,6 +29,7 @@ cdef class AsyncListener: cdef public object sock_description cdef public cython.dict _deferred cdef public cython.dict _timers + cdef public cython.dict _deferred_deadlines @cython.locals(now=double, debug=cython.bint) cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) @@ -38,7 +39,10 @@ cdef class AsyncListener: cdef _cancel_any_timers_for_addr(self, object addr) - @cython.locals(incoming=DNSIncoming, deferred=list) + @cython.locals(deadline=object, fire_at=double) + cdef double _compute_deferred_fire_at(self, object addr, double now, double delay) + + @cython.locals(incoming=DNSIncoming, deferred=list, now=double, delay=double, fire_at=double) cpdef handle_query_or_defer( self, DNSIncoming msg, diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index ed5031698..71b983b42 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -58,6 +58,7 @@ class AsyncListener: __slots__ = ( "_deferred", + "_deferred_deadlines", "_query_handler", "_record_manager", "_registry", @@ -82,6 +83,7 @@ def __init__(self, zc: Zeroconf) -> None: self.sock_description: str | None = None self._deferred: dict[str, list[DNSIncoming]] = {} self._timers: dict[str, asyncio.TimerHandle] = {} + self._deferred_deadlines: dict[str, float] = {} super().__init__() def datagram_received(self, data: _bytes, addrs: tuple[str, int] | tuple[str, int, int, int]) -> None: @@ -203,12 +205,19 @@ def handle_query_or_defer( if incoming.data == msg.data: return deferred.append(msg) - delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) # noqa: S311 loop = self.zc.loop assert loop is not None + now = loop.time() + delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) # noqa: S311 + fire_at = self._compute_deferred_fire_at(addr, now, delay) + if fire_at < 0.0: + # Sentinel: a new reset would push the flush past the + # per-addr reassembly deadline, so leave the existing + # TimerHandle in place rather than re-arming it. + return self._cancel_any_timers_for_addr(addr) self._timers[addr] = loop.call_at( - loop.time() + delay, + fire_at, self._respond_query, None, addr, @@ -217,6 +226,27 @@ def handle_query_or_defer( v6_flow_scope, ) + def _compute_deferred_fire_at(self, addr: _str, now: _float, delay: _float) -> _float: + """Return the bounded call_at time for a TC-deferred flush, or -1.0 to keep the existing timer.""" + # RFC 6762 §18.5 frames the random delay as a fixed reassembly budget + # starting at first arrival, not a sliding heartbeat. + deadline = self._deferred_deadlines.get(addr) + if deadline is None: + deadline = now + millis_to_seconds(_TC_DELAY_RANDOM_INTERVAL[1]) + self._deferred_deadlines[addr] = deadline + fire_at = now + delay + if fire_at >= deadline: + if addr in self._timers: + # Existing timer already fires at or before the deadline; + # signal the caller to leave it alone rather than reset it. + return -1.0 + # First packet for this addr already proposes a fire-time at + # or past the deadline — clamp to the deadline so the flush + # still happens within the reassembly budget. + return deadline + # Within budget: schedule at the proposed fire-time. + return fire_at + def _cancel_any_timers_for_addr(self, addr: _str) -> None: """Cancel any future truncated packet timers for the address.""" if addr in self._timers: @@ -232,6 +262,7 @@ def _respond_query( ) -> None: """Respond to a query and reassemble any truncated deferred packets.""" self._cancel_any_timers_for_addr(addr) + self._deferred_deadlines.pop(addr, None) packets = self._deferred.pop(addr, []) if msg: packets.append(msg) diff --git a/tests/test_core.py b/tests/test_core.py index 16f765d46..0848dfe47 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,7 +20,7 @@ import zeroconf as r from zeroconf import NotRunningException, Zeroconf, const, current_time_millis -from zeroconf._listener import AsyncListener, _WrappedTransport +from zeroconf._listener import _TC_DELAY_RANDOM_INTERVAL, AsyncListener, _WrappedTransport from zeroconf._protocol.incoming import DNSIncoming from zeroconf.asyncio import AsyncZeroconf @@ -699,36 +699,41 @@ def test_tc_bit_defers_last_response_missing(): assert len(packets) == 4 expected_deferred = [] - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer1 = protocol._timers[source_ip] - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer2 = protocol._timers[source_ip] - assert timer1.cancelled() - assert timer2 != timer1 - - # Send the same packet again to similar multi interfaces - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer3 = protocol._timers[source_ip] - assert not timer3.cancelled() - assert timer3 == timer2 - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer4 = protocol._timers[source_ip] - assert timer3.cancelled() - assert timer4 != timer3 + # Pin per-packet delay to the minimum so each successive fire_at lands + # before the deadline established by the first packet — keeps the + # timer-replacement assertions deterministic under bounded TC-deferral. + min_delay_ms = _TC_DELAY_RANDOM_INTERVAL[0] + with patch("zeroconf._listener.random.randint", return_value=min_delay_ms): + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer1 = protocol._timers[source_ip] + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer2 = protocol._timers[source_ip] + assert timer1.cancelled() + assert timer2 != timer1 + + # Send the same packet again to similar multi interfaces + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer3 = protocol._timers[source_ip] + assert not timer3.cancelled() + assert timer3 == timer2 + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer4 = protocol._timers[source_ip] + assert timer3.cancelled() + assert timer4 != timer3 for _ in range(8): time.sleep(0.1) @@ -743,6 +748,52 @@ def test_tc_bit_defers_last_response_missing(): zc.close() +def test_tc_bit_defer_window_is_bounded(): + """TC-deferral assembly window must not slide past first_arrival + max delay.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + _wait_for_start(zc) + type_ = "_boundeddefer._tcp.local." + registration_name = f"knownname.{type_}" + + info = r.ServiceInfo( + type_, + registration_name, + 80, + 0, + 0, + {"path": "/~paulsm/"}, + "ash-2.local.", + addresses=[socket.inet_aton("10.0.1.2")], + ) + zc.registry.async_add(info) + + protocol = zc.engine.protocols[0] + now_ms = r.current_time_millis() + _clear_cache(zc) + source_ip = "203.0.113.99" + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + generated.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN)) + for _ in range(300): + generated.add_answer_at_time(info.dns_pointer(), now_ms) + packets = generated.packets() + assert len(packets) >= 3 + + # Pin the per-packet delay at its maximum so any subsequent reset would + # land past the deadline established by the first packet. + max_delay_ms = _TC_DELAY_RANDOM_INTERVAL[1] + with patch("zeroconf._listener.random.randint", return_value=max_delay_ms): + threadsafe_query(zc, protocol, r.DNSIncoming(packets[0]), source_ip, const._MDNS_PORT, Mock(), ()) + first_when = protocol._timers[source_ip].when() + + for raw in packets[1:-1]: + threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._timers[source_ip].when() <= first_when + + zc.registry.async_remove(info) + zc.close() + + @pytest.mark.asyncio async def test_open_close_twice_from_async() -> None: """Test we can close twice from a coroutine when using Zeroconf. From a7cefe983a5174dad4eaa163e762300f0910d49b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 12:22:06 +0000 Subject: [PATCH 1411/1433] 0.149.10 Automatically generated by python-semantic-release --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92f3e19f..4051e10f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ +## v0.149.10 (2026-05-20) + +### Bug Fixes + +- Accept uppercase .local. trailer in service_type_name + ([#1747](https://github.com/python-zeroconf/python-zeroconf/pull/1747), + [`37edde2`](https://github.com/python-zeroconf/python-zeroconf/commit/37edde2f5d9688e9d6c6573ee41a7cd25a54111e)) + +- Bound TC-deferral assembly window to first-arrival + max delay + ([#1732](https://github.com/python-zeroconf/python-zeroconf/pull/1732), + [`a096238`](https://github.com/python-zeroconf/python-zeroconf/commit/a0962385a5079d6204fac7744fee9a9d67233eec)) + +### Testing + +- Add codspeed benchmarks for listener duplicate-packet dedup + ([#1744](https://github.com/python-zeroconf/python-zeroconf/pull/1744), + [`068c3f6`](https://github.com/python-zeroconf/python-zeroconf/commit/068c3f68aeaaaf085d5fa197f7bff304ab80f847)) + + ## v0.149.9 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 0cfcd1ac2..fefef4ab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.9" +version = "0.149.10" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 37e4a727b..a527f458e 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.9" +__version__ = "0.149.10" __license__ = "LGPL" From 304fae6737df7c29c961dc7b653d9b0cf51252b8 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 05:45:50 -0700 Subject: [PATCH 1412/1433] chore: enable ruff PT006/PT007 parametrize tuple rules (#1749) --- pyproject.toml | 2 -- tests/utils/test_name.py | 12 ++++++------ tests/utils/test_net.py | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fefef4ab5..938a6f583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,9 +161,7 @@ select = [ "SLF001", "PLR2004", # too many to fix right now "PT011", # too many to fix right now - "PT006", # too many to fix right now "PGH003", # too many to fix right now - "PT007", # too many to fix right now "PT027", # too many to fix right now "PLW0603" , # too many to fix right now "PLR0915", # too many to fix right now diff --git a/tests/utils/test_name.py b/tests/utils/test_name.py index 0fdce1691..31830fae3 100644 --- a/tests/utils/test_name.py +++ b/tests/utils/test_name.py @@ -28,11 +28,11 @@ def test_service_type_name_overlong_full_name(): @pytest.mark.parametrize( - "instance_name, service_type", - ( + ("instance_name", "service_type"), + [ ("CustomerInformationService-F4D4885E9EEB", "_ibisip_http._tcp.local."), ("DeviceManagementService_F4D4885E9EEB", "_ibisip_http._tcp.local."), - ), + ], ) def test_service_type_name_non_strict_compliant_names(instance_name, service_type): """Test service_type_name for valid names, but not strict-compliant.""" @@ -62,8 +62,8 @@ def test_service_type_name_non_strict_compliant_names(instance_name, service_typ @pytest.mark.parametrize( - "type_, expected", - ( + ("type_", "expected"), + [ ("_http._tcp.LOCAL.", "_http._tcp.LOCAL."), ("_http._TCP.local.", "_http._TCP.local."), ("_HTTP._tcp.local.", "_HTTP._tcp.local."), @@ -71,7 +71,7 @@ def test_service_type_name_non_strict_compliant_names(instance_name, service_typ ("_ntp._udp.LOCAL.", "_ntp._udp.LOCAL."), ("_ntp._UDP.local.", "_ntp._UDP.local."), ("Instance._ntp._udp.LOCAL.", "_ntp._udp.LOCAL."), - ), + ], ) def test_service_type_name_uppercase_trailer(type_, expected): """RFC 1035 §2.3.3 / RFC 6762 §16 — DNS names are case-insensitive.""" diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index e55a8cb47..311e95e6e 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -131,7 +131,7 @@ def test_normalize_interface_choice_errors(): @pytest.mark.parametrize( - "errno,expected_result", + ("errno", "expected_result"), [ (errno.EADDRINUSE, False), (errno.EADDRNOTAVAIL, False), From 8c9d6ce0ccdb8854d14606c93f3790482363e1b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 08:25:51 -0500 Subject: [PATCH 1413/1433] fix: bound duplicate-packet dedup against alternating-payload floods (#1750) --- src/zeroconf/_listener.pxd | 4 +- src/zeroconf/_listener.py | 43 ++++++++- src/zeroconf/const.py | 6 ++ tests/__init__.py | 8 ++ tests/test_handlers.py | 12 ++- tests/test_listener.py | 180 ++++++++++++++++++++++++++++++++++--- 6 files changed, 233 insertions(+), 20 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 37dc8828b..e6c778329 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -14,6 +14,7 @@ cdef bint TYPE_CHECKING cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL +cdef cython.uint _RECENT_PACKETS_MAX cdef class AsyncListener: @@ -30,11 +31,12 @@ cdef class AsyncListener: cdef public cython.dict _deferred cdef public cython.dict _timers cdef public cython.dict _deferred_deadlines + cdef public cython.dict _recent_packets @cython.locals(now=double, debug=cython.bint) cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs) - @cython.locals(msg=DNSIncoming) + @cython.locals(msg=DNSIncoming, recent_time=double) cpdef _process_datagram_at_time(self, bint debug, cython.uint data_len, double now, bytes data, cython.tuple addrs) cdef _cancel_any_timers_for_addr(self, object addr) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 71b983b42..826834b74 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -32,7 +32,7 @@ from ._protocol.incoming import DNSIncoming from ._transport import _WrappedTransport, make_wrapped_transport from ._utils.time import current_time_millis, millis_to_seconds -from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE +from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE, _RECENT_PACKETS_MAX if TYPE_CHECKING: from ._core import Zeroconf @@ -60,6 +60,7 @@ class AsyncListener: "_deferred", "_deferred_deadlines", "_query_handler", + "_recent_packets", "_record_manager", "_registry", "_timers", @@ -84,6 +85,12 @@ def __init__(self, zc: Zeroconf) -> None: self._deferred: dict[str, list[DNSIncoming]] = {} self._timers: dict[str, asyncio.TimerHandle] = {} self._deferred_deadlines: dict[str, float] = {} + # Bounded recency window so an alternating (A, B, A, B, ...) + # flood cannot defeat single-slot dedup; relies on dict insertion + # order so the oldest entry is evicted first. Only payloads + # without a QU question are cached so unicast replies still go + # out on every receipt. + self._recent_packets: dict[bytes, float] = {} super().__init__() def datagram_received(self, data: _bytes, addrs: tuple[str, int] | tuple[str, int, int, int]) -> None: @@ -130,6 +137,31 @@ def _process_datagram_at_time( ) return + # `get(data, -1e30)` keeps the suppression compare a single C + # double compare; the sentinel is far below any real `now - + # _DUPLICATE_PACKET_SUPPRESSION_INTERVAL` so a cache miss never + # triggers the branch even when `now` is small (time.monotonic + # is allowed to start near zero, leaving `now - INTERVAL` + # negative for the first ~1s after boot). Only non-QU payloads + # are cached, so any hit here is safe to suppress without re- + # checking has_qu_question. + recent_time = self._recent_packets.get(data, -1e30) + if (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent_time: + # No timestamp refresh on hit so the suppression window + # ends at first-observation + interval; one parse-and- + # dispatch fires per payload per interval, capping the + # CPU cost under a sustained alternating flood. + if debug: + log.debug( + "Ignoring duplicate message with no unicast questions" + " received from %s [socket %s] (%d bytes) as [%r]", + addrs, + self.sock_description, + data_len, + data, + ) + return + if len(addrs) == 2: v6_flow_scope: tuple[()] | tuple[int, int] = () # https://github.com/python/mypy/issues/1178 @@ -150,6 +182,15 @@ def _process_datagram_at_time( self.data = data self.last_time = now self.last_message = msg + if not msg.has_qu_question(): + # Refresh LRU position when an entry exists but the + # suppression window has expired; otherwise evict the oldest + # entry once the window is full. + if data in self._recent_packets: + del self._recent_packets[data] + elif len(self._recent_packets) >= _RECENT_PACKETS_MAX: + del self._recent_packets[next(iter(self._recent_packets))] + self._recent_packets[data] = now if msg.valid is True: if debug: log.debug( diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index 595d8021d..cfe45e19b 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -33,6 +33,12 @@ _LISTENER_TIME = 200 # ms _BROWSER_TIME = 10000 # ms _DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 1000 # ms +# Per-listener bounded recency window. 16 is large enough to defeat +# the alternating-payload bypass (RFC 6762 §6.2, issue #1724 — even a +# rotation of a dozen distinct payloads still dedups), and small +# enough that the dict bookkeeping per miss stays cheap under a +# hostile flood. +_RECENT_PACKETS_MAX = 16 _DUPLICATE_QUESTION_INTERVAL = 999 # ms # Must be 1ms less than _DUPLICATE_PACKET_SUPPRESSION_INTERVAL _CACHE_CLEANUP_INTERVAL = 10 # s _LOADED_SYSTEM_TIMEOUT = 10 # s diff --git a/tests/__init__.py b/tests/__init__.py index 7ba0082fa..d2af58898 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -118,6 +118,14 @@ def has_working_ipv6(): def _clear_cache(zc: Zeroconf) -> None: zc.cache.cache.clear() zc.question_history.clear() + # Reset per-listener dedup state so identical packets sent in the + # next phase of the test are not suppressed by the bounded recency + # window populated during the previous phase. + if zc.engine is not None: + for protocol in zc.engine.protocols: + protocol._recent_packets.clear() + protocol.data = None + protocol.last_time = 0 def _backdate_cache(zc: Zeroconf, ms: int = 1100) -> None: diff --git a/tests/test_handlers.py b/tests/test_handlers.py index fe65d6732..ca5bcabb5 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1758,7 +1758,8 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli with patch.object(aiozc.zeroconf, "async_send") as send_mock: send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.last_time = 0 # manually reset to avoid duplicate packet suppression + protocol._recent_packets.clear() await asyncio.sleep(0.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1769,7 +1770,8 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.last_time = 0 # manually reset to avoid duplicate packet suppression + protocol._recent_packets.clear() await asyncio.sleep(1.2) calls = send_mock.mock_calls assert len(calls) == 1 @@ -1780,9 +1782,11 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli send_mock.reset_mock() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.last_time = 0 # manually reset to avoid duplicate packet suppression + protocol._recent_packets.clear() protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression + protocol.last_time = 0 # manually reset to avoid duplicate packet suppression + protocol._recent_packets.clear() # The minimum protected send_after is 1000ms + 20ms random; sleep # well under that so coarse timers on slow runners cannot push the # send into this window and flake the assertion. diff --git a/tests/test_listener.py b/tests/test_listener.py index 4897eabe0..9f076be0f 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -222,7 +222,8 @@ def handle_query_or_defer( _handle_query_or_defer.assert_called_once() _handle_query_or_defer.reset_mock() - # Now call with the different packet and handle_query_or_defer should fire + # Replay the first packet — the recency window remembers more than + # just the most recent payload, so this is a duplicate. listener._process_datagram_at_time( False, len(packet_with_qm_question), @@ -230,7 +231,7 @@ def handle_query_or_defer( packet_with_qm_question, addrs, ) - _handle_query_or_defer.assert_called_once() + _handle_query_or_defer.assert_not_called() _handle_query_or_defer.reset_mock() # Now call with the different packet with qu question and handle_query_or_defer should fire @@ -257,18 +258,8 @@ def handle_query_or_defer( log.setLevel(logging.WARNING) - # Call with the QM packet again - listener._process_datagram_at_time( - False, - len(packet_with_qm_question), - new_time, - packet_with_qm_question, - addrs, - ) - _handle_query_or_defer.assert_called_once() - _handle_query_or_defer.reset_mock() - - # Now call with the same packet again and handle_query_or_defer should not fire + # Replay the QM packet with debug disabled — suppression must hold + # off the debug-log path too. listener._process_datagram_at_time( False, len(packet_with_qm_question), @@ -285,3 +276,164 @@ def handle_query_or_defer( _handle_query_or_defer.reset_mock() zc.close() + + +def test_guard_against_alternating_duplicate_packets() -> None: + """Alternating two distinct payloads must not bypass duplicate suppression.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + zc.registry.async_add( + ServiceInfo( + "_http._tcp.local.", + "Test._http._tcp.local.", + server="Test._http._tcp.local.", + port=4, + ) + ) + zc.question_history = QuestionHistoryWithoutSuppression() + + class SubListener(_listener.AsyncListener): + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _engine._WrappedTransport, + v6_flow_scope: tuple[()] | tuple[int, int] = (), + ) -> None: + super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + + listener = SubListener(zc) + listener.transport = MagicMock() + + query_a = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + query_a.add_question(r.DNSQuestion("a._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN)) + packet_a = query_a.packets()[0] + + query_b = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + query_b.add_question(r.DNSQuestion("b._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN)) + packet_b = query_b.packets()[0] + + assert packet_a != packet_b + + addrs = ("1.2.3.4", 43) + + with patch.object(listener, "handle_query_or_defer") as _handle_query_or_defer: + now = current_time_millis() + + # Prime both payloads. + listener._process_datagram_at_time(False, len(packet_a), now, packet_a, addrs) + listener._process_datagram_at_time(False, len(packet_b), now, packet_b, addrs) + assert _handle_query_or_defer.call_count == 2 + _handle_query_or_defer.reset_mock() + + for _ in range(4): + listener._process_datagram_at_time(False, len(packet_a), now, packet_a, addrs) + listener._process_datagram_at_time(False, len(packet_b), now, packet_b, addrs) + _handle_query_or_defer.assert_not_called() + + zc.close() + + +def test_recent_packets_window_is_bounded() -> None: + """Distinct payloads beyond the recency window evict oldest entries.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + zc.registry.async_add( + ServiceInfo( + "_http._tcp.local.", + "Test._http._tcp.local.", + server="Test._http._tcp.local.", + port=4, + ) + ) + zc.question_history = QuestionHistoryWithoutSuppression() + + class SubListener(_listener.AsyncListener): + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _engine._WrappedTransport, + v6_flow_scope: tuple[()] | tuple[int, int] = (), + ) -> None: + super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + + listener = SubListener(zc) + listener.transport = MagicMock() + + addrs = ("1.2.3.4", 43) + now = current_time_millis() + + packets = [] + for i in range(const._RECENT_PACKETS_MAX + 4): + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + query.add_question(r.DNSQuestion(f"n{i}._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN)) + packets.append(query.packets()[0]) + + with patch.object(listener, "handle_query_or_defer") as _handle_query_or_defer: + for packet in packets: + listener._process_datagram_at_time(False, len(packet), now, packet, addrs) + assert _handle_query_or_defer.call_count == len(packets) + _handle_query_or_defer.reset_mock() + + # The newest _RECENT_PACKETS_MAX entries are still in the + # window; replaying them must be suppressed. Checked before + # replaying the evicted ones below since that would mutate the + # window and could mask an off-by-one in eviction. + kept = packets[-const._RECENT_PACKETS_MAX :] + for packet in kept: + listener._process_datagram_at_time(False, len(packet), now, packet, addrs) + _handle_query_or_defer.assert_not_called() + + # The oldest packets should have been evicted and now replay. + evicted = packets[: len(packets) - const._RECENT_PACKETS_MAX] + for packet in evicted: + listener._process_datagram_at_time(False, len(packet), now, packet, addrs) + assert _handle_query_or_defer.call_count == len(evicted) + + zc.close() + + +def test_recent_packets_miss_with_small_now_is_not_suppressed() -> None: + """A cache miss must not trigger suppression when `now` is below the suppression interval.""" + # time.monotonic() can start near zero on freshly booted systems, so + # `now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL` is negative for the + # first second of process lifetime. A 0.0 default on the recency + # dict would let any negative `now - INTERVAL` satisfy the compare + # and suppress legitimate traffic. + zc = Zeroconf(interfaces=["127.0.0.1"]) + zc.registry.async_add( + ServiceInfo( + "_http._tcp.local.", + "Test._http._tcp.local.", + server="Test._http._tcp.local.", + port=4, + ) + ) + zc.question_history = QuestionHistoryWithoutSuppression() + + class SubListener(_listener.AsyncListener): + def handle_query_or_defer( + self, + msg: DNSIncoming, + addr: str, + port: int, + transport: _engine._WrappedTransport, + v6_flow_scope: tuple[()] | tuple[int, int] = (), + ) -> None: + super().handle_query_or_defer(msg, addr, port, transport, v6_flow_scope) + + listener = SubListener(zc) + listener.transport = MagicMock() + + query = r.DNSOutgoing(const._FLAGS_QR_QUERY, multicast=True) + query.add_question(r.DNSQuestion("a._http._tcp.local.", const._TYPE_PTR, const._CLASS_IN)) + packet = query.packets()[0] + + addrs = ("1.2.3.4", 43) + + with patch.object(listener, "handle_query_or_defer") as _handle_query_or_defer: + listener._process_datagram_at_time(False, len(packet), 0.0, packet, addrs) + _handle_query_or_defer.assert_called_once() + + zc.close() From 6a83ab8d9da0dfb5145114d2113996a59009d268 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 13:31:44 +0000 Subject: [PATCH 1414/1433] 0.149.11 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4051e10f3..b401a7f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.11 (2026-05-20) + +### Bug Fixes + +- Bound duplicate-packet dedup against alternating-payload floods + ([#1750](https://github.com/python-zeroconf/python-zeroconf/pull/1750), + [`8c9d6ce`](https://github.com/python-zeroconf/python-zeroconf/commit/8c9d6ce0ccdb8854d14606c93f3790482363e1b9)) + + ## v0.149.10 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 938a6f583..018c25763 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.10" +version = "0.149.11" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index a527f458e..36ef1dd8d 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.10" +__version__ = "0.149.11" __license__ = "LGPL" From b22c8ff19c66c68907d220a4823c0950f4fa93f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 09:20:31 -0500 Subject: [PATCH 1415/1433] fix: bound TC-deferred queues against spoofed-source flood OOM (#1751) --- src/zeroconf/_listener.pxd | 4 ++ src/zeroconf/_listener.py | 33 ++++++++++++- src/zeroconf/const.py | 14 ++++++ tests/test_core.py | 97 +++++++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index e6c778329..260ba091c 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -15,6 +15,8 @@ cdef bint TYPE_CHECKING cdef cython.uint _MAX_MSG_ABSOLUTE cdef cython.uint _DUPLICATE_PACKET_SUPPRESSION_INTERVAL cdef cython.uint _RECENT_PACKETS_MAX +cdef cython.uint _MAX_DEFERRED_ADDRS +cdef cython.uint _MAX_DEFERRED_PER_ADDR cdef class AsyncListener: @@ -41,6 +43,8 @@ cdef class AsyncListener: cdef _cancel_any_timers_for_addr(self, object addr) + cdef _evict_oldest_deferred(self) + @cython.locals(deadline=object, fire_at=double) cdef double _compute_deferred_fire_at(self, object addr, double now, double delay) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 826834b74..7be2a8281 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -32,7 +32,13 @@ from ._protocol.incoming import DNSIncoming from ._transport import _WrappedTransport, make_wrapped_transport from ._utils.time import current_time_millis, millis_to_seconds -from .const import _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, _MAX_MSG_ABSOLUTE, _RECENT_PACKETS_MAX +from .const import ( + _DUPLICATE_PACKET_SUPPRESSION_INTERVAL, + _MAX_DEFERRED_ADDRS, + _MAX_DEFERRED_PER_ADDR, + _MAX_MSG_ABSOLUTE, + _RECENT_PACKETS_MAX, +) if TYPE_CHECKING: from ._core import Zeroconf @@ -240,7 +246,17 @@ def handle_query_or_defer( self._respond_query(msg, addr, port, transport, v6_flow_scope) return + if addr not in self._deferred and len(self._deferred) >= _MAX_DEFERRED_ADDRS: + # Bound total deferred addrs so a spoofed-source flood + # cannot keep adding distinct entries; evict the oldest + # (insertion-order) entry and discard its in-flight queue. + self._evict_oldest_deferred() + deferred = self._deferred.setdefault(addr, []) + if len(deferred) >= _MAX_DEFERRED_PER_ADDR: + # Bound per-addr queue length; further fragments from the + # same source are dropped until the timer flushes. + return # If we get the same packet we ignore it for incoming in reversed(deferred): if incoming.data == msg.data: @@ -293,6 +309,21 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None: if addr in self._timers: self._timers.pop(addr).cancel() + def _evict_oldest_deferred(self) -> None: + """Discard the oldest deferred addr's reassembly state. + + Used when ``_MAX_DEFERRED_ADDRS`` would be exceeded; the + evicted addr's queue and timer are dropped without firing, so + the bound holds even when an attacker rotates source IPs. + Eviction is FIFO (oldest by first-seen, via dict insertion + order) rather than LRU so an active flooder cannot pin its + slots by re-sending into the same addr. + """ + oldest_addr = next(iter(self._deferred)) + self._cancel_any_timers_for_addr(oldest_addr) + self._deferred_deadlines.pop(oldest_addr, None) + del self._deferred[oldest_addr] + def _respond_query( self, msg: DNSIncoming | None, diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index cfe45e19b..c1c0c0465 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -77,6 +77,20 @@ # flooding distinct questions (RFC 6762 §7.3, defense-in-depth). _MAX_QUESTION_HISTORY_ENTRIES = 10000 +# Per-addr cap on the number of truncated (TC-bit) packets retained for +# RFC 6762 §18.5 reassembly. The spec anticipates only a handful of +# segments per truncated query; 16 is well above legitimate need and +# keeps the per-arrival dedup scan a constant-time cost under a flood. +_MAX_DEFERRED_PER_ADDR = 16 + +# Per-listener cap on the number of distinct addrs with in-flight +# TC-deferral state. Each entry can hold up to _MAX_DEFERRED_PER_ADDR +# packets of up to _MAX_MSG_ABSOLUTE bytes; 512 leaves headroom for a +# legitimate burst (LAN-wide power-resume / boot storm where many +# devices announce at once) while bounding worst-case memory at +# ~72 MB even when a peer floods with spoofed source IPs. +_MAX_DEFERRED_ADDRS = 512 + _DNS_PACKET_HEADER_LEN = 12 _MAX_MSG_TYPICAL = 1460 # unused diff --git a/tests/test_core.py b/tests/test_core.py index 0848dfe47..1bc85b1e1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -19,7 +19,7 @@ import pytest import zeroconf as r -from zeroconf import NotRunningException, Zeroconf, const, current_time_millis +from zeroconf import NotRunningException, Zeroconf, _listener, const, current_time_millis from zeroconf._listener import _TC_DELAY_RANDOM_INTERVAL, AsyncListener, _WrappedTransport from zeroconf._protocol.incoming import DNSIncoming from zeroconf.asyncio import AsyncZeroconf @@ -794,6 +794,101 @@ def test_tc_bit_defer_window_is_bounded(): zc.close() +def _make_distinct_tc_packets(count: int, name_prefix: str = "q") -> list[bytes]: + """Generate ``count`` byte-distinct TC-flagged query packets for flood inputs.""" + packets = [] + for i in range(count): + out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_TC) + out.add_question(r.DNSQuestion(f"{name_prefix}{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)) + packets.append(out.packets()[0]) + return packets + + +def _synthetic_source_ip(i: int) -> str: + """Distinct synthetic source IPs from the documentation ranges.""" + if i < 256: + return f"203.0.113.{i}" + if i < 512: + return f"198.51.100.{i - 256}" + return f"192.0.2.{i - 512}" + + +def test_tc_bit_per_addr_queue_is_bounded(quick_timing: None) -> None: + """Per-addr deferred queue must not grow past ``_MAX_DEFERRED_PER_ADDR``.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + _wait_for_start(zc) + protocol = zc.engine.protocols[0] + source_ip = "203.0.113.21" + + extra = 4 + packets = _make_distinct_tc_packets(const._MAX_DEFERRED_PER_ADDR + extra) + + # Push the reassembly timer well past any possible test runtime + # so the bound under test is the only thing that can drop entries. + with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)): + for raw in packets: + threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ()) + + assert len(protocol._deferred[source_ip]) == const._MAX_DEFERRED_PER_ADDR + # Last ``extra`` packets must have been dropped, not displaced; the + # earlier ``_MAX_DEFERRED_PER_ADDR`` entries are the ones retained. + retained = [incoming.data for incoming in protocol._deferred[source_ip]] + assert retained == packets[: const._MAX_DEFERRED_PER_ADDR] + + zc.close() + + +def test_tc_bit_total_addrs_is_bounded(quick_timing: None) -> None: + """Distinct addrs with deferred state must not exceed ``_MAX_DEFERRED_ADDRS``.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + _wait_for_start(zc) + protocol = zc.engine.protocols[0] + + raw = _make_distinct_tc_packets(1)[0] + extra = 4 + addrs = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS + extra)] + + # Push the reassembly timer well past any possible test runtime + # so the bound under test is the only thing that can drop entries; + # without this, PyPy / slow runners can fire timers between the + # last enqueue and the assertion. + with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)): + for source_ip in addrs: + threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ()) + + assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS + assert len(protocol._timers) == const._MAX_DEFERRED_ADDRS + + zc.close() + + +def test_tc_bit_eviction_drops_oldest_addr(quick_timing: None) -> None: + """Adding a new addr at capacity drops the oldest insertion (FIFO).""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + _wait_for_start(zc) + protocol = zc.engine.protocols[0] + + raw = _make_distinct_tc_packets(1)[0] + fillers = [_synthetic_source_ip(i) for i in range(const._MAX_DEFERRED_ADDRS)] + new_addr = _synthetic_source_ip(const._MAX_DEFERRED_ADDRS) + oldest = fillers[0] + + with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (60_000, 60_001)): + for source_ip in fillers: + threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ()) + assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS + assert oldest in protocol._deferred + + # One more distinct addr must evict the oldest insertion-order entry. + threadsafe_query(zc, protocol, r.DNSIncoming(raw), new_addr, const._MDNS_PORT, Mock(), ()) + assert oldest not in protocol._deferred + assert oldest not in protocol._timers + assert new_addr in protocol._deferred + assert len(protocol._deferred) == const._MAX_DEFERRED_ADDRS + + zc.close() + + @pytest.mark.asyncio async def test_open_close_twice_from_async() -> None: """Test we can close twice from a coroutine when using Zeroconf. From 4ff65407bdc097f73a8b1f98659572e24d5c0df1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 10:01:12 -0500 Subject: [PATCH 1416/1433] fix: bound QuestionHistory per-entry known-answer payload (#1755) --- src/zeroconf/_history.pxd | 1 + src/zeroconf/_history.py | 15 +++++++++- src/zeroconf/const.py | 15 ++++++++++ tests/test_history.py | 59 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_history.pxd b/src/zeroconf/_history.pxd index 3105f592d..dab257e44 100644 --- a/src/zeroconf/_history.pxd +++ b/src/zeroconf/_history.pxd @@ -5,6 +5,7 @@ from ._dns cimport DNSQuestion cdef cython.double _DUPLICATE_QUESTION_INTERVAL cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES +cdef unsigned int _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY cdef class QuestionHistory: diff --git a/src/zeroconf/_history.py b/src/zeroconf/_history.py index 2a8274ee5..10696f4f5 100644 --- a/src/zeroconf/_history.py +++ b/src/zeroconf/_history.py @@ -23,7 +23,11 @@ from __future__ import annotations from ._dns import DNSQuestion, DNSRecord -from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES +from .const import ( + _DUPLICATE_QUESTION_INTERVAL, + _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY, + _MAX_QUESTION_HISTORY_ENTRIES, +) # The QuestionHistory is used to implement Duplicate Question Suppression # https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 @@ -40,6 +44,15 @@ def __init__(self) -> None: def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None: """Remember a question with known answers.""" + if len(known_answers) > _MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY: + # Refuse to pin an attacker-sized known-answer payload. + # Any pre-existing entry for this question stays in place + # so legitimate suppression continues; the cost is missing + # one round of suppression for this (likely malicious) + # query. Truncating instead would over-suppress because + # `suppresses()` matches when the stored set is a subset + # of the incoming known-answers (smaller set, easier match). + return if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES: self._evict_to_make_room(now) self._history[question] = (now, known_answers) diff --git a/src/zeroconf/const.py b/src/zeroconf/const.py index c1c0c0465..f1b43be59 100644 --- a/src/zeroconf/const.py +++ b/src/zeroconf/const.py @@ -77,6 +77,21 @@ # flooding distinct questions (RFC 6762 §7.3, defense-in-depth). _MAX_QUESTION_HISTORY_ENTRIES = 10000 +# Per-entry cap on the number of known-answer records QuestionHistory +# will retain. Each TC-deferred reassembly can carry up to ~12k records +# (~750 records/packet x _MAX_DEFERRED_PER_ADDR fragments), and the +# resulting set is stored by reference under each non-unicast question +# in the history dict; without a per-entry cap a LAN attacker can pin +# hundreds of MB across the _MAX_QUESTION_HISTORY_ENTRIES dimension. +# 256 is well above any RFC-realistic known-answer list for a single +# question; oversized payloads are dropped from the history (no +# suppression for that one query) rather than truncated, since a +# truncated stored set would over-suppress legitimate follow-up +# queries (`suppresses()` returns True when stored set is a subset of +# the incoming known-answers, so a smaller stored set matches more +# easily). +_MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY = 256 + # Per-addr cap on the number of truncated (TC-bit) packets retained for # RFC 6762 §18.5 reassembly. The spec anticipates only a handful of # segments per truncated query; 16 is well above legitimate need and diff --git a/tests/test_history.py b/tests/test_history.py index 71743eba3..0dd40a06b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -133,3 +133,62 @@ def test_question_history_opportunistic_expire(): assert fresh in history._history assert len(history._history) == 1 + + +def _make_known_answers(count: int) -> set[r.DNSRecord]: + """Build a set of ``count`` distinct PTR records for use as known-answers.""" + return { + r.DNSPointer( + "_svc._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + f"target{i}._svc._tcp.local.", + ) + for i in range(count) + } + + +def test_question_history_oversized_known_answers_dropped(): + """Known-answer sets above the per-entry cap are not stored.""" + history = QuestionHistory() + now = r.current_time_millis() + question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + + oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1) + history.add_question_at_time(question, now, oversized) + + assert question not in history._history + + +def test_question_history_oversized_preserves_existing_entry(): + """An oversized payload must not displace a pre-existing small entry.""" + history = QuestionHistory() + now = r.current_time_millis() + question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + + small = _make_known_answers(2) + history.add_question_at_time(question, now, small) + assert history.suppresses(question, now, small) + + # An oversized follow-up must be ignored; the small entry stays and + # continues to drive suppression. + oversized = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY + 1) + history.add_question_at_time(question, now, oversized) + + stored_set = history._history[question][1] + assert stored_set is small + assert history.suppresses(question, now, small) + + +def test_question_history_at_cap_known_answers_is_stored(): + """A known-answer set exactly at the per-entry cap is retained.""" + history = QuestionHistory() + now = r.current_time_millis() + question = r.DNSQuestion("_svc._tcp.local.", const._TYPE_PTR, const._CLASS_IN) + + at_cap = _make_known_answers(const._MAX_KNOWN_ANSWERS_PER_HISTORY_ENTRY) + history.add_question_at_time(question, now, at_cap) + + assert question in history._history + assert history._history[question][1] is at_cap From f4b506645d7ac4c076bd4f59550b36c607932f39 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 15:07:48 +0000 Subject: [PATCH 1417/1433] 0.149.12 Automatically generated by python-semantic-release --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b401a7f14..219da3bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ +## v0.149.12 (2026-05-20) + +### Bug Fixes + +- Bound QuestionHistory per-entry known-answer payload + ([#1755](https://github.com/python-zeroconf/python-zeroconf/pull/1755), + [`4ff6540`](https://github.com/python-zeroconf/python-zeroconf/commit/4ff65407bdc097f73a8b1f98659572e24d5c0df1)) + +- Bound TC-deferred queues against spoofed-source flood OOM + ([#1751](https://github.com/python-zeroconf/python-zeroconf/pull/1751), + [`b22c8ff`](https://github.com/python-zeroconf/python-zeroconf/commit/b22c8ff19c66c68907d220a4823c0950f4fa93f7)) + + ## v0.149.11 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 018c25763..d1c4eb791 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.11" +version = "0.149.12" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 36ef1dd8d..c6dcba971 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.11" +__version__ = "0.149.12" __license__ = "LGPL" From cb0af4a8f4cdaad3721a2851d9fa17709d39ae62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 10:42:57 -0500 Subject: [PATCH 1418/1433] refactor: extract loopback Zeroconf fixtures and mock_incoming_msg helper (#1758) --- tests/__init__.py | 11 ++++++++- tests/conftest.py | 36 ++++++++++++++++++++++++++-- tests/services/test_browser.py | 9 +------ tests/services/test_info.py | 25 +------------------ tests/test_engine.py | 44 ++++++++++++++-------------------- 5 files changed, 64 insertions(+), 61 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index d2af58898..d68444d0e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,12 +26,13 @@ import platform import socket import time +from collections.abc import Iterable from functools import cache from unittest import mock import ifaddr -from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf +from zeroconf import DNSIncoming, DNSOutgoing, DNSQuestion, DNSRecord, Zeroconf, const from zeroconf._history import QuestionHistory _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution @@ -70,6 +71,14 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRe return False +def mock_incoming_msg(records: Iterable[DNSRecord]) -> DNSIncoming: + """Build a `DNSIncoming` response message from a list of `DNSRecord`s.""" + generated = DNSOutgoing(const._FLAGS_QR_RESPONSE) + for record in records: + generated.add_answer_at_time(record, 0) + return DNSIncoming(generated.packets()[0]) + + def _inject_responses(zc: Zeroconf, msgs: list[DNSIncoming]) -> None: """Inject a DNSIncoming response.""" assert zc.loop is not None diff --git a/tests/conftest.py b/tests/conftest.py index 31c2a17bc..c281e2d47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,15 +3,17 @@ from __future__ import annotations import threading -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import patch import pytest +import pytest_asyncio -from zeroconf import _core, const +from zeroconf import Zeroconf, _core, const from zeroconf._handlers import query_handler from zeroconf._services import browser as service_browser from zeroconf._services import info as service_info +from zeroconf.asyncio import AsyncZeroconf @pytest.fixture(autouse=True) @@ -23,6 +25,36 @@ def verify_threads_ended(): assert not threads +@pytest.fixture +def zc_loopback() -> Generator[Zeroconf]: + """Yield a loopback `Zeroconf` and close it on teardown. + + Replaces the inline `zc = Zeroconf(interfaces=["127.0.0.1"])` + + explicit `zc.close()` pattern duplicated across the suite. Calling + `zc.close()` inside a test is still safe — `close()` is idempotent. + """ + zc = Zeroconf(interfaces=["127.0.0.1"]) + try: + yield zc + finally: + zc.close() + + +@pytest_asyncio.fixture +async def aiozc_loopback() -> AsyncGenerator[AsyncZeroconf]: + """Yield a loopback `AsyncZeroconf` and close it on teardown. + + Replaces the inline `aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])` + + explicit `await aiozc.async_close()` pattern duplicated across the + suite. Calling `async_close()` inside a test is still safe. + """ + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + try: + yield aiozc + finally: + await aiozc.async_close() + + @pytest.fixture def run_isolated(): """Change the mDNS port to run the test in isolation.""" diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 32c122a4e..342b2fc11 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -8,7 +8,6 @@ import socket import time import unittest -from collections.abc import Iterable from threading import Event from typing import cast from unittest.mock import patch @@ -36,6 +35,7 @@ _inject_response, _wait_for_start, has_working_ipv6, + mock_incoming_msg, time_changed_millis, ) @@ -54,13 +54,6 @@ def teardown_module(): log.setLevel(original_logging_level) -def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - - def test_service_browser_cancel_multiple_times(): """Test we can cancel a ServiceBrowser multiple times before close.""" diff --git a/tests/services/test_info.py b/tests/services/test_info.py index dae42afd9..56146852f 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -8,7 +8,6 @@ import socket import threading import unittest -from collections.abc import Iterable from ipaddress import ip_address from threading import Event from unittest.mock import patch @@ -23,7 +22,7 @@ from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf -from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6 +from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6, mock_incoming_msg log = logging.getLogger("zeroconf") original_logging_level = logging.NOTSET @@ -279,14 +278,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send with patch.object(zc, "async_send", send): - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packets()[0]) - def get_service_info_helper(zc, type, name): nonlocal service_info service_info = zc.get_service_info(type, name) @@ -422,14 +413,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send with patch.object(zc, "async_send", send): - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - - for record in records: - generated.add_answer_at_time(record, 0) - - return r.DNSIncoming(generated.packets()[0]) - def get_service_info_helper(zc, type, name, timeout): nonlocal service_info service_info = zc.get_service_info(type, name, timeout) @@ -552,12 +535,6 @@ def test_get_info_single(self): ), ] - def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming: - generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) - for record in records: - generated.add_answer_at_time(record, 0) - return r.DNSIncoming(generated.packets()[0]) - sent_queries: list[r.DNSOutgoing] = [] def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): diff --git a/tests/test_engine.py b/tests/test_engine.py index 750d3393b..66cfa3566 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -74,39 +74,31 @@ async def test_reaper(): @pytest.mark.asyncio -async def test_setup_releases_socket_ownership() -> None: +async def test_setup_releases_socket_ownership(aiozc_loopback: AsyncZeroconf) -> None: """Engine releases its pending-socket refs once each socket has a transport.""" - aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) - try: - await aiozc.zeroconf.async_wait_for_start() - engine = aiozc.zeroconf.engine - assert engine._listen_socket is None - assert engine._respond_sockets == [] - assert engine.readers - assert engine.senders - finally: - await aiozc.async_close() + await aiozc_loopback.zeroconf.async_wait_for_start() + engine = aiozc_loopback.zeroconf.engine + assert engine._listen_socket is None + assert engine._respond_sockets == [] + assert engine.readers + assert engine.senders @pytest.mark.asyncio -async def test_async_close_propagates_outer_cancellation() -> None: +async def test_async_close_propagates_outer_cancellation(aiozc_loopback: AsyncZeroconf) -> None: """Outer-task cancellation while awaiting setup propagates to the caller.""" - aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc_loopback.zeroconf.async_wait_for_start() + engine = aiozc_loopback.zeroconf.engine + loop = asyncio.get_running_loop() + original_task = engine._setup_task + fake_task = loop.create_future() + fake_task.set_exception(asyncio.CancelledError()) + engine._setup_task = fake_task # type: ignore[assignment] try: - await aiozc.zeroconf.async_wait_for_start() - engine = aiozc.zeroconf.engine - loop = asyncio.get_running_loop() - original_task = engine._setup_task - fake_task = loop.create_future() - fake_task.set_exception(asyncio.CancelledError()) - engine._setup_task = fake_task # type: ignore[assignment] - try: - with pytest.raises(asyncio.CancelledError): - await engine._async_close() - finally: - engine._setup_task = original_task + with pytest.raises(asyncio.CancelledError): + await engine._async_close() finally: - await aiozc.async_close() + engine._setup_task = original_task @pytest.mark.asyncio From 28bb01f23951f7883d8c3af66b6d537c34c516c7 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 10:34:56 -0700 Subject: [PATCH 1419/1433] docs: clarify LGPL-2.1-or-later license in README (#1763) --- README.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 70da57ad8..08d48822f 100644 --- a/README.rst +++ b/README.rst @@ -151,4 +151,10 @@ Changelog License ======= -LGPL, see COPYING file for details. +GNU Lesser General Public License v2.1 or later (LGPL-2.1-or-later). + +The full text of LGPL 2.1 is included in the `COPYING `_ file. +You may, at your option, use this library under the terms of any later +version of the LGPL published by the Free Software Foundation. The +canonical SPDX identifier for this project is ``LGPL-2.1-or-later``, as +declared in ``pyproject.toml``. From 544449596e645fcaad3834fa0cb614a54f847a82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 13:59:06 -0500 Subject: [PATCH 1420/1433] fix: bound record payload reads against rdlength overrun (#1756) --- src/zeroconf/_protocol/incoming.py | 28 ++++++++++ tests/test_protocol.py | 83 ++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/zeroconf/_protocol/incoming.py b/src/zeroconf/_protocol/incoming.py index ce854e56f..9ef2631af 100644 --- a/src/zeroconf/_protocol/incoming.py +++ b/src/zeroconf/_protocol/incoming.py @@ -254,12 +254,27 @@ def _read_character_string(self) -> str: """Reads a character string from the packet""" length = self.view[self.offset] self.offset += 1 + # Python slicing silently truncates when indices exceed the buffer, + # but self.offset still advances by the declared length below; without + # this check a record with an inflated character-string length would + # land in the cache carrying a payload shorter than the wire claimed + # and leave the parser pointed past _data_len for the next record. + if self.offset + length > self._data_len: + raise IncomingDecodeError( + f"Character string length {length} at offset {self.offset} overruns " + f"packet of {self._data_len} bytes from {self.source}" + ) info = self.data[self.offset : self.offset + length].decode("utf-8", "replace") self.offset += length return info def _read_string(self, length: _int) -> bytes: """Reads a string of a given length from the packet""" + if self.offset + length > self._data_len: + raise IncomingDecodeError( + f"String length {length} at offset {self.offset} overruns " + f"packet of {self._data_len} bytes from {self.source}" + ) info = self.data[self.offset : self.offset + length] self.offset += length return info @@ -297,6 +312,19 @@ def _read_others(self) -> None: self.data, exc_info=True, ) + if rec is not None and self.offset != end: + # The decoded record consumed a different number of bytes than + # rdlength advertised. The record is built from a slice that + # straddles its rdata boundary, so drop it and resync to the + # declared end so the next record header lands aligned. + log.debug( + "Record for %s with type %s did not consume exactly rdlength=%d; dropping", + domain, + _TYPES.get(type_, type_), + length, + ) + self.offset = end + rec = None if rec is not None: self._answers.append(rec) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 81421a8b3..903c66927 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -886,6 +886,89 @@ def test_nsec_bitmap_truncated_window_header_rejected(): assert not any(isinstance(a, r.DNSNsec) for a in answers) +def test_txt_rdlength_overruns_packet_rejected(): + """A TXT record with rdlength past the buffer must not enter the cache. + + Python slicing silently truncates when the slice end exceeds the buffer, + so without a bounds check in ``_read_string`` a malformed wire frame + would land in the cache carrying a payload shorter than the rdlength + declared, leaving the parser desynchronized for downstream records. + """ + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x10\x80\x01" + b"\x00\x00\x11\x94" + b"\xff\xff" + b"\x05hello" + ) + parsed = r.DNSIncoming(packet) + assert parsed.valid + assert parsed.answers() == [] + + +def test_hinfo_character_string_length_overruns_record_rejected(): + """A HINFO character string declaring more bytes than remain must be rejected.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x0d\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x07" + b"\x03cpu" + b"\xff\xff\xff" + ) + parsed = r.DNSIncoming(packet) + assert parsed.valid + assert not any(isinstance(a, r.DNSHinfo) for a in parsed.answers()) + + +def test_a_record_rdlength_overruns_packet_rejected(): + """An A record whose 4-byte address would walk past the buffer must be rejected.""" + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x01\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x04" + b"\xc0\xa8" + ) + parsed = r.DNSIncoming(packet) + assert parsed.valid + assert not any(isinstance(a, r.DNSAddress) for a in parsed.answers()) + + +def test_record_consuming_more_than_rdlength_dropped_and_resyncs(): + """A record whose decoded fields overrun its rdlength must drop and resync. + + The first answer is a HINFO with ``rdlength=2`` and rdata ``\\x01x`` (one + char string ``"x"``). The second character string's length byte then comes + from the next record's name (``\\x00``, root domain), so the HINFO would + silently parse as ``cpu="x", os=""`` but leave the offset one byte past + the declared end, smearing the second record's framing. With the per-record + boundary check the bogus HINFO is dropped and the second record decodes. + """ + packet = ( + b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00" + b"\x04test\x05local\x00" + b"\x00\x0d\x80\x01" + b"\x00\x00\x11\x94" + b"\x00\x02" + b"\x01x" + b"\x00" + b"\x00\x0c\x00\x01" + b"\x00\x00\x11\x94" + b"\x00\x02" + b"\xc0\x0c" + ) + parsed = r.DNSIncoming(packet) + answers = parsed.answers() + assert not any(isinstance(a, r.DNSHinfo) for a in answers) + ptrs = [a for a in answers if isinstance(a, r.DNSPointer)] + assert len(ptrs) == 1 + assert ptrs[0].alias == "test.local." + + def test_records_same_packet_share_fate(): """Test records in the same packet all have the same created time.""" out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA) From e95297f1215b414a351216ec936a240fe59529da Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 19:04:48 +0000 Subject: [PATCH 1421/1433] 0.149.13 Automatically generated by python-semantic-release --- CHANGELOG.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219da3bcf..f76584825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ +## v0.149.13 (2026-05-20) + +### Bug Fixes + +- Bound record payload reads against rdlength overrun + ([#1756](https://github.com/python-zeroconf/python-zeroconf/pull/1756), + [`5444495`](https://github.com/python-zeroconf/python-zeroconf/commit/544449596e645fcaad3834fa0cb614a54f847a82)) + +### Documentation + +- Clarify LGPL-2.1-or-later license in README + ([#1763](https://github.com/python-zeroconf/python-zeroconf/pull/1763), + [`28bb01f`](https://github.com/python-zeroconf/python-zeroconf/commit/28bb01f23951f7883d8c3af66b6d537c34c516c7)) + +### Refactoring + +- Extract loopback Zeroconf fixtures and mock_incoming_msg helper + ([#1758](https://github.com/python-zeroconf/python-zeroconf/pull/1758), + [`cb0af4a`](https://github.com/python-zeroconf/python-zeroconf/commit/cb0af4a8f4cdaad3721a2851d9fa17709d39ae62)) + + ## v0.149.12 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index d1c4eb791..b63728c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.12" +version = "0.149.13" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index c6dcba971..2d13b848a 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.12" +__version__ = "0.149.13" __license__ = "LGPL" From 343dc7a305b47a574f58265081f172ed54f461bb Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 12:17:42 -0700 Subject: [PATCH 1422/1433] test: shave loopback timing overhead from remaining slow tests (#1760) --- src/zeroconf/_core.py | 9 ++++++++- tests/conftest.py | 25 ++++++++++++++++--------- tests/services/test_info.py | 1 + tests/test_asyncio.py | 12 ++++++------ tests/test_services.py | 2 +- tests/utils/test_asyncio.py | 12 ++++++++---- 6 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 1814e86c1..f184b6fa5 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -104,6 +104,13 @@ _REGISTER_BROADCASTS = 3 +# RFC 6762 §8.1 thundering-herd avoidance: wait a random +# 0-250ms before the first probe so simultaneously-started +# responders don't collide. We default to 150-250ms to +# preserve existing timing assumptions; tests on loopback +# may patch this lower via the `quick_timing` fixture. +_PROBE_RANDOM_DELAY_INTERVAL = (150, 250) # ms + def async_send_with_transport( log_debug: bool, @@ -561,7 +568,7 @@ async def async_check_service( # Wait a random amount of time up avoid collisions and avoid # a thundering herd when multiple services are started on the network - await self.async_wait(random.randint(150, 250)) # noqa: S311 + await self.async_wait(random.randint(*_PROBE_RANDOM_DELAY_INTERVAL)) # noqa: S311 next_instance_number = 2 next_time = now = current_time_millis() diff --git a/tests/conftest.py b/tests/conftest.py index c281e2d47..a9c4f5900 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,17 +82,22 @@ def quick_timing() -> Generator[None]: """Shorten the probe/announce/goodbye/first-query intervals for tests on loopback. The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms, - _UNREGISTER_TIME=125ms, _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms) - exist for RFC 6762 interop on real networks (§8.1 thundering-herd - avoidance for probing, §5.2 for the initial-query delay). Tests on - 127.0.0.1 do not need them and pay 1-2s per register/unregister - cycle and 20-120ms per ServiceBrowser startup without this fixture. - Opt in by adding `quick_timing` to a test's argument list. + _UNREGISTER_TIME=125ms, _PROBE_RANDOM_DELAY_INTERVAL=150-250ms, + _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms) exist for RFC 6762 + interop on real networks (§8.1 thundering-herd avoidance for + probing, §5.2 for the initial-query delay). Tests on 127.0.0.1 + do not need them and pay 1-2s per register/unregister cycle, + 150-250ms per probe, and 20-120ms per ServiceBrowser startup + without this fixture. Opt in either by adding `quick_timing` + to a test's argument list or via + `@pytest.mark.usefixtures("quick_timing")` on the test or + its class. """ with ( patch.object(_core, "_CHECK_TIME", 10), patch.object(_core, "_REGISTER_TIME", 10), patch.object(_core, "_UNREGISTER_TIME", 10), + patch.object(_core, "_PROBE_RANDOM_DELAY_INTERVAL", (1, 5)), patch.object(service_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (1, 5)), ): yield @@ -105,9 +110,11 @@ def quick_request_timing() -> Generator[None]: The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762 §5.2) help spread queries from multiple clients on real networks. On loopback they're pure overhead — get_service_info-style tests - wait ~250ms before the first query even fires. Opt in by adding - `quick_request_timing` to a test's argument list, then drop the - test's own timeouts (which had to accommodate that delay). + wait ~250ms before the first query even fires. Opt in either by + adding `quick_request_timing` to a test's argument list or via + `@pytest.mark.usefixtures("quick_request_timing")` on the test + or its class, then drop the test's own timeouts (which had to + accommodate that delay). """ with ( patch.object(service_info, "_LISTENER_TIME", 10), diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 56146852f..09d3d989c 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -251,6 +251,7 @@ def test_service_info_rejects_expired_records(self): @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") + @pytest.mark.usefixtures("quick_request_timing") def test_get_info_partial(self): zc = r.Zeroconf(interfaces=["127.0.0.1"]) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e4449b6cf..9c3871802 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -608,7 +608,7 @@ async def test_async_wait_unblocks_on_update(quick_timing: None) -> None: @pytest.mark.asyncio -async def test_service_info_async_request(quick_timing: None) -> None: +async def test_service_info_async_request(quick_timing: None, quick_request_timing: None) -> None: """Test registering services broadcasts and query with AsyncServceInfo.async_request.""" if not has_working_ipv6() or os.environ.get("SKIP_IPV6"): pytest.skip("Requires IPv6") @@ -710,13 +710,13 @@ async def test_service_info_async_request(quick_timing: None) -> None: aiozc.zeroconf.out_delay_queue.queue.clear() aiosinfo = AsyncServiceInfo(type_, registration_name) _clear_cache(aiozc.zeroconf) - # Generating the race condition is almost impossible - # without patching since its a TOCTOU race. 1500ms covers - # the initial _LISTENER_TIME + random delay (200-320ms) and - # leaves plenty of margin for the loopback response to land + # Generating the race condition is almost impossible without + # patching since it's a TOCTOU race. Under `quick_request_timing` + # the first QU query fires at ~10ms and the QM follow-up at ~15ms; + # 300ms leaves plenty of margin for the loopback response to land # before the loop times out. with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, 1500) + await aiosinfo.async_request(aiozc.zeroconf, 300) assert aiosinfo is not None assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")] diff --git a/tests/test_services.py b/tests/test_services.py index 922046e39..218afc2a1 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -33,7 +33,7 @@ def teardown_module(): class ListenerTest(unittest.TestCase): - @pytest.mark.usefixtures("quick_timing") + @pytest.mark.usefixtures("quick_timing", "quick_request_timing") def test_integration_with_listener_class(self): sub_service_added = Event() service_added = Event() diff --git a/tests/utils/test_asyncio.py b/tests/utils/test_asyncio.py index 361faa2b8..665bc8674 100644 --- a/tests/utils/test_asyncio.py +++ b/tests/utils/test_asyncio.py @@ -86,18 +86,22 @@ async def _still_running(): await asyncio.sleep(5) def _run_coro() -> None: - runcoro_thread_ready.set() assert loop is not None + future = asyncio.run_coroutine_threadsafe(_still_running(), loop) + runcoro_thread_ready.set() with contextlib.suppress(concurrent.futures.TimeoutError): - asyncio.run_coroutine_threadsafe(_still_running(), loop).result(1) + future.result(0.1) runcoro_thread = threading.Thread(target=_run_coro, daemon=True) runcoro_thread.start() runcoro_thread_ready.wait() - time.sleep(0.1) assert loop is not None - aioutils.shutdown_loop(loop) + # Patch _TASK_AWAIT_TIMEOUT so the inner `asyncio.wait` returns + # within 50ms instead of blocking the full 1s on the deliberately + # never-completing _still_running() task. + with patch.object(aioutils, "_TASK_AWAIT_TIMEOUT", 0.05): + aioutils.shutdown_loop(loop) for _ in range(5): if not loop.is_running(): break From 3e5ac4fbc5cd8d8b5ffed5cc8994782e7157cfad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 14:30:13 -0500 Subject: [PATCH 1423/1433] test: scale aggregation timings 10x to speed up timing-dependent tests (#1759) --- tests/conftest.py | 27 ++++++++++++++ tests/test_handlers.py | 82 +++++++++++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a9c4f5900..1a76efe98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,6 +103,33 @@ def quick_timing() -> Generator[None]: yield +@pytest.fixture +def quick_aggregation_timing() -> Generator[None]: + """Scale multicast aggregation / network-protection delays 10x for tests. + + The aggregation tests in `tests/test_handlers.py` verify timing- + dependent behaviour of `MulticastOutgoingQueue`: aggregation window, + network protection (~1s), and protected aggregation. The behaviour + under test is a ratio of these constants — the exact wall-clock + values are not the contract — so scaling them down and the test + sleeps in lock-step preserves what is tested while dropping each + test from ~3s to ~0.3s. + + The patches must be in place before `AsyncZeroconf(...)` is + constructed because `MulticastOutgoingQueue` reads the constants at + init time and stashes them on the instance. The per-queue + `_multicast_delay_random_min` / `_max` jitter (1-5ms here) can + still be set on the queue instance after construction by the test + itself — those slots are `cdef public` in the .pxd. + """ + with ( + patch.object(_core, "_AGGREGATION_DELAY", 50), + patch.object(_core, "_PROTECTED_AGGREGATION_DELAY", 20), + patch.object(_core, "_ONE_SECOND", 100), + ): + yield + + @pytest.fixture def quick_request_timing() -> Generator[None]: """Shorten the initial-query delay used by AsyncServiceInfo.async_request. diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ca5bcabb5..69f3c826a 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1601,14 +1601,23 @@ async def test_duplicate_goodbye_answers_in_packet(): @pytest.mark.asyncio -async def test_response_aggregation_timings(run_isolated): - """Verify multicast responses are aggregated.""" +@pytest.mark.usefixtures("quick_aggregation_timing") +async def test_response_aggregation_timings(run_isolated: None) -> None: + """Verify multicast responses are aggregated. + + Aggregation / network-protection constants are scaled 10x by + ``quick_aggregation_timing``; the asserted ratios are unchanged + but each phase finishes in ~1/10 the wall time. + """ type_ = "_mservice._tcp.local." type_2 = "_mservice2._tcp.local." type_3 = "_mservice3._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() + for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue): + queue._multicast_delay_random_min = 1 + queue._multicast_delay_random_max = 5 name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -1673,9 +1682,10 @@ async def test_response_aggregation_timings(run_isolated): protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - await asyncio.sleep(0.7) + await asyncio.sleep(0.07) - # Should aggregate into a single answer with up to a 500ms + 120ms delay + # Should aggregate into a single answer with up to a 50ms + 5ms delay + # (scaled from 500ms + 120ms by `quick_aggregation_timing`). calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1686,10 +1696,10 @@ async def test_response_aggregation_timings(run_isolated): send_mock.reset_mock() protocol.datagram_received(query3.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - await asyncio.sleep(0.3) + await asyncio.sleep(0.03) - # Should send within 120ms since there are no other - # answers to aggregate with + # Should send within 12ms (scaled max random delay) since there are + # no other answers to aggregate with. calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1698,21 +1708,21 @@ async def test_response_aggregation_timings(run_isolated): assert info3.dns_pointer() in incoming.answers() send_mock.reset_mock() - # Because the response was sent in the last second we need to make - # sure the next answer is delayed at least a second + # Because the response was sent in the last 100ms (scaled 1s) we + # need to make sure the next answer is delayed at least that long. aiozc.zeroconf.engine.protocols[0].datagram_received( query4.packets()[0], ("127.0.0.1", const._MDNS_PORT) ) - await asyncio.sleep(0.5) + await asyncio.sleep(0.05) - # After 0.5 seconds it should not have been sent + # After 50ms it should not have been sent. # Protect the network against excessive packet flooding # https://datatracker.ietf.org/doc/html/rfc6762#section-14 calls = send_mock.mock_calls assert len(calls) == 0 send_mock.reset_mock() - await asyncio.sleep(1.2) + await asyncio.sleep(0.12) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1723,14 +1733,30 @@ async def test_response_aggregation_timings(run_isolated): @pytest.mark.asyncio -async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): - """Verify multicast responses that are aggregated do not take longer than 620ms to send. - - 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" +@pytest.mark.usefixtures("quick_aggregation_timing") +async def test_response_aggregation_timings_multiple( + run_isolated: None, disable_duplicate_packet_suppression: None +) -> None: + """Verify multicast responses that are aggregated do not take longer than 62ms to send. + + Aggregation / network-protection constants are scaled 10x by + ``quick_aggregation_timing`` (500ms→50ms, 200ms→20ms, 1000ms→100ms) + and the per-queue jitter is set to 1-5ms below. The asserted + ratios are the same as the production behaviour the test pins — + aggregation window, network protection, protected aggregation — + only the absolute durations are scaled. + """ type_2 = "_mservice2._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() + # Scale the queues' random jitter to match the 10x scaled + # additional / aggregation delays; without this, the 20-120ms + # jitter would dominate the scaled window and make timing assertions + # unreliable. + for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue): + queue._multicast_delay_random_min = 1 + queue._multicast_delay_random_max = 5 name = "xxxyyy" registration_name2 = f"{name}.{type_2}" @@ -1760,7 +1786,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - await asyncio.sleep(0.2) + await asyncio.sleep(0.02) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1772,7 +1798,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - await asyncio.sleep(1.2) + await asyncio.sleep(0.12) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1787,19 +1813,19 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - # The minimum protected send_after is 1000ms + 20ms random; sleep - # well under that so coarse timers on slow runners cannot push the - # send into this window and flake the assertion. - await asyncio.sleep(0.5) + # Scaled: minimum protected send_after is 100ms + 1-5ms random; + # sleep well under that so coarse timers on slow runners cannot + # push the send into this window and flake the assertion. + await asyncio.sleep(0.05) calls = send_mock.mock_calls assert len(calls) == 0 - # 1000ms (1s network protection delays) - # - 500ms (already slept) - # + 120ms (maximum random delay) - # + 200ms (maximum protected aggregation delay) - # + 20ms (execution time) - await asyncio.sleep(millis_to_seconds(1000 - 500 + 120 + 200 + 20)) + # 100ms (scaled 1s network protection) + # - 50ms (already slept) + # + 5ms (scaled maximum random delay) + # + 20ms (scaled protected aggregation delay) + # + 5ms (execution slack) + await asyncio.sleep(millis_to_seconds(100 - 50 + 5 + 20 + 5)) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] From 90a5a39df7400926bc5498a86b9fe4c46eaffd44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 14:37:23 -0500 Subject: [PATCH 1424/1433] test: add blockbuster to detect blocking calls in asyncio tests (#1761) --- poetry.lock | 31 ++++++++++++++++++++++-- pyproject.toml | 1 + tests/conftest.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 08dbfd5f0..9215b275c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,6 +40,21 @@ files = [ {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] +[[package]] +name = "blockbuster" +version = "1.5.26" +description = "Utility to detect blocking calls in the async event loop" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb"}, + {file = "blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629"}, +] + +[package.dependencies] +forbiddenfruit = {version = ">=0.1.4", markers = "implementation_name == \"cpython\""} + [[package]] name = "certifi" version = "2025.1.31" @@ -348,6 +363,18 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "forbiddenfruit" +version = "0.1.4" +description = "Patch python built-in objects" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "implementation_name == \"cpython\"" +files = [ + {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, +] + [[package]] name = "idna" version = "3.15" @@ -1003,7 +1030,7 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -1018,4 +1045,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "d5c5962b015fd229695b0a8024a848e7f11878a3efc36cb9a48c2279f48a9777" +content-hash = "c0d62f11cb94761d233c73a46ff3f626640ae3ef3af9a2ece9f37095a47d4c71" diff --git a/pyproject.toml b/pyproject.toml index b63728c6d..94e4a08f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=5.0.2,<6.0" +blockbuster = ">=1.5.5,<2.0.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" diff --git a/tests/conftest.py b/tests/conftest.py index 1a76efe98..573b93949 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations import threading -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Generator, Iterator from unittest.mock import patch import pytest @@ -15,6 +15,64 @@ from zeroconf._services import info as service_info from zeroconf.asyncio import AsyncZeroconf +try: + from blockbuster import BlockBuster, blockbuster_ctx +except ImportError: # platforms without blockbuster (e.g. PyPy under QEMU) + BlockBuster = None # type: ignore[assignment,misc] + blockbuster_ctx = None # type: ignore[assignment] + +_BENCHMARKS_DIR = "tests/benchmarks" + +# Tests that perform sync IO inside the asyncio event loop and trip +# blockbuster. Marked xfail (strict=False) so CI stays green; pop +# entries as the underlying blocking calls get fixed. Most of the +# `test_async_service_registration*` and `test_async_tasks` entries +# share a single root cause: `Zeroconf.async_close()` -> ... -> +# `ServiceBrowser.cancel()` calls `Thread.join()` to drain the +# dedicated browser thread, and on Python 3.10-3.12 the thread is +# still alive when the join happens. `test_use_asyncio_false_*` is +# by design (sync bootstrap when `use_asyncio=False` is requested from +# inside a running loop); `test_run_coro_with_timeout` exercises the +# sync-from-thread bridge intentionally. The strict=False marker keeps +# the suite green on the Python versions where the race resolves the +# other way. +_KNOWN_BLOCKING: frozenset[str] = frozenset( + { + "tests/test_asyncio.py::test_async_service_registration", + "tests/test_asyncio.py::test_async_service_registration_with_server_missing", + "tests/test_asyncio.py::test_async_service_registration_same_server_different_ports", + "tests/test_asyncio.py::test_async_service_registration_same_server_same_ports", + "tests/test_asyncio.py::test_async_tasks", + "tests/test_core.py::Framework::test_use_asyncio_false_forces_thread_when_loop_running", + "tests/utils/test_asyncio.py::test_run_coro_with_timeout", + } +) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Mark known-blocking tests xfail so blockbuster doesn't fail the suite.""" + if blockbuster_ctx is None: + return + marker = pytest.mark.xfail( + reason="blockbuster: blocking call in asyncio path", + strict=False, + ) + for item in items: + if item.nodeid in _KNOWN_BLOCKING: + item.add_marker(marker) + + +@pytest.fixture(autouse=True) +def blockbuster( + request: pytest.FixtureRequest, +) -> Iterator[BlockBuster | None]: + """Fail any test that performs a blocking call inside the asyncio loop.""" + if blockbuster_ctx is None or _BENCHMARKS_DIR in str(request.node.fspath): + yield None + return + with blockbuster_ctx() as bb: + yield bb + @pytest.fixture(autouse=True) def verify_threads_ended(): From 4ffba87177fcc11a0fd8ccb7d93c6996a1269a26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 15:03:41 -0500 Subject: [PATCH 1425/1433] test: widen QM follow-up window in info_asking_default test (#1765) --- tests/test_asyncio.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 9c3871802..d31c8d333 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -44,7 +44,6 @@ from . import ( LOOPBACK_FIND_TIMEOUT, - QUICK_REQUEST_TIMEOUT_MS, QuestionHistoryWithoutSuppression, _clear_cache, has_working_ipv6, @@ -1191,10 +1190,12 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): with patch.object(zeroconf_info, "async_send", send): aiosinfo = AsyncServiceInfo(type_, registration_name) # Patch _is_complete so we send multiple times. Under - # `quick_request_timing` both the QU query at 0ms and the QM - # query at ~15ms land well inside QUICK_REQUEST_TIMEOUT_MS. + # `quick_request_timing` the QU query fires at 0ms and the QM + # follow-up at ~11-15ms (10ms _LISTENER_TIME + 1-5ms jitter); + # 300ms absorbs macOS short-sleep quantization so the QM wake + # lands before the loop times out. with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False): - await aiosinfo.async_request(aiozc.zeroconf, QUICK_REQUEST_TIMEOUT_MS) + await aiosinfo.async_request(aiozc.zeroconf, 300) try: assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr] assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined] From 137a5d6c29389ffcd8abfba0925d8cbe58cb2c1b Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 15:58:17 -0700 Subject: [PATCH 1426/1433] fix: skip NSEC records in ServiceBrowser to suppress spurious updates (#1762) --- src/zeroconf/_services/browser.pxd | 1 + src/zeroconf/_services/browser.py | 8 +++- tests/services/test_browser.py | 67 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_services/browser.pxd b/src/zeroconf/_services/browser.pxd index 1ea99c82d..ef9dcafc3 100644 --- a/src/zeroconf/_services/browser.pxd +++ b/src/zeroconf/_services/browser.pxd @@ -14,6 +14,7 @@ cdef bint TYPE_CHECKING cdef object cached_possible_types cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT, _MAX_MSG_TYPICAL, _DNS_PACKET_HEADER_LEN cdef cython.uint _TYPE_PTR +cdef cython.uint _TYPE_NSEC cdef object _CLASS_IN cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED cdef cython.set _ADDRESS_RECORD_TYPES diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 3c3766a17..ed70793f8 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -63,6 +63,7 @@ _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT, + _TYPE_NSEC, _TYPE_PTR, ) @@ -678,7 +679,12 @@ def async_update_records(self, zc: Zeroconf, now: float_, records: list[RecordUp old_record = record_update.old record_type = record.type - if record_type is _TYPE_PTR: + # NSEC records assert non-existence of a record type + # (RFC 6762 §6.1); skip so we do not fire spurious updates. + if record_type == _TYPE_NSEC: + continue + + if record_type == _TYPE_PTR: if TYPE_CHECKING: record = cast(DNSPointer, record) pointer = record diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 342b2fc11..28b3d12e3 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1085,6 +1085,73 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de zc.close() +def test_service_browser_nsec_record_does_not_trigger_update(): + """NSEC records assert non-existence and must not fire ServiceStateChange.Updated.""" + zc = Zeroconf(interfaces=["127.0.0.1"]) + type_ = "_hap._tcp.local." + registration_name = f"xxxyyy.{type_}" + callbacks: list[tuple[str, str, str]] = [] + service_added = Event() + + class MyServiceListener(r.ServiceListener): + def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] + if name == registration_name: + callbacks.append(("add", type_, name)) + service_added.set() + + def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] + if name == registration_name: + callbacks.append(("remove", type_, name)) + + def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def] + if name == registration_name: + callbacks.append(("update", type_, name)) + + listener = MyServiceListener() + browser = r.ServiceBrowser(zc, type_, None, listener) + try: + desc = {"path": "/~paulsm/"} + address = socket.inet_aton("10.0.1.2") + info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address]) + + _inject_response( + zc, + mock_incoming_msg( + [ + info.dns_pointer(), + info.dns_service(), + info.dns_text(), + *info.dns_addresses(), + ] + ), + ) + assert service_added.wait(timeout=5), "add_service callback never fired" + + # NSEC inject runs synchronously through the event loop; once + # _inject_response returns, async_update_records has already + # decided not to enqueue a callback for the NSEC record. + _inject_response( + zc, + mock_incoming_msg( + [ + r.DNSNsec( + registration_name, + const._TYPE_NSEC, + const._CLASS_IN | const._CLASS_UNIQUE, + const._DNS_OTHER_TTL, + registration_name, + [const._TYPE_AAAA], + ), + ] + ), + ) + + assert callbacks == [("add", type_, registration_name)] + finally: + browser.cancel() + zc.close() + + def test_service_browser_uses_non_strict_names(): """Verify we can look for technically invalid names as we cannot change what others do.""" From 9470bd6dac65d4ee7dafb9fdedf1fde9d684780e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 20 May 2026 23:03:07 +0000 Subject: [PATCH 1427/1433] 0.149.14 Automatically generated by python-semantic-release --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76584825..2062a6cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ +## v0.149.14 (2026-05-20) + +### Bug Fixes + +- Skip NSEC records in ServiceBrowser to suppress spurious updates + ([#1762](https://github.com/python-zeroconf/python-zeroconf/pull/1762), + [`137a5d6`](https://github.com/python-zeroconf/python-zeroconf/commit/137a5d6c29389ffcd8abfba0925d8cbe58cb2c1b)) + +### Testing + +- Add blockbuster to detect blocking calls in asyncio tests + ([#1761](https://github.com/python-zeroconf/python-zeroconf/pull/1761), + [`90a5a39`](https://github.com/python-zeroconf/python-zeroconf/commit/90a5a39df7400926bc5498a86b9fe4c46eaffd44)) + +- Scale aggregation timings 10x to speed up timing-dependent tests + ([#1759](https://github.com/python-zeroconf/python-zeroconf/pull/1759), + [`3e5ac4f`](https://github.com/python-zeroconf/python-zeroconf/commit/3e5ac4fbc5cd8d8b5ffed5cc8994782e7157cfad)) + +- Shave loopback timing overhead from remaining slow tests + ([#1760](https://github.com/python-zeroconf/python-zeroconf/pull/1760), + [`343dc7a`](https://github.com/python-zeroconf/python-zeroconf/commit/343dc7a305b47a574f58265081f172ed54f461bb)) + +- Widen QM follow-up window in info_asking_default test + ([#1765](https://github.com/python-zeroconf/python-zeroconf/pull/1765), + [`4ffba87`](https://github.com/python-zeroconf/python-zeroconf/commit/4ffba87177fcc11a0fd8ccb7d93c6996a1269a26)) + + ## v0.149.13 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 94e4a08f1..f0e8d4ae3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.13" +version = "0.149.14" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 2d13b848a..5b9d7fef2 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.13" +__version__ = "0.149.14" __license__ = "LGPL" From e2352ea84437d6fca81dfbdc41116feaaf45fefc Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 19:58:40 -0700 Subject: [PATCH 1428/1433] fix: preserve scope_id when scoped AAAA arrives alongside unscoped (#1764) --- src/zeroconf/_services/info.pxd | 3 + src/zeroconf/_services/info.py | 85 +++++++++++-- tests/services/test_info.py | 219 +++++++++++++++++++++++++++++++- 3 files changed, 292 insertions(+), 15 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 3f65bc0a7..7fc85f9a7 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -107,6 +107,9 @@ cdef class ServiceInfo(RecordUpdateListener): ) cdef bint _process_record_threadsafe(self, object zc, DNSRecord record, double now) + @cython.locals(existing_idx=int, existing=object) + cdef bint _upsert_ipv6_address(self, object ip_addr) + @cython.locals(cache=DNSCache) cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type) diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 9b38de9d8..d080761d2 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -24,6 +24,7 @@ import asyncio import random +from collections.abc import Sequence from typing import TYPE_CHECKING, cast from .._cache import DNSCache @@ -108,6 +109,36 @@ from .._core import Zeroconf +def _index_of_same_address( + addresses: Sequence[ZeroconfIPv4Address | ZeroconfIPv6Address], + ip_addr: ZeroconfIPv4Address | ZeroconfIPv6Address, +) -> int: + """Return the index of an existing entry with the same packed bytes, or -1. + + Match by ``zc_integer`` so IPv6 addresses that differ only in + scope_id (one observed without scope on an IPv4 socket, another + observed with scope on an IPv6 socket) collapse to a single entry. + """ + target = ip_addr.zc_integer + for idx, existing in enumerate(addresses): + if existing.zc_integer == target: + return idx + return -1 + + +def _has_more_scope_info( + new_addr: ZeroconfIPv4Address | ZeroconfIPv6Address, + existing: ZeroconfIPv4Address | ZeroconfIPv6Address, +) -> bool: + """True if ``new_addr`` carries a scope_id the ``existing`` entry lacks.""" + if new_addr.version != 6: + return False + if TYPE_CHECKING: + assert isinstance(new_addr, ZeroconfIPv6Address) + assert isinstance(existing, ZeroconfIPv6Address) + return new_addr.scope_id is not None and existing.scope_id is None + + def instance_name_from_service_info(info: ServiceInfo, strict: bool = True) -> str: """Calculate the instance name from the ServiceInfo.""" # This is kind of funky because of the subtype based tests @@ -453,11 +484,49 @@ def _get_ip_addresses_from_cache_lifo( if record.is_expired(now): continue ip_addr = get_ip_address_object_from_record(record) - if ip_addr is not None and ip_addr not in address_list: + if ip_addr is None: + continue + # The cache keeps scoped and unscoped link-local AAAA + # records as separate entries because DNSAddress equality + # includes scope_id. Collapse them here so each address + # appears once; the scoped variant wins so callers of + # parsed_scoped_addresses() get a %- + # qualified link-local address when one was observed. + existing_idx = _index_of_same_address(address_list, ip_addr) + if existing_idx == -1: address_list.append(ip_addr) + continue + # Move the re-seen address to the end so the later observation + # wins both in value (scope) and in LIFO position after reverse. + existing = address_list.pop(existing_idx) + address_list.append(ip_addr if _has_more_scope_info(ip_addr, existing) else existing) address_list.reverse() # Reverse to get LIFO order return address_list + def _upsert_ipv6_address(self, ip_addr: ZeroconfIPv6Address) -> bool: + """Insert or update an IPv6 address in LIFO order. + + Compares by integer (not IPv6Address equality, which respects + scope_id) so the same link-local address received first without + scope (IPv4 socket) and then with scope (IPv6 socket) collapses + to one entry. The scoped variant wins so parsed_scoped_addresses() + can return a qualified address. + """ + ipv6_addresses = self._ipv6_addresses + existing_idx = _index_of_same_address(ipv6_addresses, ip_addr) + if existing_idx == -1: + ipv6_addresses.insert(0, ip_addr) + return True + existing = ipv6_addresses[existing_idx] + if _has_more_scope_info(ip_addr, existing): + ipv6_addresses.pop(existing_idx) + ipv6_addresses.insert(0, ip_addr) + return True + if existing_idx != 0: + ipv6_addresses.pop(existing_idx) + ipv6_addresses.insert(0, existing) + return False + def _set_ipv6_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None: """Set IPv6 addresses from the cache.""" if TYPE_CHECKING: @@ -532,19 +601,7 @@ def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float if TYPE_CHECKING: assert isinstance(ip_addr, ZeroconfIPv6Address) - ipv6_addresses = self._ipv6_addresses - if ip_addr not in self._ipv6_addresses: - ipv6_addresses.insert(0, ip_addr) - return True - # Use int() to compare the addresses as integers - # since by default IPv6Address.__eq__ compares the - # the addresses on version and int which more than - # we need here since we know the version is 6. - if ip_addr.zc_integer != self._ipv6_addresses[0].zc_integer: - ipv6_addresses.remove(ip_addr) - ipv6_addresses.insert(0, ip_addr) - - return False + return self._upsert_ipv6_address(ip_addr) if record_key != self.key: return False diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 09d3d989c..219f52262 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -18,7 +18,8 @@ from zeroconf import DNSAddress, RecordUpdate, const from zeroconf._protocol.outgoing import DNSOutgoing from zeroconf._services import info -from zeroconf._services.info import ServiceInfo +from zeroconf._services.info import ServiceInfo, _has_more_scope_info +from zeroconf._utils.ipaddress import ZeroconfIPv4Address from zeroconf._utils.net import IPVersion from zeroconf.asyncio import AsyncZeroconf @@ -790,6 +791,222 @@ def test_scoped_addresses_from_cache(): zeroconf.close() +def test_scoped_address_preferred_when_unscoped_arrives_first_in_cache(): + """A scoped AAAA in the cache wins over an earlier unscoped copy of the same address.""" + type_ = "_http._tcp.local." + registration_name = f"scoped-first.{type_}" + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) + host = "scoped-first.local." + packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6") + + zeroconf.cache.async_add_records( + [ + r.DNSPointer( + type_, + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + registration_name, + ), + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + 0, + 0, + 80, + host, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=None, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=7, + ), + ] + ) + + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zeroconf) + assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%7"] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%7")] + zeroconf.close() + + +@pytest.mark.asyncio +async def test_scoped_address_replaces_unscoped_in_live_update(): + """A late-arriving scoped AAAA replaces a previously-stored unscoped variant.""" + type_ = "_http._tcp.local." + registration_name = f"scoped-live.{type_}" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + host = "scoped-live.local." + packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6") + + info = ServiceInfo(type_, registration_name, server=host) + now = r.current_time_millis() + unscoped = r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=None, + ) + scoped = r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=9, + ) + info.async_update_records(aiozc.zeroconf, now, [RecordUpdate(unscoped, None)]) + assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6"] + info.async_update_records(aiozc.zeroconf, now, [RecordUpdate(scoped, unscoped)]) + assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%9"] + await aiozc.async_close() + + +def test_scoped_address_kept_when_unscoped_arrives_after_in_cache(): + """Scoped AAAA seen first in iteration keeps its scope when an unscoped duplicate follows.""" + type_ = "_http._tcp.local." + registration_name = f"scoped-after.{type_}" + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) + host = "scoped-after.local." + packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6") + + zeroconf.cache.async_add_records( + [ + r.DNSPointer( + type_, + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + registration_name, + ), + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + 0, + 0, + 80, + host, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=5, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + packed, + scope_id=None, + ), + ] + ) + + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zeroconf) + assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%5"] + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%5")] + zeroconf.close() + + +def test_has_more_scope_info_returns_false_for_ipv4(): + """The scope_id helper short-circuits for IPv4 since A records carry no scope.""" + ip4 = ZeroconfIPv4Address("192.0.2.1") + assert _has_more_scope_info(ip4, ip4) is False + + +def test_scope_upgrade_preserves_lifo_recency_order(): + """A scoped AAAA that upgrades an earlier entry becomes the most recent in LIFO order.""" + type_ = "_http._tcp.local." + registration_name = f"reorder.{type_}" + zeroconf = r.Zeroconf(interfaces=["127.0.0.1"]) + host = "reorder.local." + link_local = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6") + ula = socket.inet_pton(socket.AF_INET6, "fdc8:d776:7cca:46ed::2") + + zeroconf.cache.async_add_records( + [ + r.DNSPointer( + type_, + const._TYPE_PTR, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + registration_name, + ), + r.DNSService( + registration_name, + const._TYPE_SRV, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + 0, + 0, + 80, + host, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + link_local, + scope_id=None, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + ula, + scope_id=None, + ), + r.DNSAddress( + host, + const._TYPE_AAAA, + const._CLASS_IN | const._CLASS_UNIQUE, + 120, + link_local, + scope_id=11, + ), + ] + ) + + info = ServiceInfo(type_, registration_name) + info.load_from_cache(zeroconf) + # The scoped link-local upgrade is the most recent observation, so it + # has to come first in LIFO order, ahead of the earlier unrelated ULA. + assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ + ip_address("fe80::52e:c2f2:bc5f:e9c6%11"), + ip_address("fdc8:d776:7cca:46ed::2"), + ] + assert info.parsed_scoped_addresses() == [ + "fe80::52e:c2f2:bc5f:e9c6%11", + "fdc8:d776:7cca:46ed::2", + ] + zeroconf.close() + + # This test uses asyncio because it needs to access the cache directly # which is not threadsafe @pytest.mark.asyncio From 413d3baef7e357309bc7a23a8cb99f6fb083d7ea Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 21 May 2026 03:04:32 +0000 Subject: [PATCH 1429/1433] 0.149.15 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2062a6cdd..a94c6b2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.15 (2026-05-21) + +### Bug Fixes + +- Preserve scope_id when scoped AAAA arrives alongside unscoped + ([#1764](https://github.com/python-zeroconf/python-zeroconf/pull/1764), + [`e2352ea`](https://github.com/python-zeroconf/python-zeroconf/commit/e2352ea84437d6fca81dfbdc41116feaaf45fefc)) + + ## v0.149.14 (2026-05-20) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index f0e8d4ae3..27f444d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.14" +version = "0.149.15" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 5b9d7fef2..78e0ddb0c 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.14" +__version__ = "0.149.15" __license__ = "LGPL" From fad86461630237d1f0c04e3745fef65e4cc3055c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 May 2026 08:55:20 -0500 Subject: [PATCH 1430/1433] fix: re-release for GHSA-qc2x-6f54-m6h9 (#1770) From 78670f7d05df4b4592677e88d57a82403286be8b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 21 May 2026 14:02:54 +0000 Subject: [PATCH 1431/1433] 0.149.16 Automatically generated by python-semantic-release --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/zeroconf/__init__.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94c6b2ea..d303d06a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.149.16 (2026-05-21) + +### Bug Fixes + +- Re-release for GHSA-qc2x-6f54-m6h9 + ([#1770](https://github.com/python-zeroconf/python-zeroconf/pull/1770), + [`fad8646`](https://github.com/python-zeroconf/python-zeroconf/commit/fad86461630237d1f0c04e3745fef65e4cc3055c)) + + ## v0.149.15 (2026-05-21) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 27f444d92..67badccfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "zeroconf" -version = "0.149.15" +version = "0.149.16" license = "LGPL-2.1-or-later" description = "A pure python implementation of multicast DNS service discovery" readme = "README.rst" diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index 78e0ddb0c..5b79402c5 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -88,7 +88,7 @@ __author__ = "Paul Scott-Murphy, William McBrine" __maintainer__ = "Jakub Stasiak " -__version__ = "0.149.15" +__version__ = "0.149.16" __license__ = "LGPL" From cb81e6768ca1d8988d2e9526157a6d731b329b22 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Thu, 21 May 2026 18:17:04 -0500 Subject: [PATCH 1432/1433] test: synchronise test_integration on browser first-query (#1771) --- tests/test_asyncio.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d31c8d333..58f5aaab9 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1066,6 +1066,19 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): "ash-2.local.", addresses=[socket.inet_aton("10.0.1.2")], ) + # Wait for the browser's first startup query to land (with an empty + # cache) before registering — otherwise on fast loopback the register + # may finish before the first query fires, and answers[0] picks up + # the known PTR via RFC 6762 §7.1 known-answer suppression. + await asyncio.wait_for(got_query.wait(), 1) + # Snapshot the first query's answers and reset the captures so the + # subsequent assertions don't have to predict whether further startup + # queries fire in real time (before the manual time_changed_millis + # loop) or under mock time. + first_answers = answers[0] + packets.clear() + answers.clear() + got_query.clear() task = await aio_zeroconf_registrar.async_register_service(info) await task loop = asyncio.get_running_loop() @@ -1084,7 +1097,9 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): got_query.clear() assert not unexpected_ttl.is_set() - assert len(packets) == _services_browser.STARTUP_QUERIES + # The first startup query was captured separately, so only the + # remaining STARTUP_QUERIES - 1 land here. + assert len(packets) == _services_browser.STARTUP_QUERIES - 1 packets.clear() # Wait for the first refresh query @@ -1098,12 +1113,12 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): assert len(packets) == 1 packets.clear() - assert len(answers) == _services_browser.STARTUP_QUERIES + 1 - # The first question should have no known answers - assert len(answers[0]) == 0 + assert len(answers) == _services_browser.STARTUP_QUERIES + # The first question (captured separately) should have no known answers + assert len(first_answers) == 0 # The rest of the startup questions should have # known answers - for answer_list in answers[1:-2]: + for answer_list in answers[:-2]: # Allow 0 or 1 answers due to random delays and timing assert len(answer_list) <= 1 # Once the TTL is reached, the last question should have no known answers From bd20c8efe320230b1dc65b45b7007a2be62e0b24 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Sun, 24 May 2026 14:20:31 -0500 Subject: [PATCH 1433/1433] test: add benchmarks for ipaddress object creation and hashing (#1773) --- tests/benchmarks/test_ipaddress.py | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/benchmarks/test_ipaddress.py diff --git a/tests/benchmarks/test_ipaddress.py b/tests/benchmarks/test_ipaddress.py new file mode 100644 index 000000000..60aea89c7 --- /dev/null +++ b/tests/benchmarks/test_ipaddress.py @@ -0,0 +1,48 @@ +"""Benchmarks for zeroconf._utils.ipaddress address objects.""" + +from __future__ import annotations + +from pytest_codspeed import BenchmarkFixture + +from zeroconf._utils.ipaddress import ZeroconfIPv4Address, ZeroconfIPv6Address + +_IPV4_STRS = [f"10.{(i >> 8) & 0xFF}.{i & 0xFF}.1" for i in range(1000)] +_IPV6_BYTES = [(0x20010DB8 << 96 | i).to_bytes(16, "big") for i in range(1000)] + + +def test_create_ipv4_addresses(benchmark: BenchmarkFixture) -> None: + """Benchmark constructing 1000 distinct IPv4 address objects.""" + + @benchmark + def _create() -> None: + for addr in _IPV4_STRS: + ZeroconfIPv4Address(addr) + + +def test_create_ipv6_addresses(benchmark: BenchmarkFixture) -> None: + """Benchmark constructing 1000 distinct IPv6 address objects.""" + + @benchmark + def _create() -> None: + for addr in _IPV6_BYTES: + ZeroconfIPv6Address(addr) + + +def test_hash_ipv4_address(benchmark: BenchmarkFixture) -> None: + """Benchmark hashing the same IPv4 address object 1000 times.""" + addr = ZeroconfIPv4Address("10.0.0.1") + + @benchmark + def _hash() -> None: + for _ in range(1000): + hash(addr) + + +def test_hash_ipv6_address(benchmark: BenchmarkFixture) -> None: + """Benchmark hashing the same IPv6 address object 1000 times.""" + addr = ZeroconfIPv6Address(b"\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01") + + @benchmark + def _hash() -> None: + for _ in range(1000): + hash(addr)